@pascal-app/editor 1.0.5 → 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 +20 -17
  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
@@ -11,8 +11,10 @@ import {
11
11
  DIMENSION_FLOOR_LIFT,
12
12
  mezzanineEdgeDimensions,
13
13
  type RoomDimension,
14
+ type WallDimensionFaces,
14
15
  wallPushDimensionRooms,
15
16
  wallPushDimensions,
17
+ wallSelectionDimensions,
16
18
  } from './room-push-dimensions'
17
19
 
18
20
  // Rooms are real: the walls go through core's room topology, so the faces the
@@ -66,6 +68,20 @@ function measure(
66
68
  })
67
69
  }
68
70
 
71
+ function selection({ nodes, walls }: Scene, wall: WallNode, faces: WallDimensionFaces) {
72
+ const index = createRoomTopologyIndex()
73
+ index.rebuildLevel(LEVEL, nodes)
74
+ const rooms = index.getLevelTopology(LEVEL)?.rooms ?? []
75
+ return wallSelectionDimensions({
76
+ rooms: wallPushDimensionRooms(rooms, { wallId: wall.id, t0: 0, t1: 1 }, () => 0),
77
+ wall,
78
+ walls,
79
+ faces,
80
+ resolve: (id) => nodes[id],
81
+ baseElevation: 0,
82
+ })
83
+ }
84
+
69
85
  const distances = (dimensions: RoomDimension[]) =>
70
86
  dimensions.map((dimension) => Number(dimension.distance.toFixed(4)))
71
87
 
@@ -206,6 +222,27 @@ describe('two rooms sharing the moving wall', () => {
206
222
  expect(west.key).not.toBe(east.key)
207
223
  })
208
224
 
