@pascal-app/editor 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -0,0 +1,141 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { BuildingNode, CeilingNode, LevelNode, useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { act, createRoot } from '@react-three/fiber'
5
+ import { createElement, Fragment } from 'react'
6
+ import { OrthographicCamera, type WebGLRenderer } from 'three'
7
+ import useEditor, { normalizePersistedEditorLayoutState } from '../../../store/use-editor'
8
+ import useInteractionScope from '../../../store/use-interaction-scope'
9
+ import { CeilingSystem, CeilingTopsSync } from './ceiling-system'
10
+
11
+ test('ceilings start on for the roof level and off for the floor plans, each kept as set', () => {
12
+ const fresh = normalizePersistedEditorLayoutState(undefined)
13
+ expect(fresh.showRoofCeilings).toBe(true)
14
+ expect(fresh.showCeilings).toBe(false)
15
+ const turned = normalizePersistedEditorLayoutState({
16
+ showRoofCeilings: false,
17
+ showCeilings: true,
18
+ })
19
+ expect(turned.showRoofCeilings).toBe(false)
20
+ expect(turned.showCeilings).toBe(true)
21
+ })
22
+
23
+ test('the level’s ceilings setting draws the ceiling tops, stepping aside to place on a ceiling', async () => {
24
+ const previous = {
25
+ scene: useScene.getState(),
26
+ viewer: useViewer.getState(),
27
+ editor: useEditor.getState(),
28
+ scope: useInteractionScope.getState(),
29
+ }
30
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
31
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
32
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
33
+ const makeCanvas = () =>
34
+ Object.assign(new EventTarget(), {
35
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
36
+ }) as unknown as HTMLCanvasElement
37
+ const canvas = makeCanvas()
38
+ const previewCanvas = makeCanvas()
39
+ const root = createRoot(canvas)
40
+ const previewRoot = createRoot(previewCanvas)
41
+ const building = BuildingNode.parse({})
42
+ const storey = LevelNode.parse({ parentId: building.id, level: 0, height: 3 })
43
+ const roof = LevelNode.parse({
44
+ parentId: building.id,
45
+ level: 1,
46
+ height: 3,
47
+ metadata: { role: 'roof' },
48
+ })
49
+ building.children = [storey.id, roof.id]
50
+ const ceiling = CeilingNode.parse({
51
+ parentId: storey.id,
52
+ height: 2.7,
53
+ polygon: [
54
+ [0, 0],
55
+ [4, 0],
56
+ [4, 4],
57
+ [0, 4],
58
+ ],
59
+ })
60
+ const select = (levelId: typeof storey.id) =>
61
+ act(async () =>
62
+ useViewer.setState({
63
+ selection: { buildingId: building.id, levelId, zoneId: null, selectedIds: [] },
64
+ }),
65
+ )
66
+ const tops = () => useViewer.getState().showCeilingTops
67
+ try {
68
+ useScene.setState({
69
+ nodes: {
70
+ [building.id]: building,
71
+ [storey.id]: storey,
72
+ [roof.id]: roof,
73
+ [ceiling.id]: ceiling,
74
+ },
75
+ })
76
+ useEditor.setState({
77
+ ...normalizePersistedEditorLayoutState(undefined),
78
+ tool: null,
79
+ selectedItem: null,
80
+ isPreviewMode: false,
81
+ isCaptureMode: false,
82
+ })
83
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
84
+ useViewer.setState({ showCeilingTops: false, hoveredId: null })
85
+ await select(roof.id)
86
+ const configure = (target: typeof root, domElement: HTMLCanvasElement) =>
87
+ target.configure({
88
+ gl: {
89
+ domElement,
90
+ render() {},
91
+ setSize() {},
92
+ setPixelRatio() {},
93
+ } as unknown as WebGLRenderer,
94
+ camera: Object.assign(new OrthographicCamera(), { manual: true }),
95
+ frameloop: 'never',
96
+ dpr: 1,
97
+ size: { width: 100, height: 100, top: 0, left: 0 },
98
+ })
99
+ await configure(root, canvas)
100
+ await act(async () =>
101
+ root.render(
102
+ createElement(Fragment, null, createElement(CeilingTopsSync), createElement(CeilingSystem)),
103
+ ),
104
+ )
105
+ expect(tops()).toBe(true)
106
+
107
+ // A second viewer's ceiling system (the preview's) going away leaves the tops as set.
108
+ await configure(previewRoot, previewCanvas)
109
+ await act(async () => previewRoot.render(createElement(CeilingSystem)))
110
+ await act(async () => previewRoot.unmount())
111
+ expect(tops()).toBe(true)
112
+
113
+ await select(storey.id)
114
+ expect(tops()).toBe(false)
115
+ await act(async () => useEditor.getState().setShowCeilings(true))
116
+ expect(tops()).toBe(true)
117
+ await act(async () => useEditor.setState({ tool: 'ceiling' }))
118
+ expect(tops()).toBe(false)
119
+ await act(async () => useEditor.setState({ tool: null, isPreviewMode: true }))
120
+ expect(tops()).toBe(false)
121
+ await act(async () => useEditor.setState({ isPreviewMode: false }))
122
+ expect(tops()).toBe(true)
123
+
124
+ // The roof level keeps its own setting.
125
+ await select(roof.id)
126
+ await act(async () => useEditor.getState().setShowRoofCeilings(false))
127
+ expect(tops()).toBe(false)
128
+ expect(useEditor.getState().showCeilings).toBe(true)
129
+
130
+ await act(async () => useEditor.getState().setShowRoofCeilings(true))
131
+ expect(tops()).toBe(true)
132
+ await act(async () => root.unmount())
133
+ expect(tops()).toBe(false)
134
+ } finally {
135
+ useScene.setState(previous.scene, true)
136
+ useViewer.setState(previous.viewer, true)
137
+ useEditor.setState(previous.editor, true)
138
+ useInteractionScope.setState(previous.scope, true)
139
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
140
+ }
141
+ })
@@ -1,4 +1,4 @@
1
- import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
1
+ import { type AnyNodeId, sceneRegistry, servesAsRoofLevel, useScene } from '@pascal-app/core'
2
2
  import { isProceduralItem } from '@pascal-app/core/procedural-items'
3
3
  import { useViewer } from '@pascal-app/viewer'
4
4
  import { useEffect } from 'react'
@@ -74,10 +74,59 @@ function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
74
74
  delete userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
75
75
  }
