@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -1,3 +1,4 @@
1
+ import { getWallBodyCenterOffset, type WallNode } from '@pascal-app/core'
1
2
  import { type Object3D, Vector3 } from 'three'
2
3
 
3
4
  type WallSide = 'front' | 'back'
@@ -55,3 +56,20 @@ export function wallPointerInParent(
55
56
  export function wallSideAtPointer(point: readonly number[]): WallSide {
56
57
  return point[2]! >= 0 ? 'front' : 'back'
57
58
  }
59
+
60
+ /**
61
+ * The face of a straight wall a plan point is on, as a 3D hover reports it: `front` is the wall
62
+ * frame's +Z (the plan side `(-dz, dx)` of start → end), measured from the wall body's centre so a justified
63
+ * wall splits at its middle. The plan's stand-in for the hovered face's normal.
64
+ */
65
+ export function wallSideOfPlanPoint(
66
+ wall: Pick<WallNode, 'start' | 'end' | 'thickness' | 'justification'>,
67
+ point: readonly [number, number],
68
+ ): WallSide {
69
+ const dx = wall.end[0] - wall.start[0]
70
+ const dz = wall.end[1] - wall.start[1]
71
+ const length = Math.hypot(dx, dz)
72
+ if (length < 1e-9) return 'front'
73
+ const across = ((point[0] - wall.start[0]) * -dz + (point[1] - wall.start[1]) * dx) / length
74
+ return across >= getWallBodyCenterOffset(wall) ? 'front' : 'back'
75
+ }
@@ -0,0 +1,245 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ detectSpacesForLevel,
6
+ getWallAssemblyPreset,
7
+ LevelNode as LevelSchema,
8
+ nodeRegistry,
9
+ registerNode,
10
+ useScene,
11
+ type WallNode,
12
+ WallNode as WallSchema,
13
+ wallAssemblyPatch,
14
+ wallClosesRoom,
15
+ } from '@pascal-app/core'
16
+ import { useViewer } from '@pascal-app/viewer'
17
+ import { createWallOnCurrentLevel } from '../components/tools/wall/wall-drafting'
18
+ import type { WallPlanPoint } from '../components/tools/wall/wall-snap-geometry'
19
+ import useEditor from '../store/use-editor'
20
+ import { selectWallDrawVariant } from './wall-draw-variant'
21
+ import {
22
+ endWallPullRun,
23
+ startWallPull,
24
+ useWallPullRun,
25
+ wallDraftIsRun,
26
+ wallPullSeed,
27
+ wallPullTypePatch,
28
+ } from './wall-pull'
29
+ import { isWallRunFree, snapWallRunDraftPoint, wallRunDirection } from './wall-run-snap'
30
+
31
+ // `updateNodes` batches its dirty-marking through requestAnimationFrame,
32
+ // which bun's test runtime doesn't provide.
33
+ if (typeof globalThis.requestAnimationFrame === 'undefined') {
34
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) =>
35
+ setTimeout(() => callback(0), 0)) as unknown as typeof requestAnimationFrame
36
+ globalThis.cancelAnimationFrame = ((id: number) =>
37
+ clearTimeout(id)) as typeof cancelAnimationFrame
38
+ }
39
+
40
+ const LEVEL_ID = 'level_pull' as AnyNodeId
41
+
42
+ function levelWalls(): WallNode[] {
43
+ return Object.values(useScene.getState().nodes).filter(
44
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === LEVEL_ID,
45
+ )
46
+ }
47
+
48
+ /** A lone stucco wall running east to the origin, 3.1 m tall on a 2.7 m storey, painted. */
49
+ function seedSourceWall(): WallNode {
50
+ const preset = getWallAssemblyPreset('exterior-2x6-stucco')
51
+ if (!preset) throw new Error('Expected the stucco preset')
52
+ const source = WallSchema.parse({
53
+ id: 'wall_source',
54
+ name: 'Source',
55
+ parentId: LEVEL_ID,
56
+ start: [-4, 0],
57
+ end: [0, 0],
58
+ height: 3.1,
59
+ ...wallAssemblyPatch(structuredClone(preset.assembly)),
60
+ slots: { a: 'library:paint-sage', b: 'library:stucco-white' },
61
+ skirting: { enabled: true, sides: 'both', height: 0.12, proud: 0.02, profile: 'flat' },
62
+ })
63
+ const level = LevelSchema.parse({
64
+ id: LEVEL_ID,
65
+ level: 0,
66
+ height: 2.7,
67
+ children: [source.id],
68
+ })
69
+ useScene.setState({
70
+ nodes: { [level.id]: level as AnyNode, [source.id]: source as AnyNode },
71
+ rootNodeIds: [level.id],
72
+ dirtyNodes: new Set(),
73
+ collections: {},
74
+ } as never)
75
+ return source
76
+ }
77
+
78
+ describe('pulling the next wall from a wall end', () => {
79
+ let restoreRegistry: (() => void) | undefined
80
+ let previousEditor: Partial<ReturnType<typeof useEditor.getState>>
81
+
82
+ beforeEach(() => {
83
+ previousEditor = {
84
+ toolDefaults: useEditor.getState().toolDefaults,
85
+ continuationByContext: useEditor.getState().continuationByContext,
86
+ }
87
+ // The wall's snap profile makes the armed wall tool the 'wall' snapping
88
+ // context (grid lengths), as in the app.
89
+ restoreRegistry = nodeRegistry._snapshot()
90
+ registerNode({
91
+ kind: 'wall',
92
+ schemaVersion: 10,
93
+ schema: WallSchema,
94
+ category: 'structure',
95
+ snapProfile: 'structural',
96
+ defaults: () => WallSchema.parse({ start: [0, 0], end: [1, 0] }),
97
+ capabilities: {},
98
+ } as never)
99
+ useEditor.getState().setContinuation('wall', 'room')
100
+ useEditor.getState().setSnappingMode('wall', 'grid')
101
+ useEditor.getState().armToolMode({ mode: 'select' })
102
+ useViewer.setState({
103
+ selection: {
104
+ buildingId: null,
105
+ levelId: LEVEL_ID,
106
+ zoneId: null,
107
+ selectedIds: ['wall_source'],
108
+ },
109
+ } as never)
110
+ seedSourceWall()
111
+ useScene.temporal.getState().clear()
112
+ useScene.temporal.getState().resume()
113
+ })
114
+
115
+ afterEach(() => {
116
+ endWallPullRun()
117
+ restoreRegistry?.()
118
+ restoreRegistry = undefined
119
+ useEditor.setState(previousEditor as never)
120
+ useEditor.getState().armToolMode({ mode: 'select' })
121
+ })
122
+
123
+ test('the pull arms the Walls run at the end, with the pulled wall’s type', () => {
124
+ const source = useScene.getState().nodes['wall_source' as AnyNodeId] as WallNode
125
+ expect(startWallPull({ wallId: source.id, endpoint: 'end', origin: '3d' })).toBe(true)
126
+
127
+ const run = useWallPullRun.getState().run
128
+ expect(run).toMatchObject({
129
+ sourceWallId: source.id,
130
+ levelId: LEVEL_ID,
131
+ runStart: [0, 0],
132
+ start: [0, 0],
133
+ origin: '3d',
134
+ dragging: true,
135
+ })
136
+ expect(run?.reference[0]).toBeCloseTo(1, 9)
137
+ expect(run?.reference[1]).toBeCloseTo(0, 9)
138
+ const editor = useEditor.getState()
139
+ expect(editor.mode).toBe('build')
140
+ expect(editor.tool).toBe('wall')
141
+ expect(editor.getContinuation('wall')).toBe('single')
142
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
143
+
144
+ // The pulled wall's construction, finishes and own height, not the Build
145
+ // panel's types.
146
+ const pulled = createWallOnCurrentLevel([0, 0], [3, 0])
147
+ expect(pulled?.assembly).toEqual(source.assembly)
148
+ expect(pulled?.thickness).toBe(source.thickness)
149
+ expect(pulled?.height).toBe(3.1)
150
+ expect(pulled?.slots).toEqual(source.slots)
151
+ expect(pulled?.skirting).toEqual(source.skirting)
152
+ expect(pulled?.start).toEqual([0, 0])
153
+
154
+ // Ending puts back the Rooms shape and the wall tool defaults, in Select.
155
+ endWallPullRun()
156
+ expect(useWallPullRun.getState().run).toBeNull()
157
+ expect(useEditor.getState().mode).toBe('select')
158
+ expect(useEditor.getState().getContinuation('wall')).toBe('room')
159
+ expect(useEditor.getState().toolDefaults.wall).toBeUndefined()
160
+ })
161
+
162
+ test('the type patch keeps a plain wall plain and a storey-high wall on the storey', () => {
163
+ const plain = WallSchema.parse({ start: [0, 0], end: [2, 0], height: 2.7 })
164
+ expect(wallPullTypePatch(plain, 2.7)).toEqual({ thickness: plain.thickness ?? 0.1 })
165
+ const pulledFromStart = wallPullSeed(plain, 'start')
166
+ expect(pulledFromStart?.start).toEqual([0, 0])
167
+ expect(pulledFromStart?.reference[0]).toBeCloseTo(-1, 9)
168
+ })
169
+
170
+ test('closing the run back to its start forms a room, one undo step per wall', () => {
171
+ startWallPull({ wallId: 'wall_source', endpoint: 'end', origin: '3d' })
172
+ expect(isWallRunFree()).toBe(false)
173
+ const run = useWallPullRun.getState().run!
174
+ // The run as the tool drafts it: each wall from the last one's end,
175
+ // square to it — the pulled wall first.
176
+ let start = run.start
177
+ let reference: WallPlanPoint | null = run.reference
178
+ const place = (cursor: WallPlanPoint) => {
179
+ const snapped = snapWallRunDraftPoint({
180
+ point: cursor,
181
+ start,
182
+ runStart: run.runStart,
183
+ reference,
184
+ walls: levelWalls(),
185
+ free: isWallRunFree(),
186
+ })
187
+ const before = useScene.temporal.getState().pastStates.length
188
+ const wall = createWallOnCurrentLevel(start, snapped.point)!
189
+ expect(useScene.temporal.getState().pastStates.length).toBe(before + 1)
190
+ start = wall.end
191
+ reference = wallRunDirection(wall.start, wall.end)
192
+ return { wall, snapped }
193
+ }
194
+
195
+ // Straight on (6° off) to a grid length; square up; back west until the
196
+ // run's start lines up; then onto the start itself.
197
+ const first = place([5.1, 0.45])
198
+ expect(first.snapped.lock).toBe('straight')
199
+ expect(first.wall.end).toEqual([5, 0])
200
+ const second = place([5.25, 3.9])
201
+ expect(second.snapped.lock).toBe('square')
202
+ expect(second.wall.end).toEqual([5, 4])
203
+ expect(wallClosesRoom(levelWalls(), second.wall)).toBe(false)
204
+ const third = place([0.1, 4.05])
205
+ expect(third.snapped.snap).toBe('alignment')
206
+ expect(third.wall.end).toEqual([0, 4])
207
+ const last = place([0.12, 0.2])
208
+ expect(last.snapped.snap).toBe('endpoint')
209
+ expect(last.wall.end).toEqual([0, 0])
210
+
211
+ const walls = levelWalls()
212
+ expect(wallClosesRoom(walls, last.wall)).toBe(true)
213
+ const rooms = detectSpacesForLevel(LEVEL_ID, walls).spaces.filter((space) => !space.isExterior)
214
+ expect(rooms).toHaveLength(1)
215
+ const xs = rooms[0]!.polygon.map((point) => point[0])
216
+ const zs = rooms[0]!.polygon.map((point) => point[1])
217
+ expect([Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]).toEqual([
218
+ 0, 5, 0, 4,
219
+ ])
220
+ })
221
+
222
+ // The Walls variant had become a run that chained every wall from the last; it draws one wall
223
+ // per two clicks and stops. A Polygon room's sides and a pulled run still chain.
224
+ test('a Walls segment is one wall; Polygon sides and a pulled run chain', () => {
225
+ selectWallDrawVariant('walls')
226
+ expect(wallDraftIsRun()).toBe(false)
227
+ selectWallDrawVariant('polygon')
228
+ expect(wallDraftIsRun()).toBe(true)
229
+ startWallPull({ wallId: 'wall_source', endpoint: 'end', origin: '2d' })
230
+ expect(useEditor.getState().getContinuation('wall')).toBe('single')
231
+ expect(wallDraftIsRun()).toBe(true)
232
+ endWallPullRun()
233
+ selectWallDrawVariant('walls')
234
+ expect(wallDraftIsRun()).toBe(false)
235
+ })
236
+
237
+ test('leaving the wall tool ends the run without leaving Select behind', () => {
238
+ startWallPull({ wallId: 'wall_source', endpoint: 'start', origin: '2d' })
239
+ expect(useWallPullRun.getState().run?.start).toEqual([-4, 0])
240
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'slab' })
241
+ expect(useWallPullRun.getState().run).toBeNull()
242
+ expect(useEditor.getState().tool).toBe('slab')
243
+ expect(useEditor.getState().getContinuation('wall')).toBe('room')
244
+ })
245
+ })
@@ -0,0 +1,209 @@
1
+ /**
2
+ * "Pull to continue": a selected wall's end carries a grip that, dragged,
3
+ * draws the next wall from that end.
4
+ *
5
+ * Pressing the grip arms the wall tool on Walls, as a run (each wall starts
6
+ * where the last ended, `wallDraftIsRun`) already started at the pulled end,
7
+ * with the pulled wall's type: its assembly, thickness, height and finishes.
8
+ * The drag shows the next wall; letting go places it, and the run carries on
9
+ * from its far end, click by click, until Esc, a double-click, closing the loop
10
+ * or teeing into a wall. Then the editor is back in Select, with the Rooms
11
+ * shape and wall tool settings the user had.
12
+ *
13
+ * Its walls snap as every line run does once started (`wall-run-snap.ts`):
14
+ * square to the wall before — the pulled one, for the first — or the house's
15
+ * axes, 15° steps past that, Alt free. The 3D wall tool and the floor plan each
16
+ * seed their run from this store, the way they share the plain run: whichever
17
+ * view owns the input places the walls.
18
+ */
19
+
20
+ import {
21
+ type AnyNodeId,
22
+ getWallCurveFrameAt,
23
+ getWallThickness,
24
+ useScene,
25
+ WALL_CONSTRUCTION_FIELDS,
26
+ type WallNode,
27
+ type WallPlanPoint,
28
+ wallFollowsStorey,
29
+ } from '@pascal-app/core'
30
+ import { useViewer } from '@pascal-app/viewer'
31
+ import { create } from 'zustand'
32
+ import useEditor from '../store/use-editor'
33
+ import type { ContinuationMode } from './continuation'
34
+ import { getWallDrawVariant, selectWallDrawVariant } from './wall-draw-variant'
35
+ import { publishWallRunFeedback } from './wall-run-snap'
36
+
37
+ /** The floor plan's affordance id for a wall end's pull grip (a `move-arrow`, variant `pull`). */
38
+ export const WALL_PULL_AFFORDANCE = 'wall-pull'
39
+
40
+ /** What a pull grip carries: the wall, and which of its ends it pulls from. */
41
+ export type WallPullGripPayload = { wallId: string; endpoint: 'start' | 'end' }
42
+
43
+ /** The grip's tooltip, in both views. */
44
+ export const WALL_PULL_GRIP_TITLE = 'Drag to draw the next wall'
45
+
46
+ export type WallPullRun = {
47
+ /** Bumped per pull, so each view seeds its draft from it once. */
48
+ seq: number
49
+ sourceWallId: AnyNodeId
50
+ levelId: AnyNodeId
51
+ /** The pulled end, where the run started: its walls can close square back to it. */
52
+ runStart: WallPlanPoint
53
+ /** Where the first wall starts: the pulled end. */
54
+ start: WallPlanPoint
55
+ /** Unit direction of the pulled wall, pointing out of the pulled end. */
56
+ reference: WallPlanPoint
57
+ /** The view the grip was pulled in; the floor plan drafts a 3D pull only beside it. */
58
+ origin: '2d' | '3d'
59
+ /** From the press on the grip until it is let go. */
60
+ dragging: boolean
61
+ }
62
+
63
+ type WallPullState = { run: WallPullRun | null }
64
+
65
+ /** The open pull run, if any. */
66
+ export const useWallPullRun = create<WallPullState>(() => ({ run: null }))
67
+
68
+ /**
69
+ * Whether the wall tool's open segment belongs to a run: a Polygon room's sides, or the walls
70
+ * pulled from a wall end. A run's segment holds square to the one before it (`wall-run-snap.ts`)
71
+ * and the next chains from its end. Otherwise it is a Walls segment: one wall, locked to 15° rays
72
+ * from its start, and the tool stops once it is placed.
73
+ */
74
+ export function wallDraftIsRun(): boolean {
75
+ return getWallDrawVariant() !== 'walls' || useWallPullRun.getState().run !== null
76
+ }
77
+
78
+ /**
79
+ * The wall tool defaults that draw walls of `wall`'s type (core
80
+ * `WALL_CONSTRUCTION_FIELDS`; position, support and paint regions stay with
81
+ * each wall): its assembly and the thickness it sums to (a plain wall's own
82
+ * thickness, so the Build panel's types don't take over), its finishes and
83
+ * trims, and its height when it has its own rather than following the storey
84
+ * (`storeyHeight`).
85
+ */
86
+ export function wallPullTypePatch(wall: WallNode, storeyHeight: number | null): Partial<WallNode> {
87
+ const patch: Record<string, unknown> = {}
88
+ for (const field of WALL_CONSTRUCTION_FIELDS) {
89
+ if (wall[field] !== undefined) patch[field] = structuredClone(wall[field])
90
+ }
91
+ patch.thickness = getWallThickness(wall)
92
+ if (!wallFollowsStorey(wall, storeyHeight)) patch.height = wall.height
93
+ return patch as Partial<WallNode>
94
+ }
95
+
96
+ /**
97
+ * The pulled end of `wall` and the direction out of it: along the wall for a
98
+ * straight one, along its tangent for a curved one.
99
+ */
100
+ export function wallPullSeed(
101
+ wall: Pick<WallNode, 'start' | 'end' | 'curveOffset'>,
102
+ endpoint: 'start' | 'end',
103
+ ): { start: WallPlanPoint; reference: WallPlanPoint } | null {
104
+ const start: WallPlanPoint =
105
+ endpoint === 'start' ? [wall.start[0], wall.start[1]] : [wall.end[0], wall.end[1]]
106
+ const tangent = getWallCurveFrameAt(wall as WallNode, endpoint === 'start' ? 0 : 1).tangent
107
+ const sign = endpoint === 'start' ? -1 : 1
108
+ const length = Math.hypot(tangent.x, tangent.y)
109
+ if (!(length > 1e-9)) return null
110
+ return { start, reference: [(tangent.x / length) * sign, (tangent.y / length) * sign] }
111
+ }
112
+
113
+ // What the pull changed for its run, put back when it ends.
114
+ let restore: { shape: ContinuationMode; toolDefaults: Record<string, unknown> | null } | null = null
115
+ let stopWatching: (() => void) | null = null
116
+ let pullSeq = 0
117
+
118
+ /**
119
+ * Start a pull from `endpoint` of wall `wallId`: arm the Walls run at that end
120
+ * with the wall's type. `origin` is the view the grip was pressed in. False
121
+ * when there is no such wall.
122
+ */
123
+ export function startWallPull(args: {
124
+ wallId: string
125
+ endpoint: 'start' | 'end'
126
+ origin: '2d' | '3d'
127
+ }): boolean {
128
+ const nodes = useScene.getState().nodes
129
+ const wall = nodes[args.wallId as AnyNodeId]
130
+ if (wall?.type !== 'wall' || !wall.parentId) return false
131
+ const level = nodes[wall.parentId as AnyNodeId]
132
+ if (level?.type !== 'level') return false
133
+ const seed = wallPullSeed(wall, args.endpoint)
134
+ if (!seed) return false
135
+
136
+ endWallPullRun()
137
+ const editor = useEditor.getState()
138
+ restore = {
139
+ shape: editor.getContinuation('wall'),
140
+ toolDefaults: (editor.toolDefaults.wall as Record<string, unknown> | undefined) ?? null,
141
+ }
142
+ // The grips belong to a selected wall; drawing goes on without one.
143
+ useViewer.getState().setSelection({ selectedIds: [], levelId: level.id })
144
+ selectWallDrawVariant('walls')
145
+ editor.setToolDefaults('wall', wallPullTypePatch(wall, level.height ?? null))
146
+ editor.armToolMode({ mode: 'build', tool: 'wall' })
147
+ pullSeq += 1
148
+ useWallPullRun.setState({
149
+ run: {
150
+ seq: pullSeq,
151
+ sourceWallId: wall.id as AnyNodeId,
152
+ levelId: wall.parentId as AnyNodeId,
153
+ runStart: seed.start,
154
+ start: seed.start,
155
+ reference: seed.reference,
156
+ origin: args.origin,
157
+ dragging: true,
158
+ },
159
+ })
160
+ watchWallPullRun()
161
+ return true
162
+ }
163
+
164
+ function watchWallPullRun() {
165
+ stopWatching?.()
166
+ // Leaving the wall tool (another tool, Select) ends the run.
167
+ const stopEditor = useEditor.subscribe((state) => {
168
+ if (!(state.mode === 'build' && state.tool === 'wall')) endWallPullRun()
169
+ })
170
+ // A release the view did not take as the placing click (off the plan, say)
171
+ // still ends the drag; the run waits for a click.
172
+ const onPointerUp = () => releaseWallPullDrag()
173
+ const target = typeof window === 'undefined' ? null : window
174
+ target?.addEventListener('pointerup', onPointerUp)
175
+ target?.addEventListener('pointercancel', onPointerUp)
176
+ stopWatching = () => {
177
+ stopEditor()
178
+ target?.removeEventListener('pointerup', onPointerUp)
179
+ target?.removeEventListener('pointercancel', onPointerUp)
180
+ }
181
+ }
182
+
183
+ /** The grip was let go: the run goes on by clicks. */
184
+ export function releaseWallPullDrag(): void {
185
+ const run = useWallPullRun.getState().run
186
+ if (run?.dragging) useWallPullRun.setState({ run: { ...run, dragging: false } })
187
+ }
188
+
189
+ /**
190
+ * End the pull run, however its walls ended: back to Select from the wall tool,
191
+ * with the Rooms shape and wall tool defaults the pull replaced. No-op when no
192
+ * run is open.
193
+ */
194
+ export function endWallPullRun(): void {
195
+ const run = useWallPullRun.getState().run
196
+ if (!run) return
197
+ useWallPullRun.setState({ run: null })
198
+ stopWatching?.()
199
+ stopWatching = null
200
+ publishWallRunFeedback(null)
201
+ const editor = useEditor.getState()
202
+ if (editor.mode === 'build' && editor.tool === 'wall') editor.armToolMode({ mode: 'select' })
203
+ const saved = restore
204
+ restore = null
205
+ if (!saved) return
206
+ const after = useEditor.getState()
207
+ if (after.getContinuation('wall') !== saved.shape) after.setContinuation('wall', saved.shape)
208
+ after.setToolDefaults('wall', saved.toolDefaults)
209
+ }
@@ -0,0 +1,132 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2
+ import { clearSceneHistory, nodeRegistry, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import useAlignmentGuides from '../store/use-alignment-guides'
5
+ import useEditor from '../store/use-editor'
6
+ import useInteractionScope from '../store/use-interaction-scope'
7
+ import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
8
+ import { seedRoundTripScene } from '../test-utils/live-gesture-round-trip'
9
+ import { cancelGestures } from './gesture-lifecycle'
10
+ import { runWallPushDrag, wallPushHandles } from './room-handle-drag'
11
+ import { nextInStack } from './selection-cycle'
12
+
13
+ // A side arrow push lines the wall up with a parallel wall before the grid, as
14
+ // the wall moves do; Alt pushes it free.
15
+
16
+ const LEVEL = 'level_push_line_up'
17
+ const stubbed: string[] = []
18
+ let stop = () => {}
19
+ let restoreFrames = () => {}
20
+ let registry: { mockRestore: () => void } | null = null
21
+
22
+ const nodes = () => useScene.getState().nodes
23
+ const eastWall = () =>
24
+ Object.values(nodes()).find(
25
+ (n): n is WallNode => n.type === 'wall' && n.start[0] === 6 && n.end[0] === 6,
26
+ )!
27
+
28
+ function pointer(type: 'pointermove' | 'pointerup', clientX: number, altKey = false) {
29
+ window.dispatchEvent(Object.assign(new Event(type), { clientX, clientY: 0, altKey }))
30
+ }
31
+
32
+ beforeEach(() => {
33
+ restoreFrames = installImmediateAnimationFrames()
34
+ if (!globalThis.document) {
35
+ globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
36
+ stubbed.push('document')
37
+ }
38
+ if (!globalThis.window) {
39
+ globalThis.window = new EventTarget() as Window & typeof globalThis
40
+ stubbed.push('window')
41
+ }
42
+ // The nodes package that registers walls is not loaded here.
43
+ const get = nodeRegistry.get.bind(nodeRegistry)
44
+ registry = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
45
+ kind === 'wall' ? { snapProfile: 'structural' } : get(kind)) as typeof nodeRegistry.get)
46
+ cancelGestures('cancel')
47
+ useInteractionScope.getState().end()
48
+ useScene.temporal.getState().resume()
49
+ ;({ stop } = seedRoundTripScene('building_push_line_up', LEVEL))
50
+ // An outside wall standing apart, 0.3 m east of the room's east wall line.
51
+ const apart = WallNode.parse({
52
+ id: 'wall_apart',
53
+ start: [6.3, 6],
54
+ end: [6.3, 9],
55
+ frontSide: 'exterior',
56
+ backSide: 'interior',
57
+ })
58
+ useScene.getState().applyNodeChanges({ create: [{ node: apart, parentId: LEVEL as never }] })
59
+ useViewer.getState().setSelection({
60
+ buildingId: 'building_push_line_up',
61
+ levelId: LEVEL,
62
+ selectedIds: [],
63
+ })
64
+ useEditor.setState({ phase: 'building', mode: 'select', gridSnapStep: 0.5 })
65
+ useEditor.getState().setSnappingMode('polygon', 'grid')
66
+ clearSceneHistory()
67
+ })
68
+
69
+ afterEach(() => {
70
+ cancelGestures('cancel')
71
+ stop()
72
+ registry?.mockRestore()
73
+ registry = null
74
+ restoreFrames()
75
+ })
76
+
77
+ afterAll(async () => {
78
+ // Let the click-swallow timers fire before the stubs go.
79
+ await new Promise((resolve) => setTimeout(resolve, 350))
80
+ for (const key of stubbed.splice(0)) delete (globalThis as Record<string, unknown>)[key]
81
+ })
82
+
83
+ function startPush() {
84
+ const handle = wallPushHandles(eastWall(), nodes()).find((h) => h.outward[0] > 0.5)!
85
+ runWallPushDrag({ handle, levelId: LEVEL, from: 0, along: (clientX) => clientX })
86
+ return eastWall().id
87
+ }
88
+
89
+ describe('wall push line-up', () => {
90
+ test('a push near a parallel outside wall lands on its line, guide shown while it holds', () => {
91
+ expect(eastWall().frontSide === 'exterior' || eastWall().backSide === 'exterior').toBe(true)
92
+ const id = startPush()
93
+ pointer('pointermove', 0.24)
94
+ expect(useAlignmentGuides.getState().draft?.lines).toHaveLength(1)
95
+ pointer('pointerup', 0.24)
96
+ expect(useAlignmentGuides.getState().draft).toBeNull()
97
+ const wall = nodes()[id as never] as WallNode
98
+ expect(wall.start[0]).toBeCloseTo(6.3, 9)
99
+ expect(wall.end[0]).toBeCloseTo(6.3, 9)
100
+ })
101
+
102
+ test('away from any line the push still steps on the grid', () => {
103
+ const id = startPush()
104
+ pointer('pointermove', 0.9)
105
+ pointer('pointerup', 0.9)
106
+ expect((nodes()[id as never] as WallNode).start[0]).toBeCloseTo(7, 9)
107
+ })
108
+
109
+ test('Alt pushes it free', () => {
110
+ const id = startPush()
111
+ pointer('pointermove', 0.24, true)
112
+ expect(useAlignmentGuides.getState().draft).toBeNull()
113
+ pointer('pointerup', 0.24, true)
114
+ expect((nodes()[id as never] as WallNode).start[0]).toBeCloseTo(6.24, 9)
115
+ })
116
+ })
117
+
118
+ describe('Tab through stacked objects', () => {
119
+ test('steps to the next one under the pointer and wraps round', () => {
120
+ const stack = ['wall_top', 'wall_inside', 'zone_room']
121
+ expect(nextInStack(stack, [], false)).toBe('wall_top')
122
+ expect(nextInStack(stack, ['wall_top'], false)).toBe('wall_inside')
123
+ expect(nextInStack(stack, ['zone_room'], false)).toBe('wall_top')
124
+ })
125
+
126
+ test('Shift+Tab steps back; a selection elsewhere starts at the top', () => {
127
+ const stack = ['wall_top', 'wall_inside']
128
+ expect(nextInStack(stack, ['wall_top'], true)).toBe('wall_inside')
129
+ expect(nextInStack(stack, ['wall_other'], true)).toBe('wall_top')
130
+ expect(nextInStack([], ['wall_top'], false)).toBeNull()
131
+ })
132
+ })
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  getCatalogMaterialById,
4
+ getLinearUnitLabel,
5
+ getMaterialSwatch,
6
+ type LinearUnit,
7
+ metersToLinearUnit,
4
8
  parseMaterialRef,