225
+ test('selected, a shared wall shows both room widths and no outside size, whatever the faces', () => {
226
+ for (const faces of ['both', 'interior', 'exterior'] as const) {
227
+ const dimensions = selection(rooms, middle, faces)
228
+ expect(dimensions.map((dimension) => dimension.faces)).toEqual(['interior', 'interior'])
229
+ expect(distances(dimensions).sort()).toEqual([3.8, 3.8])
230
+ }
231
+ })
232
+
233
+ test('selected, an exterior wall adds the outside size across the building', () => {
234
+ const east = wallAt(rooms, [8, 0], [8, 4])
235
+ const dimensions = selection(rooms, east, 'both')
236
+ const outside = dimensions.find((dimension) => dimension.faces === 'exterior')!
237
+ // Outer face to outer face, past the middle wall, clear of the room width's line.
238
+ expect(outside.distance).toBeCloseTo(8 + THICKNESS)
239
+ expect(outside.from[0]).toBeCloseTo(8 + THICKNESS / 2)
240
+ expect(outside.to[0]).toBeCloseTo(-THICKNESS / 2)
241
+ expect(distances(dimensions.filter((dimension) => dimension.faces === 'interior'))).toEqual([
242
+ 3.8,
243
+ ])
244
+ })
245
+
209
246
  test('a push over part of the wall measures only the rooms along that part', () => {
210
247
  const dimensions = measure(rooms, middle, [1, 0], 0, { t0: 0, t1: 0.5 })
211
248
  expect(dimensions).toHaveLength(2)
@@ -4,6 +4,7 @@ import {
4
4
  getWallFaceOffsets,
5
5
  isCurvedWall,
6
6
  type Point,
7
+ type SeparatorNode,
7
8
  type WallNode,
8
9
  } from '@pascal-app/core'
9
10
 
@@ -153,12 +154,62 @@ export function wallPushDimensionRooms(
153
154
  )
154
155
  }
155
156
 
157
+ /**
158
+ * The rooms along a pushed edge of invisible walls, as `wallPushDimensionRooms`
159
+ * reads them along a wall: each room's stretch of the edge's separators, put
160
+ * on the edge's one `line` (`[0, 1]` its length), faced as that line runs.
161
+ */
162
+ export function separatorEdgeDimensionRooms(
163
+ rooms: ReadonlyArray<{ id: string; spans: readonly BoundarySpan[] }>,
164
+ edge: {
165
+ line: Pick<SeparatorNode, 'start' | 'end'>
166
+ separators: ReadonlyArray<{ separatorId: string; t0: number; t1: number }>
167
+ },
168
+ resolve: (id: string) => AnyNode | undefined,
169
+ elevationOf: (roomId: string) => number,
170
+ ): PushDimensionRoom[] {
171
+ const { start, end } = edge.line
172
+ const dx = end[0] - start[0]
173
+ const dz = end[1] - start[1]
174
+ const lengthSq = dx * dx + dz * dz
175
+ if (lengthSq < 1e-12) return []
176
+ const members = new Set(edge.separators.map((member) => member.separatorId))
177
+ const along = ([x, z]: Point) => ((x - start[0]) * dx + (z - start[1]) * dz) / lengthSq
178
+ return rooms.flatMap((room) =>
179
+ room.spans.flatMap((own): PushDimensionRoom[] => {
180
+ const member = edge.separators.find((entry) => entry.separatorId === own.boundaryId)
181
+ const separator = resolve(own.boundaryId)
182
+ if (!member || separator?.type !== 'separator') return []
183
+ const t0 = Math.max(member.t0, own.t0)
184
+ const t1 = Math.min(member.t1, own.t1)
185
+ if (t1 - t0 < 1e-4) return []
186
+ const sx = separator.end[0] - separator.start[0]
187
+ const sz = separator.end[1] - separator.start[1]
188
+ const point = (t: number): Point => [separator.start[0] + sx * t, separator.start[1] + sz * t]
189
+ const [u0, u1] = [along(point(t0)), along(point(t1))]
190
+ // A separator drawn against the edge's line has its faces the other way round.
191
+ const same = sx * dx + sz * dz > 0
192
+ return [
193
+ {
194
+ id: room.id,
195
+ face: same ? own.face : own.face === 'a' ? 'b' : 'a',
196
+ t0: Math.min(u0, u1),
197
+ t1: Math.max(u0, u1),
198
+ others: room.spans.filter((other) => !members.has(other.boundaryId)),
199
+ elevation: elevationOf(room.id),
200
+ },
201
+ ]
202
+ }),
203
+ )
204
+ }
205
+
156
206
  /**
157
207
  * The live dimensions of a wall push: for every room along the pushed stretch,
158
208
  * the moving face (the resting face moved `distance` along `outward` — a push
159
209
  * is a pure translation) to each facing parallel boundary of that room, read
160
210
  * through `resolve` so the preview's geometry counts. Per side of the wall:
161
- * nearest first, one per distinct distance, at most three.
211
+ * nearest first, one per distinct distance, at most three. An edge of
212
+ * invisible walls is measured as its one line.
162
213
  */
163
214
  export function wallPushDimensions({
164
215
  rooms,
@@ -168,7 +219,7 @@ export function wallPushDimensions({
168
219
  resolve,
169
220
  }: {
170
221
  rooms: readonly PushDimensionRoom[]
171
- wall: WallNode
222
+ wall: WallNode | SeparatorNode
172
223
  outward: Point
173
224
  distance: number
174
225
  resolve: (id: string) => AnyNode | undefined
@@ -195,6 +246,179 @@ export function wallPushDimensions({
195
246
  )
196
247
  }
197
248
 
249
+ /** Whether two dimensions measure the same span, either way round. */
250
+ export function sameRoomDimension(a: RoomDimension, b: RoomDimension): boolean {
251
+ const near = (p: Point, q: Point) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-3
252
+ return (near(a.from, b.from) && near(a.to, b.to)) || (near(a.from, b.to) && near(a.to, b.from))
253
+ }
254
+
255
+ /** A selected wall's temporary dimension: a room width (ID) or the outside size (OD). */
256
+ export type WallSelectionDimension = RoomDimension & { faces: 'interior' | 'exterior' }
257
+
258
+ /** A selection's temporary dimension with the node it runs from: typing it moves that node. */
259
+ export type OwnedSelectionDimension = WallSelectionDimension & { ownerId: string }
260
+
261
+ /**
262
+ * Several selected nodes' temporary dimensions as one list, keys unique across
263
+ * owners. Two selected walls across one room both measure it: the width shows
264
+ * once, owned by the first.
265
+ */
266
+ export function mergeSelectionDimensions(
267
+ owners: ReadonlyArray<{ ownerId: string; dimensions: readonly WallSelectionDimension[] }>,
268
+ ): OwnedSelectionDimension[] {
269
+ const kept: OwnedSelectionDimension[] = []
270
+ for (const { ownerId, dimensions } of owners)
271
+ for (const dimension of dimensions)
272
+ if (!kept.some((other) => sameRoomDimension(other, dimension)))
273
+ kept.push({ ...dimension, key: `${ownerId}:${dimension.key}`, ownerId })
274
+ return kept
275
+ }
276
+
277
+ /** Which faces a selected wall's temporary dimensions measure to. */
278
+ export type WallDimensionFaces = 'interior' | 'exterior' | 'both'
279
+
280
+ const CONNECTION_TOLERANCE = 0.03
281
+ /** Where along the shared stretch the outside size is drawn, clear of the room widths at its middle. */
282
+ const EXTERIOR_DIMENSION_AT = 0.25
283
+
284
+ function pointSegmentDistance(p: Point, a: Point, b: Point): number {
285
+ const dx = b[0] - a[0]
286
+ const dz = b[1] - a[1]
287
+ const lengthSq = dx * dx + dz * dz
288
+ const t =
289
+ lengthSq < 1e-12
290
+ ? 0
291
+ : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dz) / lengthSq))
292
+ return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dz * t))
293
+ }
294
+
295
+ function wallsTouch(left: WallNode, right: WallNode): boolean {
296
+ return (
297
+ [left.start, left.end].some(
298
+ (p) => pointSegmentDistance(p, right.start, right.end) <= CONNECTION_TOLERANCE,
299
+ ) ||
300
+ [right.start, right.end].some(
301
+ (p) => pointSegmentDistance(p, left.start, left.end) <= CONNECTION_TOLERANCE,
302
+ )
303
+ )
304
+ }
305
+
306
+ /** The walls joined to `wall` through shared corners and T junctions, itself included. */
307
+ function connectedWalls(wall: WallNode, walls: readonly WallNode[]): WallNode[] {
308
+ const found = new Map<string, WallNode>([[wall.id, wall]])
309
+ const queue = [wall]
310
+ for (let current = queue.pop(); current; current = queue.pop()) {
311
+ for (const candidate of walls) {
312
+ if (found.has(candidate.id) || !wallsTouch(current, candidate)) continue
313
+ found.set(candidate.id, candidate)
314
+ queue.push(candidate)
315
+ }
316
+ }
317
+ return [...found.values()]
318
+ }
319
+
320
+ /**
321
+ * The outside size across the building from a wall's exterior face: to the
322
+ * far face of the farthest parallel wall of the same connected building that
323
+ * shares some of its length — the opposite exterior wall. Null when nothing
324
+ * is across (a lone wall, a curved one).
325
+ */
326
+ function overallExteriorDimension(
327
+ wall: WallNode,
328
+ exteriorFace: 'a' | 'b',
329
+ walls: readonly WallNode[],
330
+ ): Measured | null {
331
+ const own = boundaryFace(wall, 0, 1, exteriorFace)
332
+ if (!own) return null
333
+ const dx = own.end[0] - own.start[0]
334
+ const dz = own.end[1] - own.start[1]
335
+ const length = Math.hypot(dx, dz)
336
+ const dir: Point = [dx / length, dz / length]
337
+ // The face's room-ward normal points out of the building here: across is its opposite.
338
+ const across: Point = [-own.inward[0], -own.inward[1]]
339
+ const along = (p: Point) => (p[0] - own.start[0]) * dir[0] + (p[1] - own.start[1]) * dir[1]
340
+ let best: { distance: number; lo: number; hi: number } | null = null
341
+ for (const other of connectedWalls(wall, walls)) {
342
+ if (other.id === wall.id) continue
343
+ for (const face of ['a', 'b'] as const) {
344
+ const candidate = boundaryFace(other, 0, 1, face)
345
+ if (!candidate) continue
346
+ const fx = candidate.end[0] - candidate.start[0]
347
+ const fz = candidate.end[1] - candidate.start[1]
348
+ const faceLength = Math.hypot(fx, fz)
349
+ if (Math.abs(dir[0] * fz - dir[1] * fx) / faceLength > PARALLEL_SIN) continue
350
+ const distance =
351
+ (candidate.start[0] - own.start[0]) * across[0] +
352
+ (candidate.start[1] - own.start[1]) * across[1]
353
+ if (distance <= MIN_GAP) continue
354
+ const a = along(candidate.start)
355
+ const b = along(candidate.end)
356
+ const lo = Math.max(0, Math.min(a, b))
357
+ const hi = Math.min(length, Math.max(a, b))
358
+ if (hi - lo < MIN_OVERLAP) continue
359
+ if (!best || distance > best.distance + SAME_DISTANCE) best = { distance, lo, hi }
360
+ }
361
+ }
362
+ if (!best) return null
363
+ const at = (u: number): Point => [own.start[0] + dir[0] * u, own.start[1] + dir[1] * u]
364
+ const [lo, hi] = [at(best.lo), at(best.hi)]
365
+ // Measured from the same end of the stretch whichever way the wall was drawn,
366
+ // so the walls on either side of the building put their outside size in one place.
367
+ const fromLo = Math.abs(lo[0] - hi[0]) > 1e-6 ? lo[0] < hi[0] : lo[1] <= hi[1]
368
+ const offset = (best.hi - best.lo) * EXTERIOR_DIMENSION_AT
369
+ const from = at(fromLo ? best.lo + offset : best.hi - offset)
370
+ return {
371
+ from,
372
+ to: [from[0] + across[0] * best.distance, from[1] + across[1] * best.distance],
373
+ distance: best.distance,
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The temporary dimensions of a selected wall at rest, in 2D and 3D: the room
379
+ * widths a push of it shows (its face to each facing wall, both sides — ID),
380
+ * and for an exterior wall the outside size across the building (OD). `faces`
381
+ * picks which; a wall with no outside (rooms on both faces) shows its room
382
+ * widths whatever the choice. Each dimension runs `from` the selected wall,
383
+ * so typing a value moves that wall (`commitDimensionValue`).
384
+ */
385
+ export function wallSelectionDimensions({
386
+ rooms,
387
+ wall,
388
+ walls,
389
+ faces,
390
+ resolve,
391
+ baseElevation,
392
+ }: {
393
+ rooms: readonly PushDimensionRoom[]
394
+ wall: WallNode
395
+ /** The level's walls, for the opposite exterior wall. */
396
+ walls: readonly WallNode[]
397
+ faces: WallDimensionFaces
398
+ resolve: (id: string) => AnyNode | undefined
399
+ /** The wall's base, for a dimension with no room floor to stand on. */
400
+ baseElevation: number
401
+ }): WallSelectionDimension[] {
402
+ const interior = wallPushDimensions({ rooms, wall, outward: [0, 0], distance: 0, resolve }).map(
403
+ (dimension): WallSelectionDimension => ({ ...dimension, faces: 'interior' }),
404
+ )
405
+ const roomFaces = new Set(rooms.map((room) => room.face))
406
+ const exteriorFace = roomFaces.size === 1 ? (roomFaces.has('a') ? 'b' : 'a') : null
407
+ if (!exteriorFace || faces === 'interior') return interior
408
+ const overall = overallExteriorDimension(wall, exteriorFace, walls)
409
+ const exterior: WallSelectionDimension[] = overall
410
+ ? [
411
+ {
412
+ ...overall,
413
+ key: 'exterior',
414
+ faces: 'exterior',
415
+ elevation: (rooms[0]?.elevation ?? baseElevation) + DIMENSION_FLOOR_LIFT,
416
+ },
417
+ ]
418
+ : []
419
+ return faces === 'exterior' ? exterior : [...interior, ...exterior]
420
+ }
421
+
198
422
  /**
199
423
  * The live dimensions of a mezzanine edge push: the resting edge moved
200
424
  * `distance` along `outward`, to each facing parallel edge of the outline the
@@ -868,17 +868,34 @@ describe('room drill-down state through the mounted selection manager', () => {
868
868
  expect(useViewer.getState().selection.selectedIds).toEqual(['wall_free'])
869
869
  })
870
870
  })
871
- test('the 2D plan resolves walls room-first and drills on the second click', async () => {
871
+ test('the 2D plan gives a wall hit to the wall and a room interior to the room', async () => {
872
872
  await withRooms(async ({ left, right }) => {
873
- expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toEqual(left)
873
+ // Without the kind's `planHitSelectsNode`, a band hit stays room-first.
874
874
  expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toEqual(left)
875
- expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toEqual(right)
876
- expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
877
- const hit = resolvePlanRoomHit('wall_west', [-0.05, 2])
875
+ // The wall definition declares it (its floor-plan extension).
876
+ const get = nodeRegistry.get.bind(nodeRegistry)
877
+ const spy = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
878
+ kind === 'wall'
879
+ ? ({
880
+ ...get(kind),
881
+ extensions: { 'pascal:editor/floorplan': { planHitSelectsNode: true } },
882
+ } as AnyNodeDefinition)
883
+ : get(kind)) as typeof nodeRegistry.get)
884
+ try {
885
+ // Either half of a shared wall's band is the wall, never the room beside it.
886
+ expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toBeNull()
887
+ expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toBeNull()
888
+ expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toBeNull()
889
+ expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
890
+ } finally {
891
+ spy.mockRestore()
892
+ }
893
+ expect(resolvePlanRoomHit(null, [2, 2])).toEqual(left)
894
+ expect(resolvePlanRoomHit(null, [6, 2])).toEqual(right)
878
895
  const select = async (modifiers = plain) => {
879
896
  let selected = false
880
897
  await act(async () => {
881
- selected = selectRoomFromHit(hit, modifiers, 'wall_west')
898
+ selected = selectRoomFromHit(resolvePlanRoomHit(null, [2, 2]), modifiers, null)
882
899
  })
883
900
  return selected
884
901
  }
@@ -744,58 +744,79 @@ describe('scene clipboard', () => {
744
744
  }
745
745
  })
746
746
 
747
- test("a room copied to another project brings its floor script's artifacts", async () => {
748
- let systemClipboardText = ''
749
- Object.defineProperty(navigator, 'clipboard', {
750
- configurable: true,
751
- value: {
752
- readText: async () => systemClipboardText,
753
- writeText: async (text: string) => {
754
- systemClipboardText = text
747
+ const floorScript = {
748
+ kind: 'script',
749
+ script: 'e'.repeat(64),
750
+ artifact: 'f'.repeat(64),
751
+ inputs: 'key',
752
+ manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 },
753
+ } as const
754
+ const square: [number, number][] = [
755
+ [0, 0],
756
+ [3, 0],
757
+ [3, 3],
758
+ [0, 3],
759
+ ]
760
+ for (const [what, host] of [
761
+ [
762
+ "a room copied to another project brings its floor script's artifacts",
763
+ () =>
764
+ ZoneNode.parse({
765
+ id: 'zone_clipboard-floor-script',
766
+ parentId: sourceLevelId,
767
+ name: 'Hall',
768
+ polygon: square,
769
+ floor: { script: floorScript },
770
+ }),
771
+ ],
772
+ [
773
+ "a slab copied to another project brings its surface script's artifacts",
774
+ () =>
775
+ SlabNode.parse({
776
+ id: 'slab_clipboard-surface-script',
777
+ parentId: sourceLevelId,
778
+ name: 'Terrace',
779
+ polygon: square,
780
+ surfaceScript: floorScript,
781
+ }),
782
+ ],
783
+ ] as const) {
784
+ test(what, async () => {
785
+ let systemClipboardText = ''
786
+ Object.defineProperty(navigator, 'clipboard', {
787
+ configurable: true,
788
+ value: {
789
+ readText: async () => systemClipboardText,
790
+ writeText: async (text: string) => {
791
+ systemClipboardText = text
792
+ },
755
793
  },
756
- },
757
- })
758
- const room = ZoneNode.parse({
759
- id: 'zone_clipboard-floor-script',
760
- parentId: sourceLevelId,
761
- name: 'Hall',
762
- polygon: [
763
- [0, 0],
764
- [3, 0],
765
- [3, 3],
766
- [0, 3],
767
- ],
768
- floor: {
769
- script: {
770
- kind: 'script',
771
- script: 'e'.repeat(64),
772
- artifact: 'f'.repeat(64),
773
- inputs: 'key',
774
- manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 },
794
+ })
795
+ const node = host()
796
+ useScene.setState((state) => ({
797
+ nodes: {
798
+ ...state.nodes,
799
+ [sourceLevelId]: makeLevel(sourceLevelId, [node.id]),
800
+ [node.id]: node,
775
801
  },
776
- },
802
+ }))
803
+ useViewer.getState().setProjectId('project_clipboard-source')
804
+ expect(copySelectedNodesToEditorClipboard([node.id])).toBe(true)
805
+ useViewer.getState().setProjectId('project_clipboard-target')
806
+ const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[])
807
+ const previousStore = getArtifactStore()
808
+ configureArtifactStore({ ...previousStore, copyFrom })
809
+ try {
810
+ await pasteSystemEditorClipboardToLevel(targetLevelId)
811
+ expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(
812
+ new Set(['e'.repeat(64), 'f'.repeat(64)]),
813
+ )
814
+ } finally {
815
+ configureArtifactStore(previousStore)
816
+ useViewer.getState().setProjectId(null)
817
+ }
777
818
  })
778
- useScene.setState((state) => ({
779
- nodes: {
780
- ...state.nodes,
781
- [sourceLevelId]: makeLevel(sourceLevelId, [room.id]),
782
- [room.id]: room,
783
- },
784
- }))
785
- useViewer.getState().setProjectId('project_clipboard-source')
786
- expect(copySelectedNodesToEditorClipboard([room.id])).toBe(true)
787
- useViewer.getState().setProjectId('project_clipboard-target')
788
- const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[])
789
- const previousStore = getArtifactStore()
790
- configureArtifactStore({ ...previousStore, copyFrom })
791
- try {
792
- await pasteSystemEditorClipboardToLevel(targetLevelId)
793
- expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(new Set(['e'.repeat(64), 'f'.repeat(64)]))
794
- } finally {
795
- configureArtifactStore(previousStore)
796
- useViewer.getState().setProjectId(null)
797
- }
798
- })
819
+ }
799
820
 
800
821
  test('navigation during artifact copy does not paste into the next project', async () => {
801
822
  copyScriptedWindow()
@@ -488,14 +488,19 @@ export async function pasteSystemEditorClipboardToLevel(
488
488
  return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
489
489
  }
490
490
 
491
- /** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */
491
+ /**
492
+ * The script and GLB a scripted node, a room's floor script, a slab's surface script or a wall's
493
+ * face scripts reference.
494
+ */
492
495
  function artifactHashes(node: AnyNode): string[] {
493
496
  const sources =
494
497
  node.type === 'zone'
495
498
  ? [node.floor?.script]
496
- : node.type === 'wall'
497
- ? [node.finishScripts?.a, node.finishScripts?.b]
498
- : ['source' in node ? node.source : undefined]
499
+ : node.type === 'slab'
500
+ ? [node.surfaceScript]
501
+ : node.type === 'wall'
502
+ ? [node.finishScripts?.a, node.finishScripts?.b]
503
+ : ['source' in node ? node.source : undefined]
499
504
  return sources.flatMap((source) =>
500
505
  typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [],
501
506
  )
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tab steps the selection through everything stacked under the pointer in the
3
+ * floor plan, top first: where walls
4
+ * overlap, or a short wall lies inside a longer one or under a marker, a click
5
+ * takes the top one and Tab the ones below it in turn. Shift+Tab steps back.
6
+ */
7
+
8
+ import type { AnyNodeId } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+
11
+ let pointer: { x: number; y: number } | null = null
12
+ let tracking = false
13
+
14
+ /** Start following the pointer, so a Tab knows where it is. Idempotent. */
15
+ export function trackSelectionCyclePointer(): void {
16
+ if (tracking || typeof window === 'undefined') return
17
+ tracking = true
18
+ const follow = (event: PointerEvent) => {
19
+ pointer = { x: event.clientX, y: event.clientY }
20
+ }
21
+ window.addEventListener('pointermove', follow, { capture: true, passive: true })
22
+ window.addEventListener('pointerdown', follow, { capture: true, passive: true })
23
+ }
24
+
25
+ /** The plan nodes under a screen point, topmost first, each once. */
26
+ export function floorplanNodesAt(x: number, y: number): string[] {
27
+ if (typeof document === 'undefined') return []
28
+ const ids: string[] = []
29
+ for (const element of document.elementsFromPoint(x, y)) {
30
+ if (!element.closest('[data-floorplan-scene]')) continue
31
+ const entry = element.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
32
+ const id = entry?.dataset.nodeId
33
+ if (id && !ids.includes(id)) ids.push(id)
34
+ }
35
+ return ids
36
+ }
37
+
38
+ /**
39
+ * The id a Tab selects among `stack` (topmost first): the one after the
40
+ * current selection, or before it for Shift+Tab, wrapping round; the top one
41
+ * when the selection is not in the stack.
42
+ */
43
+ export function nextInStack(
44
+ stack: readonly string[],
45
+ selected: readonly string[],
46
+ back: boolean,
47
+ ): string | null {
48
+ if (stack.length === 0) return null
49
+ const index = selected.length === 1 ? stack.indexOf(selected[0]!) : -1
50
+ if (index < 0) return stack[0]!
51
+ return stack[(index + (back ? stack.length - 1 : 1)) % stack.length]!
52
+ }
53
+
54
+ /** Select the next object under the pointer in the floor plan. True when Tab was used. */
55
+ export function cycleSelectionAtPointer(back: boolean): boolean {
56
+ if (!pointer) return false
57
+ const stack = floorplanNodesAt(pointer.x, pointer.y)
58
+ const viewer = useViewer.getState()
59
+ const next = nextInStack(stack, viewer.selection.selectedIds, back)
60
+ if (!next) return false
61
+ viewer.setSelection({ selectedIds: [next as AnyNodeId] })
62
+ return true
63
+ }