@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,110 @@
1
+ import type { AnyNode, WallFace, WallNode } from '@pascal-app/core'
2
+ import { getWallFaceBaseAt } from '@pascal-app/viewer'
3
+
4
+ // Snapping for the paint tool's wall region gestures, in face coordinates:
5
+ // `u` is metres from the wall start along the reference line (the chord on a
6
+ // curved wall), `v` metres above the face's own base. Modes mirror the 'polygon'
7
+ // snap context: `grid` rounds to the grid step, `lines` catches the face's own
8
+ // features within a tolerance, `off` (or Alt held) leaves the pointer raw.
9
+
10
+ export type WallRegionSnapMode = 'grid' | 'lines' | 'off'
11
+
12
+ export type WallRegionSnap = {
13
+ mode: WallRegionSnapMode
14
+ /** Grid step (m) used by `grid`. */
15
+ gridStep: number
16
+ /** Catch distance (m) used by `lines`. */
17
+ tolerance: number
18
+ }
19
+
20
+ /** One face's base runs, as `getWallFinishData(geometry).faceBase[face]` carries them. */
21
+ export type WallFaceBaseRuns = readonly { start: number; end: number; y: number }[] | null
22
+
23
+ export type WallRegionSnapTargets = { u: number[]; v: number[] }
24
+
25
+ /** The wainscot height a Horizontal region starts at. */
26
+ export const DEFAULT_WAINSCOT_HEIGHT = 0.9
27
+ /** Regions narrower or shorter than this are a slip, not a gesture. */
28
+ export const MIN_WALL_REGION_SIZE = 0.02
29
+ export const DEFAULT_LINE_SNAP_TOLERANCE = 0.1
30
+
31
+ export function resolveWallRegionSnapMode(flags: {
32
+ grid: boolean
33
+ magnetic: boolean
34
+ alt?: boolean
35
+ }): WallRegionSnapMode {
36
+ if (flags.alt) return 'off'
37
+ if (flags.grid) return 'grid'
38
+ if (flags.magnetic) return 'lines'
39
+ return 'off'
40
+ }
41
+
42
+ /** Base (local y) of a face at station `u`: 0 when the faces share the wall base. */
43
+ export function faceBaseAt(runs: WallFaceBaseRuns, u: number): number {
44
+ return runs?.length ? getWallFaceBaseAt({ faceBase: { a: [...runs], b: [] } }, 'a', u) : 0
45
+ }
46
+
47
+ function uniqueSorted(values: number[]): number[] {
48
+ const out: number[] = []
49
+ for (const value of values.filter(Number.isFinite).sort((a, b) => a - b))
50
+ if (out.length === 0 || value - out.at(-1)! > 1e-6) out.push(value)
51
+ return out
52
+ }
53
+
54
+ /**
55
+ * What a region on this face snaps to: the wall ends, openings' jambs, sills
56
+ * and heads, other regions' bounds on the same face, the face base and the
57
+ * wainscot height. Opening heights are local y, so each is measured above the
58
+ * face base under the opening's centre.
59
+ */
60
+ export function wallRegionSnapTargets(
61
+ wall: Pick<WallNode, 'start' | 'end' | 'children' | 'faceRegions'>,
62
+ face: WallFace,
63
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
64
+ runs: WallFaceBaseRuns = null,
65
+ options: { excludeRegionId?: string } = {},
66
+ ): WallRegionSnapTargets {
67
+ const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
68
+ const u = [0, length]
69
+ const v = [0, DEFAULT_WAINSCOT_HEIGHT]
70
+ for (const childId of wall.children ?? []) {
71
+ const child = nodes[childId]
72
+ if (child?.type !== 'door' && child?.type !== 'window') continue
73
+ const [x, y] = child.position
74
+ u.push(x - child.width / 2, x + child.width / 2)
75
+ const base = faceBaseAt(runs, x)
76
+ v.push(y - child.height / 2 - base, y + child.height / 2 - base)
77
+ }
78
+ for (const region of wall.faceRegions ?? []) {
79
+ if (region.face !== face || region.id === options.excludeRegionId) continue
80
+ for (const bound of [region.u0, region.u1]) if (bound !== undefined) u.push(bound)
81
+ for (const bound of [region.v0, region.v1]) if (bound !== undefined) v.push(bound)
82
+ }
83
+ return {
84
+ u: uniqueSorted(u.filter((value) => value >= -1e-6 && value <= length + 1e-6)),
85
+ v: uniqueSorted(v.filter((value) => value >= -1e-6)),
86
+ }
87
+ }
88
+
89
+ /** Snaps one face coordinate. `target` is the feature it caught in `lines` mode. */
90
+ export function snapWallRegionValue(
91
+ value: number,
92
+ targets: readonly number[],
93
+ snap: WallRegionSnap,
94
+ ): { value: number; target: number | null } {
95
+ if (snap.mode === 'grid' && snap.gridStep > 0) {
96
+ const stepped = Math.round(value / snap.gridStep) * snap.gridStep
97
+ return { value: Math.round(stepped * 1e6) / 1e6, target: null }
98
+ }
99
+ if (snap.mode === 'lines') {
100
+ let best: number | null = null
101
+ for (const target of targets)
102
+ if (
103
+ Math.abs(target - value) <= snap.tolerance &&
104
+ (best === null || Math.abs(target - value) < Math.abs(best - value))
105
+ )
106
+ best = target
107
+ if (best !== null) return { value: best, target: best }
108
+ }
109
+ return { value, target: null }
110
+ }
@@ -84,3 +84,17 @@ export function closeWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAn
84
84
  const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
