@pascal-app/editor 1.0.6 → 1.1.0

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 (382) 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 +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -1,6 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, emitter, type GridEvent, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ formatFixed,
7
+ type GridEvent,
8
+ sceneRegistry,
9
+ usePascalI18n,
10
+ useScene,
11
+ } from '@pascal-app/core'
4
12
  import {
5
13
  CursorSphere,
6
14
  clearPlacementSurface,
@@ -11,17 +19,19 @@ import {
11
19
  isGridSnapActive,
12
20
  isMagneticSnapActive,
13
21
  isTypingTarget,
22
+ isViewVisible,
14
23
  markToolCancelConsumed,
15
24
  publishPlacementSurface,
16
25
  triggerSFX,
17
26
  useEditor,
18
27
  useInteractionScope,
19
28
  } from '@pascal-app/editor'
20
- import { Html } from '@react-three/drei'
21
29
  import { useThree } from '@react-three/fiber'
22
30
  import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
23
31
  import { createPortal } from 'react-dom'
24
32
  import { type Group, Vector3 } from 'three'
33
+ import { messageWithSlot } from '../../../i18n/message-slot'
34
+ import { Html } from '../../editor/localized-html'
25
35
  import type { RunSurfaceBounds, RunSurfaceTarget } from './distribution-run-contract'
26
36
  import { clearDrawAlignment } from './draw-alignment'
27
37
  import type { RunBodyHit, ScenePort } from './ports'
@@ -218,6 +228,11 @@ export type DistributionRunToolConfig = {
218
228
  active: boolean
219
229
  levelId: AnyNodeId | null
220
230
  toolName: string
231
+ routingMode?: 'surface' | 'horizontal' | 'vertical'
232
+ /** Level-local centreline elevation, or the target height for a vertical leg. */
233
+ elevation?: number
234
+ targetElevation?: number
235
+ connectCrossings?: boolean
221
236
  initialStart?: RunPoint | null
222
237
  initialConnection?: RunConnection | null
223
238
  getPorts: () => ScenePort[]
@@ -516,6 +531,7 @@ function surfacePointFromEvent(
516
531
 
517
532
  export function useDistributionRunTool(config: DistributionRunToolConfig) {
518
533
  const { camera, gl } = useThree()
534
+ const planVisible = useEditor((state) => isViewVisible(state, '2d'))
519
535
  const initialStartRef = useRef<RunPoint | null>(
520
536
  config.initialStart ? [...config.initialStart] : null,
521
537
  )
@@ -526,6 +542,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
526
542
  const [cursor, setCursor] = useState<RunPoint | null>(initialStartRef.current)
527
543
  const [snapTarget, setSnapTarget] = useState<RunPoint | null>(null)
528
544
  const [snapScreen, setSnapScreen] = useState<PortScreenPoint | null>(null)
545
+ const [cursorScreen, setCursorScreen] = useState<PortScreenPoint | null>(null)
529
546
  const [endConnection, setEndConnection] = useState<RunConnection>({
530
547
  port: null,
531
548
  body: null,
@@ -584,6 +601,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
584
601
  const typed = Number.parseFloat(lengthInputRef.current)
585
602
  if (
586
603
  !currentStart ||
604
+ configRef.current.routingMode === 'vertical' ||
587
605
  !Number.isFinite(typed) ||
588
606
  typed <= 0 ||
589
607
  resolved.port ||
@@ -609,28 +627,47 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
609
627
  }
610
628
  }
611
629
 
630
+ // A vertical route rises from its start to the target elevation wherever the
631
+ // pointer is, so it resolves without a pointer event.
632
+ const verticalTarget = (currentStart: RunPoint): ResolvedRunPoint => ({
633
+ point: [
634
+ currentStart[0],
635
+ configRef.current.targetElevation ?? currentStart[1],
636
+ currentStart[2],
637
+ ],
638
+ frame: createRunSurfaceFrame(currentStart),
639
+ surfaceTarget: null,
640
+ snapped: null,
641
+ directionMode: 'vertical',
642
+ port: null,
643
+ body: null,
644
+ })
645
+
612
646
  const resolvePoint = (event: GridEvent): ResolvedRunPoint => {
613
647
  const adapter = configRef.current
614
648
  const hit = surfacePointFromEvent(event, adapter.levelId)
615
649
  const currentStart = startRef.current
616
650
  const previous = lastResolvedRef.current
617
- const lockedWallTarget = currentStart ? lockedWallTargetRef.current : null
651
+ const explicitRoute = adapter.routingMode && adapter.routingMode !== 'surface'
652
+ const lockedWallTarget = currentStart && !explicitRoute ? lockedWallTargetRef.current : null
618
653
  const lockedWallHit =
619
654
  lockedWallTarget && isSameRunWallSurface(lockedWallTarget, event.surfaceHit)
620
- const working = lockedWallTarget
621
- ? {
622
- ...lockedWallTarget.frame,
623
- origin: lockedWallTarget.frame.origin.map(
624
- (value, index) =>
625
- value +
626
- lockedWallTarget.frame.normal[index]! *
627
- (adapter.surfaceClearance?.(lockedWallTarget) ?? 0),
628
- ) as RunPoint,
629
- }
630
- : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart
631
- ? createRunSurfaceFrame(currentStart, UP)
632
- : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null))
633
- const hasSurface = !!event.surfaceHit || !working || !event.localRay
655
+ const working = explicitRoute
656
+ ? createRunSurfaceFrame([0, currentStart?.[1] ?? adapter.elevation ?? 0, 0])
657
+ : lockedWallTarget
658
+ ? {
659
+ ...lockedWallTarget.frame,
660
+ origin: lockedWallTarget.frame.origin.map(
661
+ (value, index) =>
662
+ value +
663
+ lockedWallTarget.frame.normal[index]! *
664
+ (adapter.surfaceClearance?.(lockedWallTarget) ?? 0),
665
+ ) as RunPoint,
666
+ }
667
+ : !event.surfaceHit && previous?.surfaceTarget?.kind === 'wall' && currentStart
668
+ ? createRunSurfaceFrame(currentStart, UP)
669
+ : (previous?.frame ?? (currentStart ? createRunSurfaceFrame(currentStart) : null))
670
+ const hasSurface = !explicitRoute && (!!event.surfaceHit || !working || !event.localRay)
634
671
  const target = lockedWallTarget ?? (hasSurface ? hit.target : null)
635
672
  const hitForCursor = lockedWallTarget
636
673
  ? lockedWallHit
@@ -643,16 +680,25 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
643
680
  hit: hitForCursor,
644
681
  working,
645
682
  ray: event.localRay,
646
- fallback: previous?.point ?? currentStart ?? hit.point,
683
+ fallback: explicitRoute ? [...hit.point] : (previous?.point ?? currentStart ?? hit.point),
647
684
  clearance: adapter.surfaceClearance?.(target) ?? 0,
648
685
  })
686
+ const routeElevation = currentStart?.[1] ?? adapter.elevation ?? 0
687
+ if (explicitRoute) {
688
+ resolved.point[1] = routeElevation
689
+ if (currentStart && adapter.routingMode === 'vertical') return verticalTarget(currentStart)
690
+ }
649
691
  const bypass = event.nativeEvent?.altKey === true
650
692
  const gridStep = !bypass && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
651
693
  const angleLocked = !bypass && (isAngleSnapActive() || isGridSnapActive())
652
694
  let point = currentStart
653
695
  ? resolved.point
654
696
  : snapRunPointToSurface(resolved.point, resolved.frame, gridStep)
655
- const forcedDirection = forcedDirectionRef.current
697
+ const selectedDirection = forcedDirectionRef.current
698
+ const forcedDirection =
699
+ explicitRoute && selectedDirection && Math.abs(selectedDirection[1]) > 1e-6
700
+ ? null
701
+ : selectedDirection
656
702
  if (currentStart && forcedDirection) {
657
703
  point = projectRunToDirection(currentStart, point, forcedDirection)
658
704
  }
@@ -665,7 +711,11 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
665
711
  if (currentStart && gridStep > 0) {
666
712
  point = snapRunLength(currentStart, point, gridStep)
667
713
  }
668
- const sample = { frame: resolved.frame, surfaceTarget: target }
714
+ const drawingTarget: RunSurfaceTarget | null =
715
+ explicitRoute && adapter.levelId
716
+ ? { kind: 'surface', levelId: adapter.levelId, frame: resolved.frame }
717
+ : target
718
+ const sample = { frame: resolved.frame, surfaceTarget: drawingTarget }
669
719
  const native = (event.nativeEvent ?? {}) as {
670
720
  clientX?: number
671
721
  clientY?: number
@@ -705,11 +755,22 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
705
755
  const screen = projectConnection(candidate)
706
756
  return !!screen && Math.hypot(screen.x - pointer[0], screen.y - pointer[1]) <= 12
707
757
  }
758
+ // An explicit route may only connect at the height its free end reaches above that
759
+ // plan point, which includes the adapter's fall.
760
+ const routeHeightAt = (candidate: readonly number[]): number => {
761
+ const level: RunPoint = [candidate[0]!, routeElevation, candidate[2]!]
762
+ return currentStart
763
+ ? (adapter.resolveFreeEnd?.(currentStart, level, startConnectionRef.current) ?? level)[1]
764
+ : routeElevation
765
+ }
766
+ const onRoute = (candidate: readonly number[]) =>
767
+ !explicitRoute || Math.abs(candidate[1]! - routeHeightAt(candidate)) < 1e-4
708
768
  const resolveConnection = (candidate: RunPoint): ResolvedRunPoint | null => {
709
769
  if (bypass || !(isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()))
710
770
  return null
711
771
  const source = startConnectionRef.current.port
712
- const hit = findScreenPort(adapter.getPorts(), pointer, projectConnection, source)
772
+ const ports = adapter.getPorts().filter((port) => onRoute(port.position))
773
+ const hit = findScreenPort(ports, pointer, projectConnection, source)
713
774
  if (hit) {
714
775
  return {
715
776
  frame: createRunSurfaceFrame([...hit.port.position]),
@@ -722,8 +783,19 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
722
783
  body: null,
723
784
  }
724
785
  }
725
- const body = adapter.findBody(candidate, target)
726
- if (body && acceptsConnection(body.point) && body.nodeId !== source?.nodeId) {
786
+ const body =
787
+ adapter.connectCrossings === false
788
+ ? null
789
+ : adapter.findBody(
790
+ explicitRoute ? [candidate[0], routeHeightAt(candidate), candidate[2]] : candidate,
791
+ drawingTarget,
792
+ )
793
+ if (
794
+ body &&
795
+ onRoute(body.point) &&
796
+ acceptsConnection(body.point) &&
797
+ body.nodeId !== source?.nodeId
798
+ ) {
727
799
  return {
728
800
  ...sample,
729
801
  point: body.point,
@@ -739,7 +811,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
739
811
  if (connection) return connection
740
812
  // Rank displayed directions before a surface projection discards height.
741
813
  if (currentStart && event.localRay && !bypass && (forcedDirection || angleLocked)) {
742
- const source = startConnectionRef.current.port?.direction ?? null
814
+ const source = explicitRoute ? null : (startConnectionRef.current.port?.direction ?? null)
743
815
  const candidates = forcedDirection
744
816
  ? [forcedDirection]
745
817
  : source
@@ -795,7 +867,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
795
867
  forcedDirection ||
796
868
  (Math.abs(directionHit.direction[1]) > 1e-6 && target?.kind === 'floor')
797
869
  ? null
798
- : target,
870
+ : drawingTarget,
799
871
  snapped: null,
800
872
  directionMode: 'angle',
801
873
  port: null,
@@ -943,7 +1015,12 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
943
1015
  setAltActive(false)
944
1016
  }
945
1017
 
1018
+ const updateCursorScreen = (event: GridEvent) => {
1019
+ const pointer = getGridEventScreenProjection(event)?.pointer
1020
+ setCursorScreen(pointer ? { x: pointer[0], y: pointer[1], depth: 0 } : null)
1021
+ }
946
1022
  const onMove = (event: RunPointerEvent) => {
1023
+ updateCursorScreen(event)
947
1024
  lastPointerRef.current = event
948
1025
  const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
949
1026
  if (typeof clientY === 'number') lastClientYRef.current = clientY
@@ -965,6 +1042,7 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
965
1042
  }
966
1043
 
967
1044
  const onClick = (event: GridEvent) => {
1045
+ updateCursorScreen(event)
968
1046
  event.nativeEvent?.stopPropagation?.()
969
1047
  const currentStart = startRef.current
970
1048
  if (altAnchorRef.current && currentStart) {
@@ -985,8 +1063,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
985
1063
  port: resolved.port,
986
1064
  body: resolved.port ? null : resolved.body,
987
1065
  }
988
- if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) {
989
- lockedWallTargetRef.current = resolved.surfaceTarget
1066
+ if (!configRef.current.routingMode || configRef.current.routingMode === 'surface') {
1067
+ if (shouldLockRunWallSurface(resolved.surfaceTarget, connection)) {
1068
+ lockedWallTargetRef.current = resolved.surfaceTarget
1069
+ }
990
1070
  }
991
1071
  triggerSFX('sfx:grid-snap')
992
1072
  startConnectionRef.current = connection
@@ -997,6 +1077,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
997
1077
  setSnapTarget(resolved.snapped)
998
1078
  setSnapScreen(resolved.snapScreen ?? null)
999
1079
  setEndConnection({ port: null, body: null })
1080
+ // A vertical route ends at the target, not under the pointer: show that
1081
+ // riser now so the next click or Enter places what the preview shows.
1082
+ if (configRef.current.routingMode === 'vertical')
1083
+ updateCursor(verticalTarget(resolved.point))
1000
1084
  return
1001
1085
  }
1002
1086
  const typedResolved = applyTypedLength(resolved)
@@ -1028,6 +1112,11 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1028
1112
  setAltActive(true)
1029
1113
  return
1030
1114
  }
1115
+ if (
1116
+ configRef.current.routingMode === 'vertical' &&
1117
+ (event.key === 'Tab' || /^[0-9.,]$/.test(event.key))
1118
+ )
1119
+ return
1031
1120
  if (
1032
1121
  event.key === 'Tab' &&
1033
1122
  startRef.current &&
@@ -1037,7 +1126,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1037
1126
  ) {
1038
1127
  event.preventDefault()
1039
1128
  event.stopImmediatePropagation()
1040
- const source = startConnectionRef.current.port?.direction
1129
+ const source =
1130
+ configRef.current.routingMode === 'horizontal'
1131
+ ? null
1132
+ : startConnectionRef.current.port?.direction
1041
1133
  const candidates = source
1042
1134
  ? run3DDirectionCandidates(source)
1043
1135
  : runHorizontalDirectionCandidates(null)
@@ -1109,7 +1201,10 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1109
1201
  }
1110
1202
 
1111
1203
  refreshCursorRef.current = () => {
1112
- if (lastPointerRef.current)
1204
+ const currentStart = startRef.current
1205
+ if (currentStart && configRef.current.routingMode === 'vertical')
1206
+ updateCursor(verticalTarget(currentStart))
1207
+ else if (lastPointerRef.current)
1113
1208
  updateCursor(applyTypedLength(resolvePoint(lastPointerRef.current)))
1114
1209
  else if (lastResolvedRef.current) updateCursor(applyTypedLength(lastResolvedRef.current))
1115
1210
  }
@@ -1143,18 +1238,39 @@ export function useDistributionRunTool(config: DistributionRunToolConfig) {
1143
1238
  }
1144
1239
  }, [camera, gl, config.active, updateLengthInput])
1145
1240
 
1241
+ // The resolver reads config through a ref; changing these controls must refresh a stationary cursor.
1242
+ // biome-ignore lint/correctness/useExhaustiveDependencies: configuration changes invalidate the cursor
1243
+ useEffect(() => {
1244
+ forcedDirectionRef.current = null
1245
+ if (config.routingMode !== 'surface') lockedWallTargetRef.current = null
1246
+ refreshCursor()
1247
+ }, [
1248
+ config.routingMode,
1249
+ config.elevation,
1250
+ config.targetElevation,
1251
+ config.connectCrossings,
1252
+ refreshCursor,
1253
+ ])
1254
+
1146
1255
  return {
1147
1256
  refreshCursor,
1257
+ routingMode: config.routingMode,
1148
1258
  start,
1149
1259
  cursor,
1150
1260
  snapTarget,
1151
1261
  snapScreen,
1262
+ cursorScreen: planVisible ? cursorScreen : null,
1152
1263
  altActive,
1153
1264
  directionMode,
1154
1265
  lengthInput,
1155
1266
  validationMessage,
1156
1267
  onLengthInputChange: updateLengthInput,
1157
1268
  onDirectionSelect: (direction: RunPoint) => {
1269
+ if (
1270
+ configRef.current.routingMode === 'vertical' ||
1271
+ (configRef.current.routingMode === 'horizontal' && Math.abs(direction[1]) > 1e-6)
1272
+ )
1273
+ return
1158
1274
  forcedDirectionRef.current = direction
1159
1275
  setDirectionMode('angle')
1160
1276
  refreshCursorRef.current()
@@ -1175,6 +1291,7 @@ export function DistributionRunCursor({
1175
1291
  extraParts = [],
1176
1292
  cursorRef,
1177
1293
  directionMode,
1294
+ routingMode,
1178
1295
  startDirection,
1179
1296
  lengthInput,
1180
1297
  validationMessage,
@@ -1190,6 +1307,7 @@ export function DistributionRunCursor({
1190
1307
  extraParts?: DimensionPillPart[]
1191
1308
  cursorRef?: RefObject<Group | null>
1192
1309
  directionMode: RunDirectionMode
1310
+ routingMode?: 'surface' | 'horizontal' | 'vertical'
1193
1311
  startDirection?: readonly [number, number, number] | null
1194
1312
  lengthInput?: string
1195
1313
  validationMessage?: string | null
@@ -1197,10 +1315,11 @@ export function DistributionRunCursor({
1197
1315
  onLengthInputChange?: (value: string) => void
1198
1316
  onDirectionSelect?: (direction: RunPoint) => void
1199
1317
  }) {
1318
+ const i18n = usePascalI18n()
1200
1319
  const lengthFieldRef = useRef<HTMLInputElement>(null)
1201
1320
  useEffect(() => {
1202
- if (start) lengthFieldRef.current?.focus()
1203
- }, [start])
1321
+ if (start && routingMode !== 'vertical') lengthFieldRef.current?.focus()
1322
+ }, [start, routingMode])
1204
1323
  if (!cursor) return null
1205
1324
  const parts: DimensionPillPart[] = [
1206
1325
  ...(start
@@ -1261,9 +1380,9 @@ export function DistributionRunCursor({
1261
1380
  cursor={cursor}
1262
1381
  mode={directionMode}
1263
1382
  snapped={!!snapTarget}
1264
- sourceDirection={startDirection ?? null}
1383
+ sourceDirection={routingMode === 'horizontal' ? null : (startDirection ?? null)}
1265
1384
  start={start}
1266
- onDirectionSelect={onDirectionSelect}
1385
+ onDirectionSelect={routingMode === 'vertical' ? undefined : onDirectionSelect}
1267
1386
  />
1268
1387
  )}
1269
1388
  <CursorSphere
@@ -1282,32 +1401,55 @@ export function DistributionRunCursor({
1282
1401
  >
1283
1402
  <div className="flex flex-col items-center gap-1">
1284
1403
  <DimensionPill parts={parts} primary={primary} unit={unit} />
1285
- {start ? (
1404
+ {start && routingMode === 'vertical' ? (
1405
+ <div className="rounded-full border border-border/60 bg-background/90 px-3 py-1 text-xs shadow-sm">
1406
+ {i18n._({
1407
+ id: 'editor.tools.distribution.distributionRunTool.targetElevation',
1408
+ message: 'Target elevation: {elevation} m · click or Enter to place',
1409
+ values: { elevation: formatFixed(cursor[1], 2, i18n.getLocale()) },
1410
+ })}
1411
+ </div>
1412
+ ) : start ? (
1286
1413
  <label className="rounded-full border border-border/60 bg-background/90 px-3 py-1 text-[11px] tabular-nums text-muted-foreground shadow-sm backdrop-blur">
1287
- Length:{' '}
1288
- <input
1289
- className="w-20 bg-transparent text-center text-foreground outline-none"
1290
- data-run-length-input
1291
- inputMode="decimal"
1292
- onKeyDown={(event) => {
1293
- if (
1294
- event.key === 'Backspace' ||
1295
- event.key === 'Delete' ||
1296
- event.key === 'ArrowLeft' ||
1297
- event.key === 'ArrowRight'
1298
- ) {
1299
- event.stopPropagation()
1300
- }
1301
- }}
1302
- onChange={(event) => onLengthInputChange?.(event.target.value)}
1303
- onPointerDown={(event) => event.stopPropagation()}
1304
- placeholder="type a length"
1305
- ref={lengthFieldRef}
1306
- style={{ pointerEvents: 'auto' }}
1307
- type="text"
1308
- value={lengthInput ?? ''}
1309
- />{' '}
1310
- m<span className="ml-2">Tab: direction</span>
1414
+ {messageWithSlot(
1415
+ i18n,
1416
+ /* i18n */ {
1417
+ id: 'editor.tools.distribution.distributionRunTool.lengthField',
1418
+ message: 'Length: {field} m',
1419
+ },
1420
+ 'field',
1421
+ <input
1422
+ className="w-20 bg-transparent text-center text-foreground outline-none"
1423
+ data-run-length-input
1424
+ inputMode="decimal"
1425
+ onKeyDown={(event) => {
1426
+ if (
1427
+ event.key === 'Backspace' ||
1428
+ event.key === 'Delete' ||
1429
+ event.key === 'ArrowLeft' ||
1430
+ event.key === 'ArrowRight'
1431
+ ) {
1432
+ event.stopPropagation()
1433
+ }
1434
+ }}
1435
+ onChange={(event) => onLengthInputChange?.(event.target.value)}
1436
+ onPointerDown={(event) => event.stopPropagation()}
1437
+ placeholder={i18n._({
1438
+ id: 'editor.tools.distribution.distributionRunTool.typeALength',
1439
+ message: 'type a length',
1440
+ })}
1441
+ ref={lengthFieldRef}
1442
+ style={{ pointerEvents: 'auto' }}
1443
+ type="text"
1444
+ value={lengthInput ?? ''}
1445
+ />,
1446
+ )}
1447
+ <span className="ml-2">
1448
+ {i18n._({
1449
+ id: 'editor.tools.distribution.distributionRunTool.tabDirection',
1450
+ message: 'Tab: direction',
1451
+ })}
1452
+ </span>
1311
1453
  </label>
1312
1454
  ) : null}
1313
1455
  {validationMessage ? (
@@ -1,8 +1,7 @@
1
- import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
1
+ import type { AnyNodeId, SpawnNode } from '@pascal-app/core'
2
2
  import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
3
3
  import { Suspense, useMemo } from 'react'
4
4
  import { useMovingNode } from '../../../store/use-interaction-scope'
5
- import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
5
  import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
6
  import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
8
7
 
@@ -20,8 +19,6 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
20
19
  * 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
21
20
  * declare `capabilities.movable` (shelf, spawn, duct-terminal,
22
21
  * hvac-equipment, …).
23
- * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
24
- * mover hasn't been ported to a kind-owned affordance yet.
25
22
  */
26
23
  export const MoveTool: React.FC<{
27
24
  onNodeMoved?: (nodeId: AnyNodeId) => void
@@ -47,7 +44,5 @@ export const MoveTool: React.FC<{
47
44
  return <MoveRegistryNodeTool node={movingNode} />
48
45
  }
49
46
 
50
- if (movingNode.type === 'elevator')
51
- return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
52
47
  return null
53
48
  }
@@ -22,12 +22,12 @@ import {
22
22
  WallNode,
23
23
  } from '@pascal-app/core'
24
24
  import { useViewer } from '@pascal-app/viewer'
25
- import { Html } from '@react-three/drei'
26
25
  import { act, create } from '@react-three/test-renderer'
27
26
  import { Children, Component, cloneElement, isValidElement, type ReactNode } from 'react'
28
27
  import { Group } from 'three'
29
28
  import useEditor from '../../../store/use-editor'
30
29
  import useInteractionScope from '../../../store/use-interaction-scope'
30
+ import { Html } from '../../editor/localized-html'
31
31
  import { useDraftNode } from './use-draft-node'
32
32
  import { usePlacementCoordinator } from './use-placement-coordinator'
33
33
 
@@ -1,6 +1,7 @@
1
- import { beforeEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  BlockNode,
4
+ BuildingNode,
4
5
  type CeilingEvent,
5
6
  type CeilingNode,
6
7
  type GridEvent,
@@ -11,7 +12,11 @@ import {
11
12
  type WallEvent,
12
13
  type WallNode,
13
14
  } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
14
16
  import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL } from '../../../lib/contextual-help'
18
+ import useEditor from '../../../store/use-editor'
19
+ import useInteractionScope from '../../../store/use-interaction-scope'
15
20
  import {
16
21
  ceilingStrategy,
17
22
  faceHostStrategy,
@@ -498,6 +503,64 @@ describe('floorStrategy.move', () => {
498
503
  expect(result.cursorPosition[1]).toBe(0)
499
504
  expect(result.nodeUpdate?.position?.[1]).toBe(0)
500
505
  })
506
+
507
+ describe('in grid mode', () => {
508
+ const originalSelection = useViewer.getState().selection
509
+
510
+ beforeEach(() => {
511
+ // The group-move scope resolves to the item snap context without the
512
+ // node registry; grid mode at a 0.5 m step.
513
+ useInteractionScope
514
+ .getState()
515
+ .begin({ kind: 'handle-drag', nodeId: 'item_test', handle: GROUP_MOVE_DRAG_LABEL })
516
+ useEditor.getState().setSnappingMode('item', 'grid')
517
+ useEditor.getState().setGridSnapStep(0.5)
518
+ })
519
+
520
+ afterEach(() => {
521
+ useInteractionScope.getState().end()
522
+ useEditor.getState().setSnappingMode('item', 'lines')
523
+ useViewer.setState({ selection: originalSelection } as never)
524
+ })
525
+
526
+ // A 0.5 m plant centres half a cell off the lattice, its edges on it.
527
+ test("inside a turned building it lands on the building's lattice, square to the house", () => {
528
+ const yaw = (17 * Math.PI) / 180
529
+ const building = BuildingNode.parse({ position: [12.37, 0, -8.91], rotation: [0, yaw, 0] })
530
+ useScene.setState((state) => ({
531
+ ...state,
532
+ nodes: { ...state.nodes, [building.id]: building },
533
+ }))
534
+ useViewer.setState({
535
+ selection: { buildingId: building.id, levelId: null, zoneId: null, selectedIds: [] },
536
+ } as never)
537
+ const local: [number, number] = [1.37, 2.61]
538
+ const cos = Math.cos(yaw)
539
+ const sin = Math.sin(yaw)
540
+ const event: GridEvent = {
541
+ position: [
542
+ 12.37 + local[0] * cos + local[1] * sin,
543
+ 0,
544
+ -8.91 - local[0] * sin + local[1] * cos,
545
+ ],
546
+ localPosition: [local[0], 0, local[1]],
547
+ nativeEvent: {} as GridEvent['nativeEvent'],
548
+ }
549
+
550
+ const result = floorStrategy.move(floorItemContext(), event)
551
+ expect(result?.nodeUpdate?.position?.[0]).toBeCloseTo(1.25, 9)
552
+ expect(result?.nodeUpdate?.position?.[2]).toBeCloseTo(2.75, 9)
553
+ })
554
+
555
+ test('outside any building it keeps the world grid', () => {
556
+ useViewer.setState({
557
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
558
+ } as never)
559
+ const result = floorStrategy.move(floorItemContext(), makeGridEvent(5.37, 0, -3.12))
560
+ expect(result?.nodeUpdate?.position?.[0]).toBeCloseTo(5.25, 9)
561
+ expect(result?.nodeUpdate?.position?.[2]).toBeCloseTo(-3.25, 9)
562
+ })
563
+ })
501
564
  })
502
565
 
503
566
  describe('ceilingStrategy.move', () => {
@@ -27,13 +27,12 @@ import {
27
27
  resolveSurfacePlacement,
28
28
  roofFacePointToSegment,
29
29
  sceneRegistry,
30
- snapLocalXZInWorld,
31
30
  useScene,
32
31
  wouldCreateHostingCycle,
33
32
  } from '@pascal-app/core'
34
33
  import { Euler, Quaternion, Vector3 } from 'three'
35
34
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
36
- import { getActiveBuildingPose } from '../../../lib/world-grid-snap'
35
+ import { snapPlanPointToActiveGrid } from '../../../lib/world-grid-snap'
37
36
  import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
38
37
  import {
39
38
  calculateItemRotation,
@@ -73,17 +72,14 @@ export const floorStrategy = {
73
72
  const [dimX, , dimZ] = dims
74
73
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
75
74
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
76
- // Snap on the world XZ grid (the grid the editor renders) so the
77
- // item edges land on the visible grid even when the active building
78
- // is rotated; then project the world point back into building-local
79
- // for storage. Without this, a rotated building drags placement off
80
- // the world grid.
75
+ // Snap on the lattice the grid is drawn on: a turned building's own
76
+ // lattice inside it (item edges square to the house), the world grid
77
+ // outside any building.
81
78
  // Snapping is governed by the active mode (snapToGrid returns raw in Off /
82
79
  // non-grid modes); Alt is force-place only and never bypasses snapping here.
83
80
  const dimensions = [swapDims ? dimZ : dimX, swapDims ? dimX : dimZ] as const
84
- const [x, z] = snapLocalXZInWorld(
81
+ const [x, z] = snapPlanPointToActiveGrid(
85
82
  [event.localPosition[0], event.localPosition[2]],
86
- getActiveBuildingPose(),
87
83
  (value, axis) => snapToGrid(value, dimensions[axis]),
88
84
  )
89
85
  const y = ctx.gridPosition.y