76
76
 
77
- export const CeilingSystem = () => {
77
+ /**
78
+ * The "Ceilings" setting that applies on `levelId`: the roof level's, on until turned off; the
79
+ * floor plans', off until turned on. On, ceilings read as solid from above too.
80
+ */
81
+ export function useCeilingsSetting(levelId: string | null | undefined): {
82
+ onRoofLevel: boolean
83
+ shown: boolean
84
+ setShown: (show: boolean) => void
85
+ } {
86
+ const onRoofLevel = useScene((state) => {
87
+ const level = levelId ? state.nodes[levelId as AnyNodeId] : undefined
88
+ return level?.type === 'level' && servesAsRoofLevel(state.nodes, level)
89
+ })
90
+ const shown = useEditor((state) => (onRoofLevel ? state.showRoofCeilings : state.showCeilings))
91
+ const setRoof = useEditor((state) => state.setShowRoofCeilings)
92
+ const setFloor = useEditor((state) => state.setShowCeilings)
93
+ return { onRoofLevel, shown, setShown: onRoofLevel ? setRoof : setFloor }
94
+ }
95
+
96
+ function useIsCeilingToolActive(): boolean {
78
97
  const tool = useEditor((state) => state.tool)
79
98
  const selectedItem = useEditor((state) => state.selectedItem)
80
99
  const movingNode = useMovingNode()
100
+ return (
101
+ tool === 'ceiling' ||
102
+ selectedItem?.attachTo === 'ceiling' ||
103
+ (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
104
+ (isProceduralItem(movingNode) && movingNode.recipe.mounting?.attachTo === 'ceiling')
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Whether the viewer draws ceiling tops (`showCeilingTops`), from the level's Ceilings setting.
110
+ * Mounted once by the editor, outside the canvases: the editor and preview viewers each mount a
111
+ * `CeilingSystem`, and two writers each turning the tops off on unmount raced.
112
+ */
113
+ export const CeilingTopsSync = () => {
114
+ const activeLevelId = useViewer((state) => state.selection.levelId)
115
+ const ceilingsShown = useCeilingsSetting(activeLevelId).shown
116
+ const isPreviewMode = useEditor((state) => state.isPreviewMode)
117
+ const isCaptureMode = useEditor((state) => state.isCaptureMode)
118
+ const isCeilingToolActive = useIsCeilingToolActive()
119
+ // Placing on a ceiling looks through it from above, so the tops step aside; preview and
120
+ // captures keep the open look.
121
+ const showTops = ceilingsShown && !isCeilingToolActive && !isPreviewMode && !isCaptureMode
122
+ useEffect(() => {
123
+ useViewer.getState().setShowCeilingTops(showTops)
124
+ return () => useViewer.getState().setShowCeilingTops(false)
125
+ }, [showTops])
126
+ return null
127
+ }
128
+
129
+ export const CeilingSystem = () => {
81
130
  const selectedIds = useViewer((state) => state.selection.selectedIds)
82
131
  const activeLevelId = useViewer((state) => state.selection.levelId)
83
132
  const hoveredId = useViewer((state) => state.hoveredId)
@@ -87,6 +136,7 @@ export const CeilingSystem = () => {
87
136
  // reveal is gated here, on the consumer.
88
137
  const inputDragging = useViewer((state) => state.inputDragging)
89
138
  const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle')
139
+ const isCeilingToolActive = useIsCeilingToolActive()
90
140
 
91
141
  useEffect(() => {
92
142
  const nodes = useScene.getState().nodes
@@ -96,12 +146,6 @@ export const CeilingSystem = () => {
96
146
 
97
147
  const levelsToShowCeilings = new Set<string>()
98
148
 
99
- const isCeilingToolActive =
100
- tool === 'ceiling' ||
101
- selectedItem?.attachTo === 'ceiling' ||
102
- (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
103
- (isProceduralItem(movingNode) && movingNode.recipe.mounting?.attachTo === 'ceiling')
104
-
105
149
  if (isCeilingToolActive && activeLevelId) {
106
150
  levelsToShowCeilings.add(activeLevelId)
107
151
  }
@@ -162,15 +206,6 @@ export const CeilingSystem = () => {
162
206
  }
163
207
  }
164
208
  })
165
- }, [
166
- tool,
167
- selectedItem,
168
- movingNode,
169
- selectedIds,
170
- activeLevelId,
171
- hoveredId,
172
- scopeIdle,
173
- inputDragging,
174
- ])
209
+ }, [isCeilingToolActive, selectedIds, activeLevelId, hoveredId, scopeIdle, inputDragging])
175
210
  return null
176
211
  }
@@ -1,6 +1,13 @@
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
+ type GridEvent,
7
+ sceneRegistry,
8
+ usePascalI18n,
9
+ useScene,
10
+ } from '@pascal-app/core'
4
11
  import {
5
12
  CursorSphere,
6
13
  clearPlacementSurface,
@@ -17,11 +24,12 @@ import {
17
24
  useEditor,
18
25
  useInteractionScope,
19
26
  } from '@pascal-app/editor'
20
- import { Html } from '@react-three/drei'
21
27
  import { useThree } from '@react-three/fiber'
22
28
  import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
23
29
  import { createPortal } from 'react-dom'
24
30
  import { type Group, Vector3 } from 'three'
31
+ import { messageWithSlot } from '../../../i18n/message-slot'
32
+ import { Html } from '../../editor/localized-html'
25
33
  import type { RunSurfaceBounds, RunSurfaceTarget } from './distribution-run-contract'
26
34
  import { clearDrawAlignment } from './draw-alignment'
27
35
  import type { RunBodyHit, ScenePort } from './ports'
@@ -1197,6 +1205,7 @@ export function DistributionRunCursor({
1197
1205
  onLengthInputChange?: (value: string) => void
1198
1206
  onDirectionSelect?: (direction: RunPoint) => void
1199
1207
  }) {
1208
+ const i18n = usePascalI18n()
1200
1209
  const lengthFieldRef = useRef<HTMLInputElement>(null)
1201
1210
  useEffect(() => {
1202
1211
  if (start) lengthFieldRef.current?.focus()
@@ -1284,30 +1293,45 @@ export function DistributionRunCursor({
1284
1293
  <DimensionPill parts={parts} primary={primary} unit={unit} />
1285
1294
  {start ? (
1286
1295
  <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>
1296
+ {messageWithSlot(
1297
+ i18n,
1298
+ /* i18n */ {
1299
+ id: 'editor.tools.distribution.distributionRunTool.lengthField',
1300
+ message: 'Length: {field} m',
1301
+ },
1302
+ 'field',
1303
+ <input
1304
+ className="w-20 bg-transparent text-center text-foreground outline-none"
1305
+ data-run-length-input
1306
+ inputMode="decimal"
1307
+ onKeyDown={(event) => {
1308
+ if (
1309
+ event.key === 'Backspace' ||
1310
+ event.key === 'Delete' ||
1311
+ event.key === 'ArrowLeft' ||
1312
+ event.key === 'ArrowRight'
1313
+ ) {
1314
+ event.stopPropagation()
1315
+ }
1316
+ }}
1317
+ onChange={(event) => onLengthInputChange?.(event.target.value)}
1318
+ onPointerDown={(event) => event.stopPropagation()}
1319
+ placeholder={i18n._({
1320
+ id: 'editor.tools.distribution.distributionRunTool.typeALength',
1321
+ message: 'type a length',
1322
+ })}
1323
+ ref={lengthFieldRef}
1324
+ style={{ pointerEvents: 'auto' }}
1325
+ type="text"
1326
+ value={lengthInput ?? ''}
1327
+ />,
1328
+ )}
1329
+ <span className="ml-2">
1330
+ {i18n._({
1331
+ id: 'editor.tools.distribution.distributionRunTool.tabDirection',
1332
+ message: 'Tab: direction',
1333
+ })}
1334
+ </span>
1311
1335
  </label>
1312
1336
  ) : null}
1313
1337
  {validationMessage ? (
@@ -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
@@ -30,7 +30,6 @@ import {
30
30
  type WallEvent,
31
31
  } from '@pascal-app/core'
32
32
  import { useViewer } from '@pascal-app/viewer'
33
- import { Html } from '@react-three/drei'
34
33
  import { useFrame, useThree } from '@react-three/fiber'
35
34
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
36
35
  import {
@@ -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)
@@ -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)