@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
@@ -19,7 +19,6 @@ import {
19
19
  type WallNode,
20
20
  } from '@pascal-app/core'
21
21
  import { useViewer } from '@pascal-app/viewer'
22
- import { Html } from '@react-three/drei'
23
22
  import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
24
23
  import { useEffect, useMemo, useRef, useState } from 'react'
25
24
  import {
@@ -61,6 +60,7 @@ import useInteractionScope, {
61
60
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
62
61
  import { resolveResizeSnapValue } from './handles/resize-snap'
63
62
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
63
+ import { Html } from './localized-html'
64
64
  import { MeasurementPill } from './measurement-pill'
65
65
  import {
66
66
  createArrowHitAreaGeometry,
@@ -70,6 +70,7 @@ import {
70
70
  NO_RAYCAST,
71
71
  useInvisibleHitAreaMaterial,
72
72
  } from './node-arrow-handles'
73
+ import { WallPullGrip } from './wall-pull-grip'
73
74
  import { WallPushArrow } from './wall-push-arrow'
74
75
 
75
76
  const HANDLE_OFFSET = 0.27
@@ -251,6 +252,9 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
251
252
  />
252
253
  <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
253
254
  <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
255
+ {/* Past each end, the grip that pulls the next wall out of it. */}
256
+ <WallPullGrip endpoint="start" wall={effectiveWall} />
257
+ <WallPullGrip endpoint="end" wall={effectiveWall} />
254
258
  </group>
255
259
  <WallBaseElevationHandle
256
260
  baseElevation={baseElevation}
@@ -297,7 +301,7 @@ function StructureThicknessHandle({
297
301
  const structureHeight =
298
302
  node.type === 'fence'
299
303
  ? (node.height ?? 1.8)
300
- : getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
304
+ : getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0.5)
301
305
  const outward = new Vector2(frame.normal.x * side, frame.normal.y * side)
302
306
  const faceOffset = getWallLocalFaceZ({ ...node, thickness }, side > 0 ? 'a' : 'b') * side + 0.006
303
307
  const position: [number, number, number] = [
@@ -371,7 +375,7 @@ function StructureThicknessHandle({
371
375
  const initialStructureHeight =
372
376
  initialNode.type === 'fence'
373
377
  ? (initialNode.height ?? 1.8)
374
- : getWallEffectiveHeightForNodes(initialNode, useScene.getState().nodes)
378
+ : getWallEffectiveHeightForNodes(initialNode, useScene.getState().nodes, 0.5)
375
379
  const initialStructureThickness =
376
380
  initialNode.type === 'fence'
377
381
  ? (initialNode.thickness ?? 0.08)
@@ -574,7 +578,11 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
574
578
  const corner = endpoint === 'start' ? wall.start : wall.end
575
579
  const x = corner[0]
576
580
  const z = corner[1]
577
- const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
581
+ const wallHeight = getWallEffectiveHeightForNodes(
582
+ wall,
583
+ useScene.getState().nodes,
584
+ endpoint === 'start' ? 0 : 1,
585
+ )
578
586
 
579
587
  const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
580
588
  const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
@@ -780,7 +788,7 @@ function WallBaseElevationHandle({
780
788
  levelObject.updateWorldMatrix(true, false)
781
789
  const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes())
782
790
  const supportBase = initialBase - (initialNode.supportOffset ?? 0)
783
- const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes())
791
+ const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes(), 0)
784
792
  const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4(
785
793
  levelObject.matrixWorld,
786
794
  )
@@ -916,7 +924,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
916
924
  const wallAngle = Math.atan2(-dirZ, dirX)
917
925
  // `wall` is the override-merged effective wall (see
918
926
  // WallMoveSideHandlesForWall), so this height is already live during a drag.
919
- const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
927
+ const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0.5)
920
928
  const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
921
929
 
922
930
  const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
@@ -969,7 +977,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
969
977
 
970
978
  // Dragging the top makes the wall custom-height; seed from the resolved
971
979
  // effective height so a plane-bound wall's drag starts at its real top.
972
- const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
980
+ const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0)
973
981
  const wallId = wall.id as AnyNodeId
974
982
  let pendingHeight = initialHeight
975
983
  let releaseSpatialCapture: (() => void) | null = null
@@ -0,0 +1,193 @@
1
+ 'use client'
2
+
3
+ import { nodeRegistry, type WallNode } from '@pascal-app/core'
4
+ import { type ThreeEvent, useThree } from '@react-three/fiber'
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import { Color, DoubleSide, ExtrudeGeometry, OrthographicCamera, Shape } from 'three'
7
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
8
+ import { EDITOR_LAYER } from '../../lib/constants'
9
+ import { sfxEmitter } from '../../lib/sfx-bus'
10
+ import { startWallPull, WALL_PULL_GRIP_TITLE, wallPullSeed } from '../../lib/wall-pull'
11
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
12
+ import {
13
+ createEndpointHitAreaGeometry,
14
+ InvisibleHandleHitArea,
15
+ NO_RAYCAST,
16
+ useInvisibleHitAreaMaterial,
17
+ } from './handles/handle-arrow'
18
+
19
+ const PULL_FILL = '#10b981' // emerald-500 — the 2D grip's colour
20
+ const PULL_HOVER_FILL = '#34d399' // emerald-400
21
+ // Out from the wall end (m, before zoom): clear of the corner picker's hex.
22
+ const PULL_ARROW_OFFSET = 0.2
23
+ const PULL_ARROW_LENGTH = 0.34
24
+ const PULL_ARROW_HALF_WIDTH = 0.11
25
+ const PULL_ARROW_SHAFT_HALF_WIDTH = 0.04
26
+ const PULL_HIT_RADIUS = 0.17
27
+ const PULL_FLOOR_LIFT = 0.02
28
+
29
+ // The grip's tooltip: one plain DOM element beside the pointer, so it never
30
+ // lives in (or unmounts with) the R3F tree.
31
+ let tooltip: HTMLDivElement | null = null
32
+
33
+ function showGripTooltip(clientX: number, clientY: number) {
34
+ if (typeof document === 'undefined') return
35
+ if (!tooltip) {
36
+ tooltip = document.createElement('div')
37
+ tooltip.textContent = WALL_PULL_GRIP_TITLE
38
+ tooltip.setAttribute('role', 'tooltip')
39
+ Object.assign(tooltip.style, {
40
+ position: 'fixed',
41
+ zIndex: '60',
42
+ pointerEvents: 'none',
43
+ whiteSpace: 'nowrap',
44
+ borderRadius: '6px',
45
+ background: 'rgba(24, 24, 27, 0.92)',
46
+ color: '#fff',
47
+ font: '500 11px/1.4 system-ui, -apple-system, sans-serif',
48
+ padding: '4px 8px',
49
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)',
50
+ })
51
+ }
52
+ tooltip.style.left = `${clientX + 14}px`
53
+ tooltip.style.top = `${clientY + 18}px`
54
+ if (!tooltip.isConnected) document.body.appendChild(tooltip)
55
+ }
56
+
57
+ function hideGripTooltip() {
58
+ tooltip?.remove()
59
+ }
60
+
61
+ /** A flat outward arrow (tip along +X), lying in the XZ plane from x = 0. */
62
+ function createPullArrowGeometry() {
63
+ const head = PULL_ARROW_LENGTH * 0.45
64
+ const shape = new Shape()
65
+ shape.moveTo(0, -PULL_ARROW_SHAFT_HALF_WIDTH)
66
+ shape.lineTo(PULL_ARROW_LENGTH - head, -PULL_ARROW_SHAFT_HALF_WIDTH)
67
+ shape.lineTo(PULL_ARROW_LENGTH - head, -PULL_ARROW_HALF_WIDTH)
68
+ shape.lineTo(PULL_ARROW_LENGTH, 0)
69
+ shape.lineTo(PULL_ARROW_LENGTH - head, PULL_ARROW_HALF_WIDTH)
70
+ shape.lineTo(PULL_ARROW_LENGTH - head, PULL_ARROW_SHAFT_HALF_WIDTH)
71
+ shape.lineTo(0, PULL_ARROW_SHAFT_HALF_WIDTH)
72
+ shape.lineTo(0, -PULL_ARROW_SHAFT_HALF_WIDTH)
73
+ const geometry = new ExtrudeGeometry(shape, {
74
+ depth: 0.03,
75
+ bevelEnabled: true,
76
+ bevelThickness: 0.01,
77
+ bevelSize: 0.012,
78
+ bevelSegments: 4,
79
+ })
80
+ geometry.translate(0, 0, -0.015)
81
+ geometry.rotateX(-Math.PI / 2)
82
+ geometry.computeVertexNormals()
83
+ geometry.computeBoundingSphere()
84
+ return geometry
85
+ }
86
+
87
+ /**
88
+ * A selected wall end's "pull to continue" grip in 3D — the twin of the floor
89
+ * plan's: an emerald arrow lying on the floor just past the end, pointing out
90
+ * along the wall (along its tangent when curved), titled "Drag to draw the next
91
+ * wall" while the pointer rests on it. Pressing it starts
92
+ * `startWallPull`: the wall tool's Walls run opens at that end, the drag shows
93
+ * the next wall, and the release (the canvas click) places it. Screen-sized
94
+ * like the corner picker beside it; rendered in the wall's level frame at its
95
+ * base, by `WallMoveSideHandles`.
96
+ */
97
+ export function WallPullGrip({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) {
98
+ const [hover, setHover] = useState(false)
99
+ const { camera } = useThree()
100
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
101
+ const seed = wallPullSeed(wall, endpoint)
102
+ const geometry = useMemo(() => createPullArrowGeometry(), [])
103
+ const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(PULL_HIT_RADIUS), [])
104
+ const hitMaterial = useInvisibleHitAreaMaterial()
105
+ const material = useMemo(
106
+ () =>
107
+ new MeshBasicNodeMaterial({
108
+ color: new Color(PULL_FILL),
109
+ side: DoubleSide,
110
+ transparent: true,
111
+ opacity: 0.95,
112
+ depthTest: false,
113
+ depthWrite: false,
114
+ toneMapped: false,
115
+ }),
116
+ [],
117
+ )
118
+ useEffect(() => {
119
+ material.color.set(hover ? PULL_HOVER_FILL : PULL_FILL)
120
+ }, [hover, material])
121
+ useEffect(() => () => geometry.dispose(), [geometry])
122
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
123
+ useEffect(() => () => material.dispose(), [material])
124
+ // Warm the wall tool's module so the press starts drawing at once.
125
+ useEffect(() => {
126
+ void nodeRegistry
127
+ .get('wall')
128
+ ?.tool?.()
129
+ .catch(() => {})
130
+ }, [])
131
+ useEffect(
132
+ () => () => {
133
+ hideGripTooltip()
134
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
135
+ },
136
+ [],
137
+ )
138
+
139
+ if (!seed) return null
140
+ const [dx, dz] = seed.reference
141
+ const offset = PULL_ARROW_OFFSET * zoom
142
+ const position: [number, number, number] = [
143
+ seed.start[0] + dx * offset,
144
+ PULL_FLOOR_LIFT,
145
+ seed.start[1] + dz * offset,
146
+ ]
147
+ const rotationY = Math.atan2(-dz, dx)
148
+
149
+ const onPointerDown = (event: ThreeEvent<PointerEvent>) => {
150
+ if (event.button !== 0) return
151
+ event.stopPropagation()
152
+ suppressBoxSelectForPointer(event)
153
+ sfxEmitter.emit('sfx:item-pick')
154
+ setHover(false)
155
+ hideGripTooltip()
156
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
157
+ startWallPull({ wallId: wall.id, endpoint, origin: '3d' })
158
+ }
159
+
160
+ return (
161
+ <group position={position} rotation={[0, rotationY, 0]} scale={zoom}>
162
+ <group position={[PULL_ARROW_LENGTH / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}>
163
+ <InvisibleHandleHitArea
164
+ geometry={hitGeometry}
165
+ material={hitMaterial}
166
+ onPointerDown={onPointerDown}
167
+ onPointerEnter={(event) => {
168
+ event.stopPropagation()
169
+ setHover(true)
170
+ showGripTooltip(event.nativeEvent.clientX, event.nativeEvent.clientY)
171
+ document.body.style.cursor = 'grab'
172
+ }}
173
+ onPointerLeave={(event) => {
174
+ event.stopPropagation()
175
+ setHover(false)
176
+ hideGripTooltip()
177
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
178
+ }}
179
+ scale={1}
180
+ />
181
+ </group>
182
+ <mesh
183
+ frustumCulled={false}
184
+ geometry={geometry}
185
+ layers={EDITOR_LAYER}
186
+ material={material}
187
+ raycast={NO_RAYCAST}
188
+ renderOrder={1003}
189
+ scale={hover ? 1.15 : 1}
190
+ />
191
+ </group>
192
+ )
193
+ }
@@ -203,7 +203,7 @@ function WallRegionHandlesForWall({ wall }: { wall: WallNode }) {
203
203
  const frame: WallFrame = {
204
204
  wall: effectiveWall,
205
205
  baseElevation: getWallBaseElevationForNodes(effectiveWall, nodes),
206
- wallHeight: getWallEffectiveHeightForNodes(effectiveWall, nodes),
206
+ wallHeight: getWallEffectiveHeightForNodes(effectiveWall, nodes, 0.5),
207
207
  angle: Math.atan2(
208
208
  effectiveWall.end[1] - effectiveWall.start[1],
209
209
  effectiveWall.end[0] - effectiveWall.start[0],
@@ -426,7 +426,7 @@ function RegionEdgeHandle({
426
426
  const face = region.face
427
427
  const faceSurface = getWallFaceSurface(initialNode, face)
428
428
  const baseAt = faceBaseSampler(initialNode.id, face)
429
- const wallHeight = getWallEffectiveHeightForNodes(initialNode, nodes)
429
+ const wallHeight = getWallEffectiveHeightForNodes(initialNode, nodes, 0.5)
430
430
  const faceHeight = regionFaceHeight(region, faceSurface.length, wallHeight, baseAt)
431
431
  const bounds = resolveRegionBounds(region, faceSurface.length, faceHeight)
432
432
  const midU = (bounds.u0 + bounds.u1) / 2
@@ -16,6 +16,7 @@ import {
16
16
  } from '@pascal-app/core'
17
17
  import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
18
18
  import { useViewer } from '@pascal-app/viewer'
19
+ import { Html } from '@react-three/drei'
19
20
  import { useFrame } from '@react-three/fiber'
20
21
  import { memo, useMemo, useRef } from 'react'
21
22
  import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
@@ -31,10 +32,14 @@ import { EDITOR_LAYER } from '../../lib/constants'
31
32
  *
32
33
  * endpoint (corner) → square midpoint → triangle
33
34
  * intersection → ✕ cross wall body (edge) → circle
35
+ * perpendicular → ⊥ corner projection guide → diamond
36
+ * grid length → small +
34
37
  *
35
38
  * Subscribes to the shared `useWallSnapIndicator` store (published by the wall
36
39
  * draft + endpoint-move tools). The vertical mouse pillar and the corner
37
- * (endpoint) square are green; the other floor glyphs are indigo.
40
+ * (endpoint) square are green; the other floor glyphs are indigo. A point that
41
+ * carries a `label` (a wall pulled from a wall end) also names what it caught
42
+ * in a pill beside the foot of the pillar.
38
43
  *
39
44
  * The point carries only XZ (building-local plan coords); like the alignment
40
45
  * guides it's lifted to the active level's building-local Y each frame so it
@@ -135,6 +140,23 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
135
140
  renderOrder={1002}
136
141
  />
137
142
  <SnapMarker kind={point.kind} x={point.x} z={point.z} />
143
+ {point.label ? (
144
+ <Html
145
+ position={[point.x, 0.05, point.z]}
146
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
147
+ zIndexRange={[24, 0]}
148
+ >
149
+ <div
150
+ className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-sans font-semibold text-[11px] text-white"
151
+ style={{
152
+ backgroundColor: point.kind === 'endpoint' ? '#16a34a' : '#6366f1',
153
+ transform: 'translate(12px, 8px)',
154
+ }}
155
+ >
156
+ {point.label}
157
+ </div>
158
+ </Html>
159
+ ) : null}
138
160
  </group>
139
161
  )
140
162
  })
@@ -143,9 +165,10 @@ type WallTopHighlightSegment = {
143
165
  angle: number
144
166
  center: [number, number]
145
167
  length: number
168
+ tCenter: number
146
169
  }
147
170
 
148
- function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
171
+ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>, t = 0.5): number {
149
172
  const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
150
173
  const support = spatialGridManager.getSlabSupportForWall(
151
174
  levelId,
@@ -159,12 +182,16 @@ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
159
182
  wall.justification,
160
183
  )
161
184
  return (
162
- getWallTop(wall, levelId, nodes as Record<string, AnyNode>, support.elevation) +
185
+ getWallTop(wall, levelId, nodes as Record<string, AnyNode>, support.elevation, t) +
163
186
  WALL_TOP_HIGHLIGHT_LIFT
164
187
  )
165
188
  }
166
189
 
167
- function buildHighlightSegment(start: [number, number], end: [number, number]) {
190
+ function buildHighlightSegment(
191
+ start: [number, number],
192
+ end: [number, number],
193
+ tCenter = 0.5,
194
+ ): WallTopHighlightSegment | null {
168
195
  const dx = end[0] - start[0]
169
196
  const dz = end[1] - start[1]
170
197
  const length = Math.hypot(dx, dz)
@@ -174,15 +201,20 @@ function buildHighlightSegment(start: [number, number], end: [number, number]) {
174
201
  angle: -Math.atan2(dz, dx),
175
202
  center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
176
203
  length,
204
+ tCenter,
177
205
  }
178
206
  }
179
207
 
180
208
  function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
181
209
  if (!isCurvedWall(wall)) {
182
- const segment = buildHighlightSegment(wall.start, wall.end)
210
+ const segment = buildHighlightSegment(wall.start, wall.end, 0.5)
183
211
  return segment ? [segment] : []
184
212
  }
185
213
 
214
+ const dx = wall.end[0] - wall.start[0]
215
+ const dz = wall.end[1] - wall.start[1]
216
+ const chordLenSq = dx * dx + dz * dz
217
+
186
218
  const sampleCount = Math.max(
187
219
  8,
188
220
  Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
@@ -191,7 +223,16 @@ function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[
191
223
  let previous = getWallCurveFrameAt(wall, 0).point
192
224
  for (let index = 1; index <= sampleCount; index += 1) {
193
225
  const current = getWallCurveFrameAt(wall, index / sampleCount).point
194
- const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
226
+ const midX = (previous.x + current.x) / 2
227
+ const midY = (previous.y + current.y) / 2
228
+ const tCenter =
229
+ chordLenSq > 1e-12
230
+ ? Math.max(
231
+ 0,
232
+ Math.min(1, ((midX - wall.start[0]) * dx + (midY - wall.start[1]) * dz) / chordLenSq),
233
+ )
234
+ : 0.5
235
+ const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y], tCenter)
195
236
  if (segment) segments.push(segment)
196
237
  previous = current
197
238
  }
@@ -206,38 +247,40 @@ function WallTopHighlight({
206
247
  wall: WallNode
207
248
  }) {
208
249
  const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
209
- const y = getWallTopY(wall, nodes)
210
250
  const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
211
251
  const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
212
252
 
213
253
  return (
214
254
  <>
215
- {segments.map((segment, index) => (
216
- <group key={`${wall.id}:${index}`}>
217
- <mesh
218
- frustumCulled={false}
219
- geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
220
- layers={EDITOR_LAYER}
221
- material={wallTopHighlightGlowMaterial}
222
- position={[segment.center[0], y - 0.003, segment.center[1]]}
223
- raycast={NO_RAYCAST}
224
- renderOrder={1003}
225
- rotation={[0, segment.angle, 0]}
226
- scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
227
- />
228
- <mesh
229
- frustumCulled={false}
230
- geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
231
- layers={EDITOR_LAYER}
232
- material={wallTopHighlightMaterial}
233
- position={[segment.center[0], y + 0.002, segment.center[1]]}
234
- raycast={NO_RAYCAST}
235
- renderOrder={1004}
236
- rotation={[0, segment.angle, 0]}
237
- scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
238
- />
239
- </group>
240
- ))}
255
+ {segments.map((segment, index) => {
256
+ const y = getWallTopY(wall, nodes, segment.tCenter)
257
+ return (
258
+ <group key={`${wall.id}:${index}`}>
259
+ <mesh
260
+ frustumCulled={false}
261
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
262
+ layers={EDITOR_LAYER}
263
+ material={wallTopHighlightGlowMaterial}
264
+ position={[segment.center[0], y - 0.003, segment.center[1]]}
265
+ raycast={NO_RAYCAST}
266
+ renderOrder={1003}
267
+ rotation={[0, segment.angle, 0]}
268
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
269
+ />
270
+ <mesh
271
+ frustumCulled={false}
272
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
273
+ layers={EDITOR_LAYER}
274
+ material={wallTopHighlightMaterial}
275
+ position={[segment.center[0], y + 0.002, segment.center[1]]}
276
+ raycast={NO_RAYCAST}
277
+ renderOrder={1004}
278
+ rotation={[0, segment.angle, 0]}
279
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
280
+ />
281
+ </group>
282
+ )
283
+ })}
241
284
  </>
242
285
  )
243
286
  }
@@ -295,6 +338,64 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }
295
338
  </>
296
339
  )
297
340
  }
341
+ if (kind === 'perpendicular') {
342
+ // ⊥ — a base bar with an upright from its middle.
343
+ return (
344
+ <>
345
+ <mesh
346
+ geometry={FLAT_BOX_GEOMETRY}
347
+ layers={EDITOR_LAYER}
348
+ material={beaconMaterial}
349
+ position={[x, y, z]}
350
+ renderOrder={1001}
351
+ scale={[MARKER * 2.6, 1, MARKER * 0.45]}
352
+ />
353
+ <mesh
354
+ geometry={FLAT_BOX_GEOMETRY}
355
+ layers={EDITOR_LAYER}
356
+ material={beaconMaterial}
357
+ position={[x, y, z - MARKER * 0.8]}
358
+ renderOrder={1001}
359
+ scale={[MARKER * 0.45, 1, MARKER * 1.6]}
360
+ />
361
+ </>
362
+ )
363
+ }
364
+ if (kind === 'alignment') {
365
+ return (
366
+ <mesh
367
+ geometry={FLAT_BOX_GEOMETRY}
368
+ layers={EDITOR_LAYER}
369
+ material={beaconMaterial}
370
+ position={[x, y, z]}
371
+ renderOrder={1001}
372
+ rotation={[0, Math.PI / 4, 0]}
373
+ scale={[MARKER * 1.4, 1, MARKER * 1.4]}
374
+ />
375
+ )
376
+ }
377
+ if (kind === 'grid') {
378
+ return (
379
+ <>
380
+ <mesh
381
+ geometry={FLAT_BOX_GEOMETRY}
382
+ layers={EDITOR_LAYER}
383
+ material={beaconMaterial}
384
+ position={[x, y, z]}
385
+ renderOrder={1001}
386
+ scale={[MARKER * 1.8, 1, MARKER * 0.35]}
387
+ />
388
+ <mesh
389
+ geometry={FLAT_BOX_GEOMETRY}
390
+ layers={EDITOR_LAYER}
391
+ material={beaconMaterial}
392
+ position={[x, y, z]}
393
+ renderOrder={1001}
394
+ scale={[MARKER * 0.35, 1, MARKER * 1.8]}
395
+ />
396
+ </>
397
+ )
398
+ }
298
399
  // 'wall' (edge / along-wall) → circle
299
400
  return (
300
401
  <mesh
@@ -0,0 +1,76 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { type AnyNode, BuildingNode, LevelNode, WallNode } from '@pascal-app/core'
3
+ import { normalizePersistedEditorLayoutState } from '../../store/use-editor'
4
+ import { adjacentWallLevels, adjacentWallOutlines } from './floorplan-adjacent-walls-layer'
5
+
6
+ function house() {
7
+ const building = BuildingNode.parse({ id: 'building_adj' })
8
+ const levels = ['ground', 'upper', 'roof'].map((name, level) =>
9
+ LevelNode.parse({
10
+ id: `level_adj_${name}`,
11
+ parentId: building.id,
12
+ level,
13
+ height: 3,
14
+ ...(name === 'roof' ? { metadata: { role: 'roof' } } : {}),
15
+ }),
16
+ )
17
+ building.children = levels.map((level) => level.id)
18
+ const walls = [
19
+ WallNode.parse({
20
+ id: 'wall_adj_a',
21
+ parentId: levels[1]!.id,
22
+ start: [0, 0],
23
+ end: [6, 0],
24
+ thickness: 0.2,
25
+ }),
26
+ WallNode.parse({
27
+ id: 'wall_adj_b',
28
+ parentId: levels[1]!.id,
29
+ start: [6, 0],
30
+ end: [6, 4],
31
+ thickness: 0.2,
32
+ }),
33
+ WallNode.parse({ id: 'wall_adj_c', parentId: levels[0]!.id, start: [0, 0], end: [6, 0] }),
34
+ ]
35
+ return Object.fromEntries(
36
+ [building, ...levels, ...walls].map((node) => [node.id, node]),
37
+ ) as Record<string, AnyNode>
38
+ }
39
+
40
+ test('the roof plan traces the top storey’s walls below, both faces, joined at the corner', () => {
41
+ const nodes = house()
42
+ expect(adjacentWallLevels(nodes, 'level_adj_roof', { below: true, above: true })).toEqual({
43
+ below: 'level_adj_upper',
44
+ above: null,
45
+ })
46
+ expect(adjacentWallLevels(nodes, 'level_adj_upper', { below: true, above: false })).toEqual({
47
+ below: 'level_adj_ground',
48
+ above: null,
49
+ })
50
+ const outlines = adjacentWallOutlines(nodes, 'level_adj_upper')
51
+ expect(outlines.map((outline) => outline.id).sort()).toEqual(['wall_adj_a', 'wall_adj_b'])
52
+ const points = outlines
53
+ .find((outline) => outline.id === 'wall_adj_a')!
54
+ .points.split(' ')
55
+ .map((pair) => pair.split(',').map(Number) as [number, number])
56
+ const zs = points.map(([, z]) => z)
57
+ // Both faces of the 0.2 m wall, and mitred into the corner at x = 6 (past the centreline).
58
+ expect(Math.max(...zs) - Math.min(...zs)).toBeCloseTo(0.2)
59
+ expect(Math.max(...points.map(([x]) => x))).toBeCloseTo(6.1)
60
+ })
61
+
62
+ test('walls below start on for the roof plan and off for the floor plans, each kept as set', () => {
63
+ const fresh = normalizePersistedEditorLayoutState(undefined)
64
+ expect(fresh.showRoofWallsBelow).toBe(true)
65
+ expect(fresh.showWallsBelow).toBe(false)
66
+ // Saved before the roof plan had its own setting: the roof plan still starts on.
67
+ expect(normalizePersistedEditorLayoutState({ showWallsBelow: false }).showRoofWallsBelow).toBe(
68
+ true,
69
+ )
70
+ const turnedOff = normalizePersistedEditorLayoutState({
71
+ showRoofWallsBelow: false,
72
+ showWallsBelow: true,
73
+ })
74
+ expect(turnedOff.showRoofWallsBelow).toBe(false)
75
+ expect(turnedOff.showWallsBelow).toBe(true)
76
+ })