@pascal-app/editor 1.0.6 → 1.0.7

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 (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -1,14 +1,26 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { BuildingNode, LevelNode, useScene } from '@pascal-app/core'
2
+ import { BuildingNode, LevelNode, snapWorldXZToBuildingLocal, useScene } from '@pascal-app/core'
3
3
  import { useViewer } from '@pascal-app/viewer'
4
- import { getActiveBuildingId } from './world-grid-snap'
4
+ import useEditor from '../store/use-editor'
5
+ import useInteractionScope from '../store/use-interaction-scope'
6
+ import {
7
+ getActiveBuildingId,
8
+ getActiveGridPose,
9
+ resolveAlignmentForActiveBuilding,
10
+ resolveAlignmentForFloorplanView,
11
+ snapBuildingLocalToWorldGrid,
12
+ snapWorldXZForActiveBuilding,
13
+ } from './world-grid-snap'
5
14
 
6
15
  const originalNodes = useScene.getState().nodes
7
16
  const originalSelection = useViewer.getState().selection
17
+ const originalPhase = useEditor.getState().phase
8
18
 
9
19
  afterEach(() => {
10
20
  useScene.setState({ nodes: originalNodes } as never)
11
21
  useViewer.setState({ selection: originalSelection } as never)
22
+ useEditor.setState({ phase: originalPhase })
23
+ useInteractionScope.getState().end()
12
24
  })
13
25
 
14
26
  describe('getActiveBuildingId', () => {
@@ -46,3 +58,128 @@ describe('getActiveBuildingId', () => {
46
58
  expect(getActiveBuildingId()).toBe(building.id)
47
59
  })
48
60
  })
61
+
62
+ // A building the site planner set on its lot: turned and offset. The plan only
63
+ // carries the level, as it often does (`selection.buildingId` null).
64
+ const YAW = (17 * Math.PI) / 180
65
+ const POSITION: [number, number, number] = [12.37, 0, -8.91]
66
+
67
+ function selectTurnedBuilding() {
68
+ const level = LevelNode.parse({})
69
+ const building = BuildingNode.parse({
70
+ position: POSITION,
71
+ rotation: [0, YAW, 0],
72
+ children: [level.id],
73
+ })
74
+ useScene.setState({
75
+ nodes: { [building.id]: building, [level.id]: { ...level, parentId: building.id } },
76
+ } as never)
77
+ useViewer.setState({
78
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
79
+ } as never)
80
+ return building
81
+ }
82
+
83
+ describe("grid snap on the building's own lattice", () => {
84
+ test('a turned, offset building snaps square to itself, not to the world grid', () => {
85
+ selectTurnedBuilding()
86
+ expect(snapBuildingLocalToWorldGrid([1.26, 3.74], 0.5)).toEqual([1.5, 3.5])
87
+ expect(snapBuildingLocalToWorldGrid([1.26, 3.74], 0)).toEqual([1.26, 3.74])
88
+ })
89
+
90
+ test('an unturned building at the origin keeps the world grid, which is its lattice', () => {
91
+ const building = BuildingNode.parse({})
92
+ useScene.setState({ nodes: { [building.id]: building } } as never)
93
+ useViewer.setState({
94
+ selection: { buildingId: building.id, levelId: null, zoneId: null, selectedIds: [] },
95
+ } as never)
96
+ expect(snapBuildingLocalToWorldGrid([1.26, 3.74], 0.5)).toEqual([1.5, 3.5])
97
+ expect(snapWorldXZForActiveBuilding(1.26, 3.74, 0.5)).toEqual({
98
+ world: [1.5, 3.5],
99
+ local: [1.5, 3.5],
100
+ })
101
+ })
102
+
103
+ test('a world cursor lands on the building lattice, in both frames', () => {
104
+ selectTurnedBuilding()
105
+ const { world, local } = snapWorldXZForActiveBuilding(15.2, -4.4, 0.5)
106
+ const raw = snapWorldXZToBuildingLocal(15.2, -4.4, POSITION, YAW, 0).local
107
+ expect(local).toEqual(snapBuildingLocalToWorldGrid(raw, 0.5))
108
+ const back = snapWorldXZToBuildingLocal(world[0], world[1], POSITION, YAW, 0).local
109
+ expect(back[0]).toBeCloseTo(local[0], 9)
110
+ expect(back[1]).toBeCloseTo(local[1], 9)
111
+ })
112
+ })
113
+
114
+ describe('resolveAlignmentForFloorplanView', () => {
115
+ test("aligns on a turned building's own axes and returns building-local guides", () => {
116
+ selectTurnedBuilding()
117
+ const result = resolveAlignmentForFloorplanView({
118
+ moving: [{ nodeId: 'moving', kind: 'corner', x: 2.05, z: 1 }],
119
+ candidates: [{ nodeId: 'wall', kind: 'corner', x: 2, z: 5 }],
120
+ threshold: 0.08,
121
+ })
122
+ expect(result.snap?.dx).toBeCloseTo(-0.05, 9)
123
+ expect(result.snap?.dz).toBeCloseTo(0, 9)
124
+ expect(result.guides).toHaveLength(1)
125
+ expect(result.guides[0]?.axis).toBe('x')
126
+ expect(result.guides[0]?.anchor).toEqual({ x: 2, z: 5 })
127
+ })
128
+ })
129
+
130
+ describe('resolveAlignmentForActiveBuilding', () => {
131
+ test("aligns on a turned building's own axes; guide ends come back in world coords", () => {
132
+ selectTurnedBuilding()
133
+ const result = resolveAlignmentForActiveBuilding({
134
+ moving: [{ nodeId: 'moving', kind: 'corner', x: 2.05, z: 1 }],
135
+ candidates: [{ nodeId: 'wall', kind: 'corner', x: 2, z: 5 }],
136
+ threshold: 0.08,
137
+ })
138
+ expect(result.snap?.dx).toBeCloseTo(-0.05, 9)
139
+ expect(result.snap?.dz).toBeCloseTo(0, 9)
140
+ const anchor = result.guides[0]?.anchor
141
+ const ends = [result.guides[0]?.from, result.guides[0]?.to]
142
+ // The candidate end sits at the anchor's world position.
143
+ const world = ends.find(
144
+ (end) =>
145
+ end &&
146
+ Math.abs(end.x - (POSITION[0] + 2 * Math.cos(YAW) + 5 * Math.sin(YAW))) < 1e-9 &&
147
+ Math.abs(end.z - (POSITION[2] - 2 * Math.sin(YAW) + 5 * Math.cos(YAW))) < 1e-9,
148
+ )
149
+ expect(anchor).toEqual({ x: 2, z: 5 })
150
+ expect(world).toBeDefined()
151
+ })
152
+ })
153
+
154
+ describe('getActiveGridPose (the frame the 3D grid is drawn in)', () => {
155
+ test("is the active building's pose while working in it", () => {
156
+ useEditor.setState({ phase: 'building' })
157
+ selectTurnedBuilding()
158
+ expect(getActiveGridPose()).toEqual({ position: POSITION, rotationY: YAW })
159
+ })
160
+
161
+ test('is the world grid for site work and while the building itself moves', () => {
162
+ const building = selectTurnedBuilding()
163
+ useEditor.setState({ phase: 'site' })
164
+ expect(getActiveGridPose()).toBeNull()
165
+
166
+ useEditor.setState({ phase: 'building' })
167
+ useInteractionScope.getState().begin({
168
+ kind: 'moving',
169
+ node: building,
170
+ nodeId: building.id,
171
+ nodeType: 'building',
172
+ view: '3d',
173
+ })
174
+ expect(getActiveGridPose()).toBeNull()
175
+ })
176
+
177
+ test('is the world grid with no building', () => {
178
+ useEditor.setState({ phase: 'building' })
179
+ useScene.setState({ nodes: {} } as never)
180
+ useViewer.setState({
181
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
182
+ } as never)
183
+ expect(getActiveGridPose()).toBeNull()
184
+ })
185
+ })
@@ -1,12 +1,18 @@
1
1
  /**
2
- * World-grid snap for tools that consume `grid:move` / `grid:click`.
2
+ * Grid snap and alignment frames for tools that consume `grid:move` /
3
+ * `grid:click` inside a building.
3
4
  *
4
- * Tools historically snapped on `event.localPosition` (the cursor in the
5
- * active building's local frame). After the floor-plan grid was pulled
6
- * out of the rotated scene group, snapping has to follow the WORLD XZ
7
- * grid — otherwise a rotated building drags every placement off the
8
- * visible grid lines. This helper resolves the active building's pose
9
- * and projects the world snap back into local coords for storage.
5
+ * A building placed on a lot by the site planner is usually turned (yaw) and
6
+ * offset. Everything drawn or placed in it — walls, slabs, rooms, furniture,
7
+ * doors and windows — lives in the building's own frame, and so does the grid
8
+ * both views draw for it: the floor plan's grid sits inside the rotated scene
9
+ * group, and the 3D grid is laid on the building (`getActiveGridPose`). The
10
+ * snaps here follow that lattice, so a wall drawn square to the house stays
11
+ * square to it and lands on the lattice its neighbours were drawn on. A
12
+ * world-XZ lattice sits at the building's yaw to the house and off it by the
13
+ * building's offset, leaving drafted ends a fraction short of the walls they
14
+ * meet. For an unturned building at the origin the two coincide; outside any
15
+ * building the world grid is the lattice.
10
16
  */
11
17
  import {
12
18
  type AlignmentAnchor,
@@ -21,6 +27,8 @@ import {
21
27
  useScene,
22
28
  } from '@pascal-app/core'
23
29
  import { useViewer } from '@pascal-app/viewer'
30
+ import useEditor from '../store/use-editor'
31
+ import { getMovingNode } from '../store/use-interaction-scope'
24
32
 
25
33
  /**
26
34
  * Look up the active building's pose, or null when we're at the site root.
@@ -68,13 +76,26 @@ export function getActiveBuildingPose(): BuildingPose | null {
68
76
  }
69
77
 
70
78
  /**
71
- * Resolve Figma-style alignment for tools whose anchors are in the active
72
- * building's local frame, but where alignment must run on the WORLD axes
73
- * (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
74
- * with the active building lookup so callers don't repeat the boilerplate.
79
+ * The frame of the lattice the 3D grid is drawn on: the active building's pose
80
+ * for everything drawn or placed in it, or null — the world XZ grid — for
81
+ * site work (the site phase, which terrain sculpting also runs in), while a
82
+ * building itself is being moved (it moves on the site), and with no building.
83
+ */
84
+ export function getActiveGridPose(): BuildingPose | null {
85
+ if (useEditor.getState().phase === 'site') return null
86
+ if (getMovingNode()?.type === 'building') return null
87
+ return getActiveBuildingPose()
88
+ }
89
+
90
+ /**
91
+ * Resolve Figma-style alignment for 3D tools whose anchors are in the active
92
+ * building's local frame, on the building's own axes (the axes its grid is
93
+ * drawn on). Wraps `resolveAlignmentInBuildingWorld` with the active building
94
+ * lookup so callers don't repeat the boilerplate.
75
95
  *
76
96
  * Returns:
77
- * - `guides` in WORLD coords (renderer must live in a world-space group),
97
+ * - `guides` with `from` / `to` in WORLD coords (callers bring them back
98
+ * with `projectAlignmentGuidesWorldToActiveBuildingLocal`),
78
99
  * - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
79
100
  */
80
101
  export function resolveAlignmentForActiveBuilding(args: {
@@ -82,7 +103,24 @@ export function resolveAlignmentForActiveBuilding(args: {
82
103
  candidates: readonly AlignmentAnchor[]
83
104
  threshold: number
84
105
  }): ResolveAlignmentInBuildingResult {
85
- return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
106
+ // Pose `null` resolves in the anchors' own (building-local) frame.
107
+ const result = resolveAlignmentInBuildingWorld({ ...args, pose: null })
108
+ const pose = getActiveBuildingPose()
109
+ if (!pose) return result
110
+ return {
111
+ snap: result.snap,
112
+ guides: result.guides.map((guide) => {
113
+ const [fromX, fromZ] = poseLocalXZToWorld(guide.from.x, guide.from.z, pose)
114
+ const [toX, toZ] = poseLocalXZToWorld(guide.to.x, guide.to.z, pose)
115
+ return { ...guide, from: { x: fromX, z: fromZ }, to: { x: toX, z: toZ } }
116
+ }),
117
+ }
118
+ }
119
+
120
+ function poseLocalXZToWorld(x: number, z: number, pose: BuildingPose): [number, number] {
121
+ const cos = Math.cos(pose.rotationY)
122
+ const sin = Math.sin(pose.rotationY)
123
+ return [pose.position[0] + x * cos + z * sin, pose.position[2] - x * sin + z * cos]
86
124
  }
87
125
 
88
126
  function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
@@ -97,9 +135,10 @@ function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [n
97
135
  /**
98
136
  * Project WORLD-frame alignment guides into the active building's LOCAL frame.
99
137
  *
100
- * The 3D alignment layer is still mounted inside the building-local tool group,
101
- * so tools that resolve alignment on the world axes (item placement, slab move)
102
- * need their guides converted before publishing to `useAlignmentGuides`.
138
+ * The 3D alignment layer is mounted inside the building-local tool group, so
139
+ * tools that resolve through `resolveAlignmentForActiveBuilding` (item
140
+ * placement, slab move) convert its guides before publishing to
141
+ * `useAlignmentGuides`.
103
142
  */
104
143
  export function projectAlignmentGuidesWorldToActiveBuildingLocal(
105
144
  guides: readonly AlignmentGuide[],
@@ -117,119 +156,77 @@ export function projectAlignmentGuidesWorldToActiveBuildingLocal(
117
156
  }
118
157
 
119
158
  /**
120
- * Baseline rotation the floor-plan view applies on top of the building
121
- * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 0` in lib/floorplan/geometry.ts —
122
- * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
123
- */
124
- const FLOORPLAN_VIEW_ROTATION_RAD = 0
125
-
126
- function rotateAnchorsBy(
127
- anchors: readonly AlignmentAnchor[],
128
- cos: number,
129
- sin: number,
130
- ): AlignmentAnchor[] {
131
- return anchors.map((a) => ({
132
- nodeId: a.nodeId,
133
- kind: a.kind,
134
- x: a.x * cos - a.z * sin,
135
- z: a.x * sin + a.z * cos,
136
- }))
137
- }
138
-
139
- /**
140
- * Resolve alignment in the 2D floor-plan view frame — the frame the user
141
- * sees the (always axis-aligned) grid lines in, regardless of how the
142
- * building has been rotated. Use this from EVERY 2D floor-plan path so
143
- * alignment guides stay parallel to the visible grid.
159
+ * Resolve alignment for the 2D floor plan, on the active building's own axes.
160
+ * Use this from EVERY 2D floor-plan path so alignment guides stay parallel to
161
+ * the plan's grid.
144
162
  *
145
- * Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
146
- * shows the world XZ grid, so world-frame alignment matches the visible
147
- * grid there. The 2D floor plan, however, rotates the scene `<g>` by
148
- * `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
149
- * OUTSIDE that rotated group — so the visible axes are
150
- * `R(FVR − buildingRot) · local`. World-frame alignment would land on
151
- * world axes, which appear diagonal in this view; view-frame alignment
152
- * lands on the SVG axes the user actually reads.
163
+ * The floor plan rotates its scene `<g>` by the building's yaw (and the user's
164
+ * view rotation) and draws both its grid and the alignment-guide layer inside
165
+ * that group, in building-local metres. So the axes the user reads are the
166
+ * building's local axes, and guides must come back in local coords: resolving
167
+ * on world axes would align a turned building's corners on a diagonal to its
168
+ * walls and draw each guide away from the anchors it matched.
153
169
  *
154
- * Returns guides in view-frame coords (correct input for the floor-plan
155
- * alignment-guide layer, which is mounted outside the rotated scene
156
- * group) and a snap delta projected back into building-local (so callers
157
- * can add it to a local position as-is).
170
+ * Returns guides and the snap delta in building-local coords (add the delta
171
+ * to a local position as-is).
158
172
  */
159
173
  export function resolveAlignmentForFloorplanView(args: {
160
174
  moving: readonly AlignmentAnchor[]
161
175
  candidates: readonly AlignmentAnchor[]
162
176
  threshold: number
163
177
  }): ResolveAlignmentInBuildingResult {
164
- const pose = getActiveBuildingPose()
165
- const buildingRotY = pose?.rotationY ?? 0
166
- const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
167
- const cos = Math.cos(rot)
168
- const sin = Math.sin(rot)
169
- const result = resolveAlignmentInBuildingWorld({
170
- moving: rotateAnchorsBy(args.moving, cos, sin),
171
- candidates: rotateAnchorsBy(args.candidates, cos, sin),
172
- threshold: args.threshold,
173
- // Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
174
- // path: it just runs `resolveAlignment` on the already-rotated
175
- // anchors and returns the snap delta in the same view frame.
176
- pose: null,
177
- })
178
- if (!result.snap) return result
179
- // View-frame delta → local-frame delta (transpose of R(rot)).
180
- const { dx, dz } = result.snap
181
- return {
182
- guides: result.guides,
183
- snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
184
- }
178
+ // Pose `null` resolves in the anchors' own (building-local) frame.
179
+ return resolveAlignmentInBuildingWorld({ ...args, pose: null })
185
180
  }
186
181
 
187
182
  /**
188
- * Snap a world XZ position to the grid, then express it in the active
189
- * building's local frame. When no building is active, world == local.
183
+ * Snap a world XZ position onto the active building's grid lattice (see
184
+ * `snapBuildingLocalToWorldGrid`) and return the snapped point in both
185
+ * frames. With no active building, world == local and the world grid is the
186
+ * lattice.
190
187
  */
191
188
  export function snapWorldXZForActiveBuilding(
192
189
  worldX: number,
193
190
  worldZ: number,
194
191
  step: number,
195
192
  ): { world: [number, number]; local: [number, number] } {
196
- const buildingId = useViewer.getState().selection.buildingId
197
- const building = buildingId ? useScene.getState().nodes[buildingId] : null
198
- if (building?.type !== 'building') {
199
- if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
200
- const sx = Math.round(worldX / step) * step
201
- const sz = Math.round(worldZ / step) * step
202
- return { world: [sx, sz], local: [sx, sz] }
193
+ const pose = getActiveBuildingPose()
194
+ if (!pose) {
195
+ const [x, z] = snapBuildingLocalToWorldGrid([worldX, worldZ], step)
196
+ return { world: [x, z], local: [x, z] }
203
197
  }
204
- return snapWorldXZToBuildingLocal(
205
- worldX,
206
- worldZ,
207
- building.position,
208
- building.rotation[1] ?? 0,
209
- step,
210
- )
198
+ const { local } = snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0)
199
+ const [x, z] = snapBuildingLocalToWorldGrid(local, step)
200
+ return { world: poseLocalXZToWorld(x, z, pose), local: [x, z] }
211
201
  }
212
202
 
213
203
  /**
214
- * Snap a building-local plan point so the resulting position sits on the
215
- * world XZ grid. The returned point is still in building-local coords —
216
- * useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
217
- * etc., which operate entirely in the local frame.
204
+ * Snap a plan point in the 3D tools' frame (a grid event's `localPosition`)
205
+ * onto the lattice the grid is drawn on, one axis at a time with the caller's
206
+ * per-axis snap (an item snaps its edges by its own size). Inside a building
207
+ * the point is building-local and the lattice runs along the building's axes;
208
+ * outside any building it is the world point and the world grid is the
209
+ * lattice — so no pose is needed either way.
210
+ */
211
+ export function snapPlanPointToActiveGrid(
212
+ point: readonly [number, number],
213
+ snap: (value: number, axis: 0 | 1) => number,
214
+ ): [number, number] {
215
+ return [snap(point[0], 0), snap(point[1], 1)]
216
+ }
217
+
218
+ /**
219
+ * Snap a building-local plan point onto the building's own grid lattice:
220
+ * multiples of `step` along its local axes from its origin, the lattice both
221
+ * views draw and the wall tool drafts on. Coordinates in and out are
222
+ * building-local, so it suits a `gridSnap` callback for snapWallDraftPoint /
223
+ * snapFenceDraftPoint etc. (The name predates the building frame: it used to
224
+ * snap onto the world XZ grid, at a turned building's yaw to the house.)
218
225
  */
219
226
  export function snapBuildingLocalToWorldGrid(
220
227
  local: readonly [number, number],
221
228
  step: number,
222
229
  ): [number, number] {
223
- const buildingId = useViewer.getState().selection.buildingId
224
- const building = buildingId ? useScene.getState().nodes[buildingId] : null
225
- if (building?.type !== 'building') {
226
- if (step <= 0) return [local[0], local[1]]
227
- return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
228
- }
229
- const rotY = building.rotation[1] ?? 0
230
- const cos = Math.cos(rotY)
231
- const sin = Math.sin(rotY)
232
- const worldX = building.position[0] + local[0] * cos + local[1] * sin
233
- const worldZ = building.position[2] - local[0] * sin + local[1] * cos
234
- return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
230
+ if (step <= 0) return [local[0], local[1]]
231
+ return snapPlanPointToActiveGrid(local, (value) => Math.round(value / step) * step)
235
232
  }
@@ -6,16 +6,42 @@
6
6
  import type { AlignmentGuide } from '@pascal-app/core'
7
7
  import { create } from 'zustand'
8
8
 
9
+ /**
10
+ * A wall draft's dashed projection guide, in building-local plan coordinates:
11
+ * the extension of the wall the draft comes from, a line through another wall's
12
+ * end on one of the house's axes, or the same through the run's start.
13
+ */
14
+ export type DraftGuideLine = {
15
+ kind: 'extension' | 'alignment' | 'run-start'
16
+ from: [number, number]
17
+ to: [number, number]
18
+ }
19
+
20
+ /**
21
+ * The projection guides of a wall pulled from a selected wall's end (and of
22
+ * the run it continues): dashed lines, and markers where two of them cross.
23
+ */
24
+ export type DraftGuides = {
25
+ lines: DraftGuideLine[]
26
+ intersections: Array<[number, number]>
27
+ }
28
+
9
29
  type AlignmentGuidesState = {
10
30
  guides: AlignmentGuide[]
31
+ /** A wall draft's projection guides, drawn by the same layers; null when none. */
32
+ draft: DraftGuides | null
11
33
  set(guides: AlignmentGuide[]): void
34
+ /** Publish a wall draft's projection guides, which take the place of alignment guides. */
35
+ setDraft(draft: DraftGuides | null): void
12
36
  clear(): void
13
37
  }
14
38
 
15
39
  const useAlignmentGuides = create<AlignmentGuidesState>((set) => ({
16
40
  guides: [],
41
+ draft: null,
17
42
  set: (guides) => set({ guides }),
18
- clear: () => set({ guides: [] }),
43
+ setDraft: (draft) => set({ guides: [], draft }),
44
+ clear: () => set({ guides: [], draft: null }),
19
45
  }))
20
46
 
21
47
  export default useAlignmentGuides
@@ -528,6 +528,24 @@ type EditorState = {
528
528
  setReferenceFloorOffset: (offset: number) => void
529
529
  referenceFloorOpacity: number
530
530
  setReferenceFloorOpacity: (opacity: number) => void
531
+ // The 2D plan's dashed outlines of the walls on the level below / above
532
+ // the active one. "Below" is set apart for the roof plan, where it traces
533
+ // the top storey's walls the roof is checked against and starts on, and the
534
+ // floor plans, where it starts off (`useWallsBelowSetting`). Persisted.
535
+ showWallsBelow: boolean
536
+ setShowWallsBelow: (show: boolean) => void
537
+ showRoofWallsBelow: boolean
538
+ setShowRoofWallsBelow: (show: boolean) => void
539
+ showWallsAbove: boolean
540
+ setShowWallsAbove: (show: boolean) => void
541
+ // Ceilings drawn solid from above as well as from below. Set apart like
542
+ // "Walls below": on for the roof level, where the roof is checked over closed
543
+ // rooms, off for the floor plans, where the camera looks into the rooms
544
+ // (`useCeilingsSetting`). Persisted.
545
+ showCeilings: boolean
546
+ setShowCeilings: (show: boolean) => void
547
+ showRoofCeilings: boolean
548
+ setShowRoofCeilings: (show: boolean) => void
531
549
  // Development-only camera debug flag for inspecting underside geometry
532
550
  allowUndergroundCamera: boolean
533
551
  setAllowUndergroundCamera: (enabled: boolean) => void
@@ -605,6 +623,11 @@ type PersistedEditorLayoutState = Pick<
605
623
  | 'showReferenceFloor'
606
624
  | 'referenceFloorOffset'
607
625
  | 'referenceFloorOpacity'
626
+ | 'showWallsBelow'
627
+ | 'showRoofWallsBelow'
628
+ | 'showWallsAbove'
629
+ | 'showCeilings'
630
+ | 'showRoofCeilings'
608
631
  >
609
632
  type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
610
633
 
@@ -640,6 +663,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
640
663
  showReferenceFloor: false,
641
664
  referenceFloorOffset: 1,
642
665
  referenceFloorOpacity: 0.35,
666
+ showWallsBelow: false,
667
+ showRoofWallsBelow: true,
668
+ showWallsAbove: false,
669
+ showCeilings: false,
670
+ showRoofCeilings: true,
643
671
  }
644
672
 
645
673
  const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
@@ -899,6 +927,11 @@ export function normalizePersistedEditorLayoutState(
899
927
  Number.isFinite(state.referenceFloorOpacity)
900
928
  ? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity))
901
929
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
930
+ showWallsBelow: state?.showWallsBelow === true,
931
+ showRoofWallsBelow: state?.showRoofWallsBelow !== false,
932
+ showWallsAbove: state?.showWallsAbove === true,
933
+ showCeilings: state?.showCeilings === true,
934
+ showRoofCeilings: state?.showRoofCeilings !== false,
902
935
  }
903
936
  }
904
937
 
@@ -1568,6 +1601,16 @@ const useEditor = create<EditorState>()(
1568
1601
  referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
1569
1602
  setReferenceFloorOpacity: (opacity) =>
1570
1603
  set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
1604
+ showWallsBelow: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showWallsBelow,
1605
+ setShowWallsBelow: (show) => set({ showWallsBelow: show }),
1606
+ showRoofWallsBelow: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showRoofWallsBelow,
1607
+ setShowRoofWallsBelow: (show) => set({ showRoofWallsBelow: show }),
1608
+ showWallsAbove: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showWallsAbove,
1609
+ setShowWallsAbove: (show) => set({ showWallsAbove: show }),
1610
+ showCeilings: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showCeilings,
1611
+ setShowCeilings: (show) => set({ showCeilings: show }),
1612
+ showRoofCeilings: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showRoofCeilings,
1613
+ setShowRoofCeilings: (show) => set({ showRoofCeilings: show }),
1571
1614
  allowUndergroundCamera: false,
1572
1615
  setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
1573
1616
  show2dVoronoi: false,
@@ -1672,6 +1715,11 @@ const useEditor = create<EditorState>()(
1672
1715
  showReferenceFloor: state.showReferenceFloor,
1673
1716
  referenceFloorOffset: state.referenceFloorOffset,
1674
1717
  referenceFloorOpacity: state.referenceFloorOpacity,
1718
+ showWallsBelow: state.showWallsBelow,
1719
+ showRoofWallsBelow: state.showRoofWallsBelow,
1720
+ showWallsAbove: state.showWallsAbove,
1721
+ showCeilings: state.showCeilings,
1722
+ showRoofCeilings: state.showRoofCeilings,
1675
1723
  }),
1676
1724
  skipHydration: true,
1677
1725
  },
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+
3
+ const STORAGE_KEY = 'pascal-floorplan-annotation-visibility'
4
+ const savedWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
5
+ afterEach(() => {
6
+ if (savedWindow) Object.defineProperty(globalThis, 'window', savedWindow)
7
+ else Reflect.deleteProperty(globalThis, 'window')
8
+ })
9
+
10
+ /** A browser's localStorage, seeded with what an earlier session saved. */
11
+ function stubStorage(state?: Record<string, unknown>) {
12
+ const values = new Map<string, string>(
13
+ state ? [[STORAGE_KEY, JSON.stringify({ state, version: 0 })]] : [],
14
+ )
15
+ const localStorage: Partial<Storage> = {
16
+ getItem: (key) => values.get(key) ?? null,
17
+ setItem: (key, value) => void values.set(key, value),
18
+ removeItem: (key) => void values.delete(key),
19
+ }
20
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: { localStorage } })
21
+ return { read: () => JSON.parse(values.get(STORAGE_KEY) ?? 'null')?.state }
22
+ }
23
+
24
+ let fresh = 0
25
+ async function loadStore() {
26
+ const { default: store } = await import(
27
+ `./use-floorplan-annotation-visibility.ts?load=${++fresh}`
28
+ )
29
+ return store as typeof import('./use-floorplan-annotation-visibility').default
30
+ }
31
+
32
+ describe('Display dimension preferences', () => {
33
+ test('a first visit shows dimensions and measures walls to both faces', async () => {
34
+ stubStorage()
35
+ const store = await loadStore()
36
+ await store.persist.rehydrate()
37
+
38
+ expect(store.getState().showDimensions).toBe(true)
39
+ expect(store.getState().wallDimensionFaces).toBe('both')
40
+ })
41
+
42
+ test('a stale saved choice, from the retired Wall dimensions option, opens on Both', async () => {
43
+ stubStorage({ wallDimensionReference: 'stud-faces', wallDimensionFaces: 'centerline' })
44
+ const store = await loadStore()
45
+ await store.persist.rehydrate()
46
+
47
+ expect(store.getState().wallDimensionFaces).toBe('both')
48
+ expect(store.getState()).not.toHaveProperty('wallDimensionReference')
49
+ })
50
+
51
+ test('Dimensions and Measure walls come back after a reload', async () => {
52
+ const storage = stubStorage()
53
+ const firstSession = await loadStore()
54
+ await firstSession.persist.rehydrate()
55
+ firstSession.getState().setShowDimensions(false)
56
+ firstSession.getState().setWallDimensionFaces('interior')
57
+ expect(storage.read()).toMatchObject({ showDimensions: false, wallDimensionFaces: 'interior' })
58
+
59
+ const nextSession = await loadStore()
60
+ await nextSession.persist.rehydrate()
61
+ expect(nextSession.getState().showDimensions).toBe(false)
62
+ expect(nextSession.getState().wallDimensionFaces).toBe('interior')
63
+ })
64
+ })