@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
@@ -9,6 +9,7 @@ import {
9
9
  collectAlignmentAnchors,
10
10
  emitter,
11
11
  findLevelAncestorId,
12
+ formatLinearMeasurement,
12
13
  type GridEvent,
13
14
  getScaledDimensions,
14
15
  getWallBodyCenterOffset,
@@ -30,7 +31,6 @@ import {
30
31
  type WallEvent,
31
32
  } from '@pascal-app/core'
32
33
  import { useViewer } from '@pascal-app/viewer'
33
- import { Html } from '@react-three/drei'
34
34
  import { useFrame, useThree } from '@react-three/fiber'
35
35
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
36
36
  import {
@@ -50,7 +50,6 @@ import {
50
50
  publishPlacementSurface,
51
51
  } from '../../../lib/active-placement-surface'
52
52
  import { EDITOR_LAYER } from '../../../lib/constants'
53
- import { formatLinearMeasurement } from '../../../lib/measurements'
54
53
  import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
55
54
  import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
56
55
  import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -74,6 +73,7 @@ import useEditor, {
74
73
  import useFacingPose from '../../../store/use-facing-pose'
75
74
  import { getMovingNode } from '../../../store/use-interaction-scope'
76
75
  import usePlacementPreview from '../../../store/use-placement-preview'
76
+ import { Html } from '../../editor/localized-html'
77
77
  import {
78
78
  createItemSurfaceGridDispatch,
79
79
  createItemSurfacePointerArbitration,
@@ -1008,9 +1008,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1008
1008
 
1009
1009
  // Floor grab-offset: the item tracks the grabbed point instead of snapping
1010
1010
  // its origin under the cursor. The offset is computed in building-local space
1011
- // (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
1012
- // (`event.position`), so the corrected local point is re-projected to a
1013
- // corrected world point and both frames carry the offset to stay consistent.
1011
+ // (`event.localPosition`, which `floorStrategy.move` snaps), and the corrected
1012
+ // local point is re-projected to a corrected world point so both frames carry
1013
+ // the offset and stay consistent.
1014
1014
  const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
1015
1015
  if (relativeFloorStart === null) return event
1016
1016
  const rawX = event.localPosition[0]
@@ -1871,6 +1871,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1871
1871
  position: floorPos,
1872
1872
  rotation,
1873
1873
  })
1874
+ // The surface moves published a building-space cursor (the host's top, level height
1875
+ // included) as the live transform. Back on the floor the item renders level-local, so
1876
+ // that stale height lifted it by its level's elevation (a bedside left on a 4th-floor bed
1877
+ // floated ~10 m up) until the next floor move: publish the floor pose now.
1878
+ useLiveTransforms.getState().set(draft.id, { position: floorPos, rotation: rotation[1] })
1874
1879
  }
1875
1880
 
1876
1881
  revalidate()
@@ -2717,13 +2722,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2717
2722
 
2718
2723
  // ---- Subscribe ----
2719
2724
 
2725
+ // With a level selected, surfaces on other floors don't capture the draft: past the building
2726
+ // edge, through a window or over a stairwell the ray finds a lower floor's furniture, slab or
2727
+ // wall, and adopting it re-parented the item to that floor mid-gesture (and paid a level
2728
+ // change each time). The floor cursor keeps following the selected level instead.
2729
+ const onActiveLevel = (node: AnyNode | undefined) => {
2730
+ const levelId = useViewer.getState().selection.levelId
2731
+ if (!levelId || !node) return true
2732
+ return resolveLevelId(node, useScene.getState().nodes) === levelId
2733
+ }
2720
2734
  const specializedKinds = new Set<string>()
2721
- const subscribeSpecialized = <E,>(key: string, handler: (event: E) => void) => {
2735
+ const unsubscribeSpecialized: (() => void)[] = []
2736
+ const subscribeSpecialized = <E extends { node?: AnyNode }>(
2737
+ key: string,
2738
+ handler: (event: E) => void,
2739
+ ) => {
2722
2740
  specializedKinds.add(key.slice(0, key.lastIndexOf(':')))
2723
- emitter.on(key as never, handler as never)
2741
+ const gated = (event: E) => {
2742
+ if (onActiveLevel(event.node)) handler(event)
2743
+ }
2744
+ emitter.on(key as never, gated as never)
2745
+ unsubscribeSpecialized.push(() => emitter.off(key as never, gated as never))
2724
2746
  }
2725
2747
  const genericRoute = (phase: 'enter' | 'move' | 'click' | 'leave') => (event: NodeEvent) => {
2726
2748
  if (specializedKinds.has(event.node.type)) return
2749
+ if (!onActiveLevel(event.node)) return
2727
2750
  if (
2728
2751
  !surfaceOwnership.allows(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
2729
2752
  )
@@ -2820,38 +2843,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2820
2843
  gridDispatch.cancel()
2821
2844
  emitter.off('grid:move', gridDispatch.schedule)
2822
2845
  emitter.off('grid:click', onGridClick)
2823
- emitter.off('item:enter', onItemEnter)
2824
- emitter.off('item:move', onItemMove)
2825
- emitter.off('item:leave', onItemLeave)
2826
- emitter.off('item:click', onItemClick)
2827
- emitter.off('wall:enter', onWallEnter)
2828
- emitter.off('wall:move', onWallMove)
2829
- emitter.off('wall:click', onWallClick)
2830
- emitter.off('wall:leave', onWallLeave)
2831
- emitter.off('roof:enter', onRoofWallEnter)
2832
- emitter.off('roof:move', onRoofWallMove)
2833
- emitter.off('roof:click', onRoofWallClick)
2834
- emitter.off('roof:leave', onRoofWallLeave)
2846
+ for (const unsubscribe of unsubscribeSpecialized) unsubscribe()
2835
2847
  emitter.off('node:enter', onNodeEnter)
2836
2848
  emitter.off('node:move', onNodeMove)
2837
2849
  emitter.off('node:click', onNodeClick)
2838
2850
  emitter.off('node:leave', onNodeLeave)
2839
- emitter.off('ceiling:enter', onCeilingEnter)
2840
- emitter.off('ceiling:move', onCeilingMove)
2841
- emitter.off('ceiling:click', onCeilingClick)
2842
- emitter.off('ceiling:leave', onCeilingLeave)
2843
- emitter.off('cabinet:enter', onItemEnter)
2844
- emitter.off('procedural-item:enter' as never, onItemEnter)
2845
- emitter.off('cabinet:move', onItemMove)
2846
- emitter.off('procedural-item:move' as never, onItemMove)
2847
- emitter.off('cabinet:click', onItemClick)
2848
- emitter.off('procedural-item:click' as never, onItemClick)
2849
- emitter.off('cabinet:leave', onItemLeave)
2850
- emitter.off('procedural-item:leave' as never, onItemLeave)
2851
- emitter.off('shelf:enter', onShelfEnter)
2852
- emitter.off('shelf:move', onShelfMove)
2853
- emitter.off('shelf:click', onShelfClick)
2854
- emitter.off('shelf:leave', onShelfLeave)
2855
2851
  emitter.off('node:click', commitFloorOnSurfaceClick as never)
2856
2852
  emitter.off('tool:cancel', onCancel)
2857
2853
  window.removeEventListener('keydown', onKeyDown)
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  emitter,
7
+ formatLinearMeasurement,
7
8
  sceneRegistry,
8
9
  useScene,
9
10
  type WallFace,
@@ -27,7 +28,6 @@ import {
27
28
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
28
29
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
29
30
  import { EDITOR_LAYER } from '../../../lib/constants'
30
- import { formatLinearMeasurement } from '../../../lib/measurements'
31
31
  import { paintRegionTargets, usePaintRegionMode } from '../../../lib/paint-region-mode'
32
32
  import { isTypingTarget } from '../../../lib/typing-target'
33
33
  import {
@@ -22,6 +22,7 @@ import {
22
22
  } from '@pascal-app/core/procedural-items'
23
23
  import { Group, Vector3 } from 'three'
24
24
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
25
+ import useEditor from '../../../store/use-editor'
25
26
  import { registerHostingTestNode } from '../__fixtures__/hosting'
26
27
  import {
27
28
  createItemSurfaceGridDispatch,
@@ -62,6 +63,7 @@ const host = ItemNode.parse({
62
63
  let restoreRegistry: () => void
63
64
  let oldNodes: ReturnType<typeof useScene.getState>['nodes']
64
65
  let oldRoots: AnyNodeId[]
66
+ let oldSnapping: ReturnType<typeof useEditor.getState>['snappingModeByContext']
65
67
 
66
68
  beforeEach(() => {
67
69
  globalThis.requestAnimationFrame = (callback) => {
@@ -69,6 +71,14 @@ beforeEach(() => {
69
71
  return 0
70
72
  }
71
73
  globalThis.cancelAnimationFrame = () => {}
74
+ // Poses are asserted raw: whatever context a previous suite left active
75
+ // (a tool, a scope, its snapping mode) must not snap them to the grid.
76
+ oldSnapping = useEditor.getState().snappingModeByContext
77
+ useEditor.setState({
78
+ snappingModeByContext: Object.fromEntries(
79
+ Object.keys(oldSnapping).map((context) => [context, 'off']),
80
+ ) as typeof oldSnapping,
81
+ })
72
82
  restoreRegistry = nodeRegistry._snapshot()
73
83
  oldNodes = useScene.getState().nodes
74
84
  oldRoots = useScene.getState().rootNodeIds
@@ -119,6 +129,7 @@ afterEach(() => {
119
129
  globalThis.requestAnimationFrame = originalRaf
120
130
  globalThis.cancelAnimationFrame = originalCancelRaf
121
131
  restoreRegistry()
132
+ useEditor.setState({ snappingModeByContext: oldSnapping })
122
133
  useLiveTransforms.getState().clear(original.id)
123
134
  for (const id of ['level_surface-move', host.id, 'item_second-host'])
124
135
  sceneRegistry.nodes.delete(id)
@@ -107,6 +107,7 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
107
107
  cancelAnimationFrame: { configurable: true, value: (key: number) => frames.delete(key) },
108
108
  IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
109
109
  })
110
+ let supportY = 0
110
111
  core.registerNode({
111
112
  kind: node.type,
112
113
  schemaVersion: 1,
@@ -114,7 +115,14 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
114
115
  schema: z.object({ type: z.literal(node.type) }).passthrough(),
115
116
  defaults: () => ({}),
116
117
  capabilities: {
117
- movable: { isValidPosition: () => false },
118
+ movable: {
119
+ isValidPosition: () => false,
120
+ resolvePosition: ({ position }: { position: [number, number, number] }) => [
121
+ position[0],
122
+ supportY,
123
+ position[2],
124
+ ],
125
+ },
118
126
  floorPlaced: {
119
127
  collides: true,
120
128
  footprint: (candidate: AnyNode) => ({
@@ -251,6 +259,39 @@ test('focus completion: mounted registry mover owns only outside R T and Alt', a
251
259
  expect((await key('t', false)).defaultPrevented).toBe(true)
252
260
  expect(mesh.rotation.y).toBeCloseTo(0.25)
253
261
  expect(core.useLiveTransforms.getState().transforms.get(id)?.rotation).toBeCloseTo(0.25)
262
+ supportY = 0.75
263
+ await act(async () => {
264
+ core.emitter.emit('grid:move', {
265
+ nativeEvent: {
266
+ target: { closest: () => null },
267
+ } as unknown as import('@pascal-app/core').GridEvent['nativeEvent'],
268
+ position: [7, 0, 3],
269
+ localPosition: [7, 0, 3],
270
+ stopPropagation() {},
271
+ } as import('@pascal-app/core').GridEvent)
272
+ flushFrames()
273
+ })
274
+ expect(mesh.position.y).toBeCloseTo(0.75)
275
+ expect(core.useLiveTransforms.getState().transforms.get(id)?.position?.[1]).toBeCloseTo(0.75)
276
+ expect(
277
+ (core.useScene.getState().nodes[id] as unknown as { position: number[] }).position[1],
278
+ ).toBe(0)
279
+ supportY = 1.25
280
+ await key('Alt', false)
281
+ await act(async () => {
282
+ core.emitter.emit('grid:click', {
283
+ nativeEvent: {
284
+ target: { closest: () => null },
285
+ } as unknown as import('@pascal-app/core').GridEvent['nativeEvent'],
286
+ position: [7, 0, 3],
287
+ localPosition: [7, 0, 3],
288
+ stopPropagation() {},
289
+ } as import('@pascal-app/core').GridEvent)
290
+ flushFrames()
291
+ })
292
+ expect(
293
+ (core.useScene.getState().nodes[id] as unknown as { position: number[] }).position[1],
294
+ ).toBeCloseTo(1.25)
254
295
  } finally {
255
296
  if (root)
256
297
  await act(async () => {
@@ -484,6 +484,18 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
484
484
  return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
485
485
  }
486
486
 
487
+ const resolvePosition = (position: [number, number, number]) => {
488
+ const nodes = useScene.getState().nodes
489
+ const current = nodes[node.id] ?? node
490
+ return (
491
+ nodeRegistry.get(current.type)?.capabilities?.movable?.resolvePosition?.({
492
+ node: current,
493
+ position,
494
+ nodes,
495
+ }) ?? position
496
+ )
497
+ }
498
+
487
499
  const keepStartingElevation = (position: [number, number, number], yaw: number) => {
488
500
  if (!preserveLevelAndElevation) return position
489
501
  const rotation = toCommitRotation(yaw)
@@ -534,14 +546,19 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
534
546
  // them through `useLiveNodeOverrides` (transient — no history churn;
535
547
  // GeometrySystem merges overrides via getEffectiveNode). Each connected
536
548
  // node is re-dirtied so its geometry rebuilds against the new override.
537
- const previewConnectivity = (position: [number, number, number], rotationY: number) => {
549
+ // False, previewing nothing, when the connected joints cannot follow.
550
+ const previewConnectivity = (
551
+ position: [number, number, number],
552
+ rotationY: number,
553
+ ): boolean => {
538
554
  const connectivity = connectivityRef.current
539
- if (!connectivity) return
555
+ if (!connectivity) return true
540
556
  const updates = resolveConnectivityUpdates(
541
557
  connectivity,
542
558
  buildPreviewNode(position, rotationY),
543
559
  )
544
- if (updates.length === 0) return
560
+ if (!updates) return false
561
+ if (updates.length === 0) return true
545
562
  useLiveNodeOverrides
546
563
  .getState()
547
564
  .setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
@@ -549,6 +566,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
549
566
  for (const u of updates) {
550
567
  if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
551
568
  }
569
+ return true
552
570
  }
553
571
 
554
572
  const clearConnectivityOverrides = () => {
@@ -1026,7 +1044,11 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1026
1044
  },
1027
1045
  ).position
1028
1046
  }
1029
- position = keepStartingElevation(position, rotationRef.current)
1047
+ position = resolvePosition(keepStartingElevation(position, rotationRef.current))
1048
+ // Carry connected ductwork along (preview only — committed on drop). A
1049
+ // position the connected joints cannot follow holds the last frame,
1050
+ // which is what the drop commits.
1051
+ if (!previewConnectivity(position, rotationRef.current)) return
1030
1052
  const visualPosition = getVisualPosition(position)
1031
1053
  hasMovedRef.current = true
1032
1054
  setCursorPosition(visualPosition)
@@ -1054,8 +1076,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1054
1076
  })
1055
1077
  syncParentFramePreview(position)
1056
1078
  markMovedNodeDirty()
1057
- // Carry connected ductwork along (preview only — committed on drop).
1058
- previewConnectivity(position, rotationRef.current)
1059
1079
 
1060
1080
  const soundStep = {
1061
1081
  coords: [x, z],
@@ -1116,7 +1136,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1116
1136
  // tool — unless Alt (free place) is held to force placement. Other kinds
1117
1137
  // carry no validity box (`validRef` stays true), so they're never blocked.
1118
1138
  if (!validRef.current && !altRef.current) return
1119
- const position: [number, number, number] = [...lastCursorRef.current]
1139
+ const position = resolvePosition([...lastCursorRef.current])
1120
1140
 
1121
1141
  const rotation = toCommitRotation(rotationRef.current)
1122
1142
  let committedId = node.id as AnyNodeId
@@ -1171,8 +1191,9 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1171
1191
  ? resolveConnectivityUpdates(
1172
1192
  connectivityRef.current,
1173
1193
  buildPreviewNode(position, rotationRef.current),
1174
- ).filter((u) => useScene.getState().nodes[u.id])
1194
+ )?.filter((u) => useScene.getState().nodes[u.id])
1175
1195
  : []
1196
+ if (!connectivityUpdates) return
1176
1197
  const surfaceId = surfaceAttachmentId(effectiveNode)
1177
1198
  itemSurfaceMove?.restore()
1178
1199
  useScene.temporal.getState().resume()
@@ -1298,7 +1319,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1298
1319
  attachmentRotationRef.current,
1299
1320
  )
1300
1321
  if (nextFreeRotation === null) return
1301
- sfxEmitter.emit('sfx:item-rotate')
1302
1322
  const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
1303
1323
  let position = hostedPose?.position ?? lastCursorRef.current
1304
1324
  if (
@@ -1319,10 +1339,16 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1319
1339
  )
1320
1340
  position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1321
1341
  }
1322
- position = keepStartingElevation(position, hostedPose?.rotationY ?? nextFreeRotation)
1342
+ const rotationY = hostedPose?.rotationY ?? nextFreeRotation
1343
+ position = keepStartingElevation(position, rotationY)
1344
+ // Rotating the fitting swings its collars — connected ducts follow. A
1345
+ // swing they cannot follow holds the last frame; a surface-hosted node
1346
+ // has already turned with its host, so its drop refuses instead.
1347
+ if (!previewConnectivity(position, rotationY) && !hostedPose) return
1348
+ sfxEmitter.emit('sfx:item-rotate')
1323
1349
  lastCursorRef.current = position
1324
- freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1325
- rotationRef.current = freeRotationRef.current
1350
+ freeRotationRef.current = rotationY
1351
+ rotationRef.current = rotationY
1326
1352
  setCursorRotationY(previewRotationY(rotationRef.current))
1327
1353
  const visualPosition = getVisualPosition(position)
1328
1354
  setCursorPosition(visualPosition)
@@ -1335,8 +1361,6 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1335
1361
  })
1336
1362
  syncParentFramePreview(position)
1337
1363
  markMovedNodeDirty()
1338
- // Rotating the fitting swings its collars — connected ducts follow.
1339
- previewConnectivity(position, rotationRef.current)
1340
1364
  // Rotation changes the footprint's collision span — re-check validity.
1341
1365
  if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
1342
1366
  onItemMove(lastSurfaceEvent)
@@ -139,4 +139,49 @@ describe('selectable candidates', () => {
139
139
  })
140
140
  expect(collectSelectableCandidateIds().sort()).toEqual(['item_pick', 'wall_pick'])
141
141
  })
142
+
143
+ test("a box leaves a room's automatic floor plate and ceiling to the room, keeping what hangs from it", () => {
144
+ const restore = nodeRegistry._snapshot()
145
+ try {
146
+ for (const kind of ['slab', 'ceiling', 'box-select-test-light'] as const)
147
+ if (!nodeRegistry.has(kind))
148
+ registerNode({
149
+ kind,
150
+ schemaVersion: 1,
151
+ schema: z.object({ type: z.literal(kind) }) as never,
152
+ category: 'structure',
153
+ defaults: () => ({}),
154
+ capabilities: { selectable: {} },
155
+ } as AnyNodeDefinition)
156
+ useScene.setState({
157
+ nodes: {
158
+ level_test: {
159
+ id: 'level_test',
160
+ type: 'level',
161
+ parentId: 'building_test',
162
+ children: ['slab_shared', 'slab_drawn', 'ceiling_auto'],
163
+ },
164
+ slab_shared: {
165
+ id: 'slab_shared',
166
+ type: 'slab',
167
+ parentId: 'level_test',
168
+ boundary: 'auto',
169
+ plateRole: 'base',
170
+ },
171
+ slab_drawn: { id: 'slab_drawn', type: 'slab', parentId: 'level_test' },
172
+ ceiling_auto: {
173
+ id: 'ceiling_auto',
174
+ type: 'ceiling',
175
+ parentId: 'level_test',
176
+ boundary: 'auto',
177
+ children: ['light_hung'],
178
+ },
179
+ light_hung: { id: 'light_hung', type: 'box-select-test-light', parentId: 'ceiling_auto' },
180
+ } as unknown as Record<string, AnyNode>,
181
+ })
182
+ expect(collectSelectableCandidateIds().sort()).toEqual(['light_hung', 'slab_drawn'])
183
+ } finally {
184
+ restore()
185
+ }
186
+ })
142
187
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ isDerivedNode,
4
5
  isRegistrySelectable,
5
6
  type LevelNode,
6
7
  nodeRegistry,
@@ -16,6 +17,14 @@ function isVisibleSelectableNode(node: AnyNode): boolean {
16
17
  return isRegistrySelectable(node.type)
17
18
  }
18
19
 
20
+ /**
21
+ * Derived construction (a room's automatic floor plate or ceiling) belongs to its rooms, so a box
22
+ * never takes it along: a box round a porch would otherwise catch the house's shared floor plate,
23
+ * and deleting the selection took the floor out from under every room on it. A click still selects
24
+ * one on its own.
25
+ */
26
+ const boxSelectable = (node: AnyNode) => !isDerivedNode(node)
27
+
19
28
  export function collectSelectableCandidateIds(): string[] {
20
29
  const { levelId } = useViewer.getState().selection
21
30
  const { nodes } = useScene.getState()
@@ -43,7 +52,8 @@ export function collectSelectableCandidateIds(): string[] {
43
52
  )
44
53
  return
45
54
 
46
- if (isRegistrySelectable(node.type)) {
55
+ // A ceiling's hung lights and fans are still the box's; the ceiling is not.
56
+ if (isRegistrySelectable(node.type) && boxSelectable(node)) {
47
57
  addNode(node)
48
58
  }
49
59
 
@@ -70,7 +80,8 @@ export function collectSelectableCandidateIds(): string[] {
70
80
 
71
81
  const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
72
82
  for (const node of Object.values(nodes)) {
73
- if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue
83
+ if (!node || node.type === 'level' || !isVisibleSelectableNode(node) || !boxSelectable(node))
84
+ continue
74
85
 
75
86
  const def = nodeRegistry.get(node.type)
76
87
  const isBuildingScoped = def?.floorplanScope === 'building'
@@ -1,4 +1,4 @@
1
- import { Html } from '@react-three/drei'
1
+ import { type MessageDescriptor, usePascalI18n } from '@pascal-app/core'
2
2
  import type { ThreeElements } from '@react-three/fiber'
3
3
  import { forwardRef } from 'react'
4
4
  import { CircleGeometry, CylinderGeometry, type Group, SphereGeometry } from 'three'
@@ -8,6 +8,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
8
8
  import { wallCursorIcon } from '../../../lib/tool-cursor-icon'
9
9
  import useEditor from '../../../store/use-editor'
10
10
  import useWallSnapIndicator from '../../../store/use-wall-snap-indicator'
11
+ import { Html } from '../../editor/localized-html'
11
12
 
12
13
  interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
13
14
  color?: string
@@ -51,6 +52,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
51
52
  },
52
53
  ref,
53
54
  ) {
55
+ const i18n = usePascalI18n()
54
56
  const tool = useEditor((s) => s.tool)
55
57
  const mode = useEditor((s) => s.mode)
56
58
  const catalogCategory = useEditor((s) => s.catalogCategory)
@@ -62,7 +64,8 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
62
64
  const wallMode = useEditor((s) => s.continuationByContext.wall)
63
65
 
64
66
  // Find the icon for the current tool — the wall shows its Rooms variant.
65
- let activeToolConfig: { label: string; iconSrc: string } | null | undefined = null
67
+ let activeToolConfig: { label: string | MessageDescriptor; iconSrc: string } | null | undefined =
68
+ null
66
69
  if (mode === 'build' && tool) {
67
70
  if (tool === 'wall') {
68
71
  activeToolConfig = wallCursorIcon(wallMode)
@@ -162,7 +165,11 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
162
165
  {tooltipContent || (
163
166
  // eslint-disable-next-line @next/next/no-img-element
164
167
  <img
165
- alt={activeToolConfig!.label}
168
+ alt={
169
+ typeof activeToolConfig!.label === 'string'
170
+ ? activeToolConfig!.label
171
+ : i18n._(activeToolConfig!.label)
172
+ }
166
173
  src={activeToolConfig!.iconSrc}
167
174
  style={{
168
175
  width: '100%',
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { Html } from '@react-three/drei'
3
+ import { Html } from '../../editor/localized-html'
4
4
 
5
5
  /** Lift of a draft's readout above the top of what it measures, in metres. */
6
6
  export const DRAFT_LABEL_Y_OFFSET = 0.22
@@ -2,17 +2,13 @@
2
2
 
3
3
  import '../../../three-types'
4
4
 
5
- import { Html } from '@react-three/drei'
5
+ import { formatLinearMeasurement, type LinearUnit, type MetricNotation } from '@pascal-app/core'
6
6
  import { useEffect, useMemo } from 'react'
7
7
  import { PlaneGeometry } from 'three'
8
8
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
9
9
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
10
10
  import { EDITOR_LAYER } from '../../../lib/constants'
11
- import {
12
- formatLinearMeasurement,
13
- type LinearUnit,
14
- type MetricNotation,
15
- } from '../../../lib/measurements'
11
+ import { Html } from '../../editor/localized-html'
16
12
  import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry'
17
13
 
18
14
  const VALID_COLOR = 0x22_c5_5e // green-500
@@ -0,0 +1,94 @@
1
+ 'use client'
2
+
3
+ import { usePascalI18n } from '@pascal-app/core'
4
+ import { type PlacementTypingField, usePlacementTyping } from '../../../store/use-placement-typing'
5
+
6
+ export type PlacementCoordinateInputProps = {
7
+ /** What each empty field stands for; defaults to the session's `fieldDefaults`. */
8
+ placeholders?: readonly string[]
9
+ }
10
+
11
+ /**
12
+ * The typed-entry HUD, one box per field of the session. It only displays the
13
+ * session: keys reach the active tool's own handler (`routePlacementTypingKey`),
14
+ * so the same HUD can sit in the 3D view and the plan at once without
15
+ * competing for focus. Clicking a field makes it the active one.
16
+ */
17
+ export function PlacementCoordinateInput({ placeholders }: PlacementCoordinateInputProps) {
18
+ const i18n = usePascalI18n()
19
+ const isActive = usePlacementTyping((state) => state.isActive)
20
+ const labels = usePlacementTyping((state) => state.labels)
21
+ const fields = usePlacementTyping((state) => state.fields)
22
+ const fieldDefaults = usePlacementTyping((state) => state.fieldDefaults)
23
+ const activeField = usePlacementTyping((state) => state.activeField)
24
+
25
+ if (!isActive) return null
26
+
27
+ return (
28
+ <div
29
+ data-placement-coordinate-entry
30
+ onClick={(event) => event.stopPropagation()}
31
+ onPointerDown={(event) => event.stopPropagation()}
32
+ style={{
33
+ alignItems: 'center',
34
+ background: 'rgba(15, 23, 42, 0.94)',
35
+ border: '1px solid rgba(148, 163, 184, 0.55)',
36
+ borderRadius: 6,
37
+ boxShadow: '0 4px 14px rgba(15, 23, 42, 0.28)',
38
+ color: '#f8fafc',
39
+ display: 'flex',
40
+ gap: 6,
41
+ padding: '5px 7px',
42
+ pointerEvents: 'auto',
43
+ userSelect: 'none',
44
+ whiteSpace: 'nowrap',
45
+ }}
46
+ >
47
+ {labels.map((descriptor, index) => {
48
+ const field = index as PlacementTypingField
49
+ const label = i18n._(descriptor)
50
+ const active = field === activeField
51
+ const value = fields[field]
52
+ return (
53
+ <div
54
+ data-placement-coordinate-field={descriptor.id}
55
+ key={descriptor.id}
56
+ onPointerDown={(event) => {
57
+ event.stopPropagation()
58
+ usePlacementTyping.getState().setActiveField(field)
59
+ }}
60
+ style={{
61
+ alignItems: 'center',
62
+ display: 'flex',
63
+ flexDirection: 'column',
64
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
65
+ fontSize: 9,
66
+ gap: 2,
67
+ textTransform: 'uppercase',
68
+ }}
69
+ >
70
+ <span style={{ color: active ? '#a5b4fc' : '#94a3b8' }}>{label}</span>
71
+ <span
72
+ aria-label={label}
73
+ role="status"
74
+ style={{
75
+ background: active ? '#eef2ff' : '#e2e8f0',
76
+ borderRadius: 3,
77
+ color: value ? '#0f172a' : '#64748b',
78
+ fontSize: 12,
79
+ fontWeight: 600,
80
+ minWidth: 76,
81
+ outline: active ? '2px solid #818cf8' : 'none',
82
+ padding: '3px 5px',
83
+ textAlign: 'right',
84
+ textTransform: 'none',
85
+ }}
86
+ >
87
+ {value || placeholders?.[field] || fieldDefaults[field] || '\u00a0'}
88
+ </span>
89
+ </div>
90
+ )
91
+ })}
92
+ </div>
93
+ )
94
+ }
@@ -1,13 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import { useViewer } from '@pascal-app/viewer'
4
- import { Html } from '@react-three/drei'
5
4
  import { useLayoutEffect, useMemo } from 'react'
6
5
  import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
7
6
  import { LineBasicNodeMaterial } from 'three/webgpu'
8
7
  import { EDITOR_LAYER } from '../../../lib/constants'
9
8
  import type { PlacementPreviewDimension } from '../../../store/use-placement-preview'
10
9
  import usePlacementPreview from '../../../store/use-placement-preview'
10
+ import { Html } from '../../editor/localized-html'
11
11
  import { formatMeasurement } from '../../editor/measurement-pill'
12
12
 
13
13
  const DIMENSION_COLOR = 0x63_66_f1
@@ -83,10 +83,6 @@ function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): Pl
83
83
  return best.tangent
84
84
  }
85
85
 
86
- export function formatAngleRadians(angle: number) {
87
- return `${Math.round((angle * 180) / Math.PI)}°`
88
- }
89
-
90
86
  export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
91
87
  const firstLength = Math.hypot(first[0], first[1])
92
88
  const secondLength = Math.hypot(second[0], second[1])