85
85
  startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 0, options)
86
86
  }
87
+
88
+ export function openWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) {
89
+ const node = useScene.getState().nodes[windowId]
90
+ if (
91
+ node?.type !== 'window' ||
92
+ node.openingKind === 'opening' ||
93
+ !isOperableWindowType(node.windowType)
94
+ ) {
95
+ return
96
+ }
97
+
98
+ const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
99
+ startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 1, options)
100
+ }
@@ -50,7 +50,10 @@ function polygonContainsWithTolerance(
50
50
  })
51
51
  }
52
52
 
53
- function polygonMatchesZoneFootprint(surfacePolygon: Point2D[], footprint: Point2D[]): boolean {
53
+ export function polygonMatchesZoneFootprint(
54
+ surfacePolygon: Point2D[],
55
+ footprint: Point2D[],
56
+ ): boolean {
54
57
  if (surfacePolygon.length < 3) return false
55
58
  return (
56
59
  polygonContainsWithTolerance(footprint, surfacePolygon, SURFACE_POLYGON_TOLERANCE) &&
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import { nextZoneName } from './zone-name'
4
+
5
+ const zone = (id: string, name?: string) => ({ id, type: 'zone', name }) as unknown as AnyNode
6
+
7
+ describe('nextZoneName', () => {
8
+ test('room zones do not consume zone numbers', () => {
9
+ expect(nextZoneName({ a: zone('a', 'Room'), b: zone('b', 'Kitchen') })).toBe('Zone 1')
10
+ })
11
+
12
+ test('continues after the highest existing Zone N', () => {
13
+ expect(nextZoneName({ a: zone('a', 'Zone 1'), b: zone('b', 'Zone 4'), c: zone('c') })).toBe(
14
+ 'Zone 5',
15
+ )
16
+ })
17
+ })
@@ -0,0 +1,17 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+
3
+ const ZONE_NAME = /^Zone (\d+)$/
4
+
5
+ /**
6
+ * Next free "Zone N" name. Rooms own zones too ("Room", "Kitchen", …), so
7
+ * counting every zone would skip numbers; only existing "Zone N" names count.
8
+ */
9
+ export function nextZoneName(nodes: Record<string, AnyNode>): string {
10
+ let highest = 0
11
+ for (const node of Object.values(nodes)) {
12
+ if (node.type !== 'zone') continue
13
+ const match = ZONE_NAME.exec(node.name ?? '')
14
+ if (match) highest = Math.max(highest, Number(match[1]))
15
+ }
16
+ return `Zone ${highest + 1}`
17
+ }
@@ -0,0 +1,223 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { createElement } from 'react'
3
+ import type { SidebarTab } from '../components/ui/sidebar/tab-bar'
4
+ import { useShownSidebarPanel } from '../lib/sidebar-panel'
5
+ import { withSelectionHarness } from '../test-utils/selection-harness'
6
+ import useEditor, { DEFAULT_PERSISTED_EDITOR_UI_STATE, editorUiStateOnOpen } from './use-editor'
7
+ import { sceneLayout } from './view-layout'
8
+
9
+ /**
10
+ * A project always opens in select mode.
11
+ *
12
+ * The armed tool used to ride along in the persisted UI preferences, so whatever
13
+ * the last session left armed came back on the next load and the first canvas
14
+ * click drew a wall (or dropped an item) instead of selecting what the user
15
+ * clicked on. `partialize` no longer writes the tool, and a blob written before
16
+ * that must not re-arm one either — which is what these cover. Everything else
17
+ * in the blob is a real preference and still has to come back.
18
+ */
19
+ describe('opening a project', () => {
20
+ test('a persisted wall tool is not armed, and the preferences beside it survive', () => {
21
+ const state = editorUiStateOnOpen({
22
+ phase: 'building',
23
+ toolMode: { mode: 'build', tool: 'wall' },
24
+ mode: 'build',
25
+ tool: 'wall',
26
+ viewLayouts: { edit: sceneLayout('split') },
27
+ })
28
+
29
+ expect(state.toolMode).toEqual({ mode: 'select' })
30
+ expect(state.mode).toBe('select')
31
+ expect(state.tool).toBeNull()
32
+ expect(state.phase).toBe('building')
33
+ expect(state.viewLayouts).toEqual({ edit: sceneLayout('split') })
34
+ })
35
+
36
+ test('the 2D plan opens in select mode as well', () => {
37
+ const state = editorUiStateOnOpen({
38
+ phase: 'building',
39
+ toolMode: { mode: 'build', tool: 'slab' },
40
+ mode: 'build',
41
+ tool: 'slab',
42
+ viewLayouts: { edit: sceneLayout('2d') },
43
+ })
44
+
45
+ expect(state.viewLayouts).toEqual({ edit: sceneLayout('2d') })
46
+ expect(state.mode).toBe('select')
47
+ expect(state.tool).toBeNull()
48
+ })
49
+
50
+ test('a persisted item tool leaves no catalog category armed either', () => {
51
+ const state = editorUiStateOnOpen({
52
+ phase: 'building',
53
+ toolMode: { mode: 'build', tool: 'item' },
54
+ mode: 'build',
55
+ tool: 'item',
56
+ catalogCategory: 'kitchen',
57
+ })
58
+
59
+ expect(state.phase).toBe('building')
60
+ expect(state.mode).toBe('select')
61
+ expect(state.tool).toBeNull()
62
+ expect(state.catalogCategory).toBeNull()
63
+ })
64
+
65
+ test('a phase stored before Structure and Furnish merged opens inside the building', () => {
66
+ // Browsers still hold `structure` / `furnish` from before the merge.
67
+ for (const stored of ['structure', 'furnish']) {
68
+ expect(editorUiStateOnOpen({ phase: stored as never }).phase).toBe('building')
69
+ }
70
+ expect(editorUiStateOnOpen({ phase: 'site' }).phase).toBe('site')
71
+ expect(editorUiStateOnOpen({ phase: 'garden' as never }).phase).toBe('site')
72
+ })
73
+
74
+ test('the brush modes do not come back armed', () => {
75
+ // Paint and sculpt hold an interaction scope for the whole mode, so opening
76
+ // into one is the same class of surprise as opening into a build tool.
77
+ expect(editorUiStateOnOpen({ phase: 'site', mode: 'terrain-sculpt' }).mode).toBe('select')
78
+ expect(editorUiStateOnOpen({ phase: 'building', mode: 'material-paint' }).mode).toBe('select')
79
+ })
80
+
81
+ test('a blob from the Elements/Rooms layer days opens in select mode, its layer dropped', () => {
82
+ const state = editorUiStateOnOpen({
83
+ phase: 'building',
84
+ structureLayer: 'zones',
85
+ toolMode: { mode: 'build', tool: 'zone' },
86
+ mode: 'build',
87
+ tool: 'zone',
88
+ } as never)
89
+
90
+ expect(state).not.toHaveProperty('structureLayer')
91
+ expect(state.mode).toBe('select')
92
+ expect(state.tool).toBeNull()
93
+ })
94
+
95
+ test('an empty blob opens on the defaults', () => {
96
+ expect(editorUiStateOnOpen(null)).toEqual(DEFAULT_PERSISTED_EDITOR_UI_STATE)
97
+ })
98
+ })
99
+
100
+ const STORAGE_KEY = 'pascal-editor-ui-preferences'
101
+ const savedWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
102
+ afterEach(() => {
103
+ if (savedWindow) Object.defineProperty(globalThis, 'window', savedWindow)
104
+ else Reflect.deleteProperty(globalThis, 'window')
105
+ })
106
+
107
+ /** A browser's localStorage, seeded with what an earlier session left behind (a string as is). */
108
+ function stubStorage(stored: Record<string, unknown> = {}) {
109
+ const values = new Map(
110
+ Object.entries(stored).map(
111
+ ([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)] as const,
112
+ ),
113
+ )
114
+ const localStorage: Partial<Storage> = {
115
+ getItem: (key) => values.get(key) ?? null,
116
+ setItem: (key, value) => void values.set(key, value),
117
+ removeItem: (key) => void values.delete(key),
118
+ }
119
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: { localStorage } })
120
+ return { read: (key: string) => JSON.parse(values.get(key) ?? 'null') }
121
+ }
122
+
123
+ let fresh = 0
124
+ /** The editor store as a page load creates it: not yet rehydrated (`skipHydration`). */
125
+ async function loadEditorStore() {
126
+ const { default: useEditor } = await import(`./use-editor.tsx?load=${++fresh}`)
127
+ return useEditor as typeof import('./use-editor').default
128
+ }
129
+
130
+ describe('reopening the editor', () => {
131
+ // The editor rehydrates in a mount effect, after the host's own mount effects
132
+ // have written to the store. Each write saves the whole state, so a write
133
+ // saved before rehydrate would replace the stored preferences with the
134
+ // defaults before they are read.
135
+ test('a write before the store rehydrates leaves the stored panel to come back', async () => {
136
+ const storage = stubStorage({
137
+ [STORAGE_KEY]: { state: { sidebarPanels: { edit: 'ai' } }, version: 0 },
138
+ })
139
+ const useEditor = await loadEditorStore()
140
+
141
+ useEditor.getState().setCanFindNode(true)
142
+ await useEditor.persist.rehydrate()
143
+
144
+ expect(useEditor.getState().activeSidebarPanel).toBe('ai')
145
+ expect(useEditor.getState().canFindNode).toBe(true)
146
+ expect(storage.read(STORAGE_KEY).state.sidebarPanels).toEqual({ edit: 'ai' })
147
+ })
148
+
149
+ test('each workspace reopens its own panel, and a reload opens Edit on its own', async () => {
150
+ stubStorage()
151
+ const firstSession = await loadEditorStore()
152
+ await firstSession.persist.rehydrate()
153
+ const editor = () => firstSession.getState()
154
+
155
+ editor().setActiveSidebarPanel('ai')
156
+ editor().setWorkspaceMode('studio')
157
+ editor().setActiveSidebarPanel('studio-materials')
158
+ editor().setWorkspaceMode('edit')
159
+ expect(editor().activeSidebarPanel).toBe('ai')
160
+ editor().setWorkspaceMode('studio')
161
+ expect(editor().activeSidebarPanel).toBe('studio-materials')
162
+
163
+ const nextSession = await loadEditorStore()
164
+ await nextSession.persist.rehydrate()
165
+ expect(nextSession.getState().workspaceMode).toBe('edit')
166
+ expect(nextSession.getState().activeSidebarPanel).toBe('ai')
167
+ nextSession.getState().setWorkspaceMode('studio')
168
+ expect(nextSession.getState().activeSidebarPanel).toBe('studio-materials')
169
+ })
170
+
171
+ test('an unreadable stored blob opens on the defaults, and the next change replaces it', async () => {
172
+ const storage = stubStorage({ [STORAGE_KEY]: '{"state":{"sidebarPanels"' })
173
+ const useEditor = await loadEditorStore()
174
+ await useEditor.persist.rehydrate()
175
+
176
+ expect(useEditor.persist.hasHydrated()).toBe(true)
177
+ expect(useEditor.getState().activeSidebarPanel).toBe('build')
178
+ useEditor.getState().setActiveSidebarPanel('ai')
179
+ expect(storage.read(STORAGE_KEY).state.sidebarPanels).toEqual({ edit: 'ai' })
180
+ })
181
+
182
+ test("a tab whose preferences did not change leaves another tab's panel stored", async () => {
183
+ const storage = stubStorage()
184
+ const staleTab = await loadEditorStore()
185
+ await staleTab.persist.rehydrate()
186
+ staleTab.getState().setActiveSidebarPanel('build')
187
+ const otherTab = await loadEditorStore()
188
+ await otherTab.persist.rehydrate()
189
+ otherTab.getState().setActiveSidebarPanel('ai')
190
+
191
+ staleTab.getState().setCanFindNode(true)
192
+
193
+ expect(storage.read(STORAGE_KEY).state.sidebarPanels).toEqual({ edit: 'ai' })
194
+ })
195
+ })
196
+
197
+ describe('the rail', () => {
198
+ test('a remembered panel the rail lacks shows the first tab, keeps the pick, and comes back', async () => {
199
+ const tab = (id: string): SidebarTab => ({ id, label: id })
200
+ let shown = ''
201
+ function Rail({ tabs }: { tabs: SidebarTab[] }) {
202
+ shown = useShownSidebarPanel(tabs)
203
+ return null
204
+ }
205
+
206
+ await withSelectionHarness(async ({ render }) => {
207
+ useEditor.setState({
208
+ workspaceMode: 'edit',
209
+ activeSidebarPanel: 'nature',
210
+ sidebarPanels: { edit: 'nature' },
211
+ })
212
+
213
+ await render(createElement(Rail, { tabs: [tab('build'), tab('paint')] }))
214
+ expect(shown).toBe('build')
215
+ expect(useEditor.getState().activeSidebarPanel).toBe('build')
216
+ expect(useEditor.getState().sidebarPanels).toEqual({ edit: 'nature' })
217
+
218
+ await render(createElement(Rail, { tabs: [tab('build'), tab('paint'), tab('nature')] }))
219
+ expect(shown).toBe('nature')
220
+ expect(useEditor.getState().activeSidebarPanel).toBe('nature')
221
+ })
222
+ })
223
+ })
@@ -0,0 +1,188 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { armPaintFromShortcut } from '../hooks/use-keyboard'
3
+ import {
4
+ bindPaintPickHold,
5
+ isPaintErasing,
6
+ paintPickReturnMode,
7
+ paintRegionModeActive,
8
+ resetPaintMode,
9
+ usePaintRegionMode,
10
+ } from '../lib/paint-region-mode'
11
+ import useEditor from './use-editor'
12
+
13
+ const TILE = { materialPreset: 'library:tile', sourceTarget: 'wall' } as const
14
+ const sub = () => usePaintRegionMode.getState().mode
15
+ const editorMode = () => useEditor.getState().mode
16
+
17
+ function reset() {
18
+ useEditor.getState().setPhase('building')
19
+ useEditor.getState().armToolMode({ mode: 'select' })
20
+ useEditor.getState().setActivePaintMaterial(null)
21
+ resetPaintMode()
22
+ }
23
+ beforeEach(reset)
24
+ afterEach(reset)
25
+
26
+ describe('paint sub-mode state machine', () => {
27
+ test('the eraser enters paint mode directly from select, with no colour picked', () => {
28
+ expect(useEditor.getState().activePaintMaterial).toBeNull()
29
+ useEditor.getState().armMaterialPaint(undefined, 'erase')
30
+ expect(editorMode()).toBe('material-paint')
31
+ expect(sub()).toBe('erase')
32
+ expect(isPaintErasing()).toBe(true)
33
+ // Erasing is not a region gesture: whole-surface clicks own the pointer.
34
+ expect(paintRegionModeActive(editorMode())).toBe(false)
35
+ })
36
+
37
+ test('the paint tool enters on the last painting sub-mode, never on erase', () => {
38
+ useEditor.getState().armMaterialPaint(undefined, 'rectangle')
39
+ usePaintRegionMode.getState().setMode('erase')
40
+ useEditor.getState().armToolMode({ mode: 'select' })
41
+ useEditor.getState().armMaterialPaint()
42
+ expect(editorMode()).toBe('material-paint')
43
+ expect(sub()).toBe('rectangle')
44
+ expect(paintRegionModeActive(editorMode())).toBe(true)
45
+ })
46
+
47
+ test('the P shortcut while erasing switches back to painting', () => {
48
+ useEditor.getState().armMaterialPaint(undefined, 'polygon')
49
+ usePaintRegionMode.getState().setMode('erase')
50
+ armPaintFromShortcut()
51
+ expect(editorMode()).toBe('material-paint')
52
+ expect(sub()).toBe('polygon')
53
+ })
54
+
55
+ test('leaving paint mode by any route ends erasing and clears the notice', () => {
56
+ for (const leave of [
57
+ () => useEditor.getState().armToolMode({ mode: 'select' }),
58
+ () => useEditor.getState().setMode('delete'),
59
+ () => useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' }),
60
+ () => useEditor.getState().armToolMode({ mode: 'terrain-sculpt' }),
61
+ ]) {
62
+ reset()
63
+ useEditor.getState().armMaterialPaint(undefined, 'rectangle')
64
+ usePaintRegionMode.getState().setMode('erase')
65
+ usePaintRegionMode.getState().setNotice('Up to 8 regions per wall face')
66
+ leave()
67
+ expect(editorMode()).not.toBe('material-paint')
68
+ expect(sub()).toBe('rectangle')
69
+ expect(usePaintRegionMode.getState().notice).toBeNull()
70
+ }
71
+ })
72
+
73
+ test('picking a colour while erasing paints with it on the last painting sub-mode', () => {
74
+ useEditor.getState().armMaterialPaint(undefined, 'rectangle')
75
+ useEditor.getState().armMaterialPaint(undefined, 'erase')
76
+ useEditor.getState().armMaterialPaint(TILE)
77
+ expect(sub()).toBe('rectangle')
78
+ expect(useEditor.getState().activePaintMaterial?.materialPreset).toBe('library:tile')
79
+
80
+ usePaintRegionMode.getState().setMode('erase')
81
+ useEditor.getState().setActivePaintMaterial(TILE)
82
+ expect(sub()).toBe('rectangle')
83
+ })
84
+
85
+ test('switching sub-mode inside paint mode keeps the mode and remembers the drawing one', () => {
86
+ useEditor.getState().armMaterialPaint(TILE)
87
+ for (const mode of ['rectangle', 'erase', 'polygon', 'erase', 'surface'] as const) {
88
+ usePaintRegionMode.getState().setMode(mode)
89
+ expect(editorMode()).toBe('material-paint')
90
+ expect(sub()).toBe(mode)
91
+ }
92
+ expect(usePaintRegionMode.getState().drawMode).toBe('surface')
93
+ })
94
+
95
+ test('a new project starts on whole-surface paint', () => {
96
+ useEditor.getState().armMaterialPaint(undefined, 'polygon')
97
+ useEditor.getState().armToolMode({ mode: 'select' })
98
+ resetPaintMode()
99
+ useEditor.getState().armMaterialPaint()
100
+ expect(sub()).toBe('surface')
101
+ })
102
+ })
103
+
104
+ describe('eyedropper sub-mode', () => {
105
+ const key = (type: string, init: KeyboardEventInit) => {
106
+ const event = new Event(type, { cancelable: true }) as KeyboardEvent
107
+ Object.assign(event, { key: init.key, repeat: init.repeat ?? false })
108
+ Object.defineProperty(event, 'target', { value: null })
109
+ return event
110
+ }
111
+
112
+ test('the pick icon enters from select and a pick returns to the sub-mode it came from', () => {
113
+ useEditor.getState().armMaterialPaint(undefined, 'rectangle')
114
+ usePaintRegionMode.getState().setMode('pick')
115
+ expect(sub()).toBe('pick')
116
+ expect(paintRegionModeActive(editorMode())).toBe(false)
117
+ expect(paintPickReturnMode()).toBe('rectangle')
118
+ // What the paint path does with a picked material.
119
+ useEditor.getState().armMaterialPaint(TILE, paintPickReturnMode())
120
+ expect(sub()).toBe('rectangle')
121
+ expect(useEditor.getState().activePaintMaterial?.materialPreset).toBe('library:tile')
122
+
123
+ useEditor.getState().armToolMode({ mode: 'select' })
124
+ useEditor.getState().armMaterialPaint(undefined, 'pick')
125
+ expect(editorMode()).toBe('material-paint')
126
+ expect(sub()).toBe('pick')
127
+ })
128
+
129
+ test('picking while erasing returns to painting, not erasing', () => {
130
+ useEditor.getState().armMaterialPaint(undefined, 'polygon')
131
+ usePaintRegionMode.getState().setMode('erase')
132
+ usePaintRegionMode.getState().setMode('pick')
133
+ expect(paintPickReturnMode()).toBe('polygon')
134
+ })
135
+
136
+ test('Alt held while painting picks; releasing, blur or Escape goes back; never while erasing', () => {
137
+ const target = new EventTarget()
138
+ const unbind = bindPaintPickHold(target)
139
+ try {
140
+ useEditor.getState().armMaterialPaint(TILE, 'surface')
141
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
142
+ expect(sub()).toBe('pick')
143
+ target.dispatchEvent(key('keyup', { key: 'Alt' }))
144
+ expect(sub()).toBe('surface')
145
+
146
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
147
+ expect(sub()).toBe('pick')
148
+ target.dispatchEvent(new Event('blur'))
149
+ expect(sub()).toBe('surface')
150
+
151
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
152
+ const escapeKey = key('keydown', { key: 'Escape' })
153
+ target.dispatchEvent(escapeKey)
154
+ expect(escapeKey.defaultPrevented).toBe(true)
155
+ expect(sub()).toBe('surface')
156
+ target.dispatchEvent(key('keyup', { key: 'Alt' }))
157
+
158
+ // Erasing picks nothing: Alt does not turn the eyedropper on.
159
+ usePaintRegionMode.getState().setMode('erase')
160
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
161
+ expect(sub()).toBe('erase')
162
+ target.dispatchEvent(key('keyup', { key: 'Alt' }))
163
+
164
+ // The region sub-modes keep Alt for free placement.
165
+ usePaintRegionMode.getState().setMode('rectangle')
166
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
167
+ expect(sub()).toBe('rectangle')
168
+ target.dispatchEvent(key('keyup', { key: 'Alt' }))
169
+
170
+ // A pick taken while held is not undone by the release.
171
+ usePaintRegionMode.getState().setMode('surface')
172
+ target.dispatchEvent(key('keydown', { key: 'Alt' }))
173
+ useEditor.getState().armMaterialPaint(TILE, paintPickReturnMode())
174
+ target.dispatchEvent(key('keyup', { key: 'Alt' }))
175
+ expect(sub()).toBe('surface')
176
+ } finally {
177
+ unbind()
178
+ }
179
+ })
180
+
181
+ test('leaving paint mode ends the eyedropper', () => {
182
+ useEditor.getState().armMaterialPaint(undefined, 'pick')
183
+ usePaintRegionMode.setState({ picked: TILE })
184
+ useEditor.getState().armToolMode({ mode: 'select' })
185
+ expect(sub()).toBe('surface')
186
+ expect(usePaintRegionMode.getState().picked).toBeNull()
187
+ })
188
+ })