5
9
  roomSideFaces,
6
10
  type SceneMaterial,
@@ -10,7 +14,6 @@ import {
10
14
  type WallFaceRegion,
11
15
  type WallNode,
12
16
  } from '@pascal-app/core'
13
- import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measurements'
14
17
 
15
18
  // Rows for the wall panel's "Paint regions" list: finish swatch + name, the
16
19
  // face in room terms when the wall has a room on exactly one side, and the
@@ -18,7 +21,13 @@ import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measu
18
21
 
19
22
  const FALLBACK_SWATCH_COLOR = '#d4d4d8'
20
23
 
21
- export type WallRegionFinish = { name: string; color: string; imageUrl?: string }
24
+ export type WallRegionFinish = {
25
+ name: string
26
+ color: string
27
+ imageUrl?: string
28
+ /** The image is a white albedo `color` tints: multiply it (a siding's profile). */
29
+ tinted?: boolean
30
+ }
22
31
 
23
32
  export type WallRegionRow = {
24
33
  id: string
@@ -63,11 +72,13 @@ export function describeRegionFinish(
63
72
  }
64
73
  if (parsed?.kind === 'library') {
65
74
  const item = getCatalogMaterialById(parsed.id)
66
- const imageUrl = item?.previewThumbnailUrl ?? item?.preset.maps.albedoMap
75
+ if (!item) return { name: 'Library finish', color: FALLBACK_SWATCH_COLOR }
76
+ const swatch = getMaterialSwatch(item)
67
77
  return {
68
- name: item?.label ?? 'Library finish',
69
- color: item?.previewColor ?? item?.preset.mapProperties.color ?? FALLBACK_SWATCH_COLOR,
70
- ...(imageUrl ? { imageUrl } : {}),
78
+ name: item.label,
79
+ color: swatch.color,
80
+ ...(swatch.imageUrl ? { imageUrl: swatch.imageUrl } : {}),
81
+ ...(swatch.tinted ? { tinted: true } : {}),
71
82
  }
72
83
  }
73
84
  return { name: 'Custom finish', color: FALLBACK_SWATCH_COLOR }