@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -9,6 +9,7 @@ import {
9
9
  CeilingNode as CeilingNodeSchema,
10
10
  type ColumnNode,
11
11
  calculateLevelMiters,
12
+ createSizedStairFlight,
12
13
  DEFAULT_ANGLE_STEP,
13
14
  type DoorNode,
14
15
  DoorNode as DoorNodeSchema,
@@ -27,6 +28,7 @@ import {
27
28
  isCurvedWall,
28
29
  type LevelNode,
29
30
  loadAssetUrl,
31
+ newZone,
30
32
  nodeRegistry,
31
33
  normalizeWallCurveOffset,
32
34
  type Point2D,
@@ -40,7 +42,6 @@ import {
40
42
  SlabNode as SlabNodeSchema,
41
43
  type StairNode,
42
44
  StairNode as StairNodeSchema,
43
- StairSegmentNode as StairSegmentNodeSchema,
44
45
  sampleWallCenterline,
45
46
  sceneRegistry,
46
47
  snapPointAlongAngleRay,
@@ -55,14 +56,14 @@ import {
55
56
  type WindowNode,
56
57
  WindowNode as WindowNodeSchema,
57
58
  wallClosesRoom,
58
- ZoneNode as ZoneNodeSchema,
59
- type ZoneNode as ZoneNodeType,
59
+ wallRectangleCorners,
60
60
  } from '@pascal-app/core'
61
61
  import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
62
62
  import { getSceneTheme, useViewer } from '@pascal-app/viewer'
63
63
  import { Command, Ruler } from 'lucide-react'
64
64
  import {
65
65
  type ComponentProps,
66
+ type CSSProperties,
66
67
  memo,
67
68
  type MouseEvent as ReactMouseEvent,
68
69
  type ReactNode,
@@ -77,11 +78,15 @@ import {
77
78
  import { createPortal } from 'react-dom'
78
79
  import { Vector3 } from 'three'
79
80
  import { useShallow } from 'zustand/react/shallow'
81
+ import {
82
+ hoverRoomFromHit,
83
+ resolvePlanRoomHit,
84
+ roomPickingEnabled,
85
+ } from '../../hooks/use-selected-room'
80
86
  import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
81
87
  import {
82
88
  alignFloorplanDraftPoint,
83
89
  buildFloorplanItemEntry,
84
- buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
85
90
  collectLevelDescendants,
86
91
  FLOORPLAN_VIEW_ROTATION_DEG,
87
92
  floorplanLocalToWorldPoint,
@@ -90,20 +95,58 @@ import {
90
95
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
91
96
  worldToFloorplanLocalPoint,
92
97
  } from '../../lib/floorplan'
98
+ import { buildFloorplanContext } from '../../lib/floorplan/floorplan-readonly'
99
+ // Site-plan view. Owned under `lib/floorplan/site-plan/**`; this panel
100
+ // only chooses between it and the registry layer, and mounts the switch.
101
+ import {
102
+ FloorplanDrawingTypeSwitch,
103
+ useSitePlanAvailable,
104
+ } from '../../lib/floorplan/site-plan/drawing-type-switch'
105
+ import { FloorplanSitePlanLayer } from '../../lib/floorplan/site-plan/site-plan-layer'
93
106
  import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
94
107
  import type { EditorGridEvent } from '../../lib/grid-event-presentation'
95
108
  import { groundHeightAt } from '../../lib/ground-surface'
96
109
  import { guideEmitter } from '../../lib/guide-events'
110
+ import {
111
+ acceptsKeyboardPan,
112
+ advanceKeyboardPanMotion,
113
+ clearKeyboardPanKeys,
114
+ createKeyboardPanMotion,
115
+ isKeyboardPanKey,
116
+ type KeyboardPanState,
117
+ keyboardPanSpeed,
118
+ resetKeyboardPanMotion,
119
+ setKeyboardPanKey,
120
+ syncKeyboardPanMotion,
121
+ } from '../../lib/keyboard-pan'
97
122
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
98
123
  import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
124
+ import { snapRegisteredDraftPoint } from '../../lib/registered-draft-snap'
125
+ import { selectRoomFromHit } from '../../lib/room-selection-commands'
126
+ import { enterBuildingFromPlanHit } from '../../lib/selection-routing'
99
127
  import { sfxEmitter } from '../../lib/sfx-bus'
100
128
  import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
101
129
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
102
- import { cancelPendingZonePaint, focusedUnitNode, paintZoneMembership } from '../../lib/units'
130
+ import {
131
+ cancelPendingZonePaint,
132
+ focusedUnitNode,
133
+ paintZoneMembership,
134
+ zoneAtLevelPoint,
135
+ } from '../../lib/units'
103
136
  import { cn } from '../../lib/utils'
137
+ import { getWallDrawVariant } from '../../lib/wall-draw-variant'
138
+ import {
139
+ addWallPolygonDraftCorner,
140
+ commitWallPolygonDraft,
141
+ discardWallPolygonDraft,
142
+ startWallPolygonDraft,
143
+ wallPolygonDraftWalls,
144
+ } from '../../lib/wall-polygon-draft'
104
145
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
146
+ import { nextZoneName } from '../../lib/zone-name'
105
147
  import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
106
148
  import useAlignmentGuides from '../../store/use-alignment-guides'
149
+ import useDrawingView from '../../store/use-drawing-view'
107
150
  import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
108
151
  import useEditor, {
109
152
  isAngleSnapActive,
@@ -122,11 +165,13 @@ import useInteractionScope, {
122
165
  import usePlacementPreview from '../../store/use-placement-preview'
123
166
  import { expandSessionSelectionForNode } from '../../store/use-session-groups'
124
167
  import { useStairBuildPreview } from '../../store/use-stair-build-preview'
168
+ import { activeViewLayout, isViewVisible, VIEW_2D, visibleScene } from '../../store/view-layout'
125
169
  import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
126
170
  import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
127
171
  import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
128
172
  import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
129
173
  import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
174
+ import { FloorplanOpenWallEndsLayer } from '../editor-2d/floorplan-open-wall-ends-layer'
130
175
  import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer'
131
176
  import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
132
177
  import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
@@ -137,6 +182,7 @@ import {
137
182
  import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer'
138
183
  import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
139
184
  import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
185
+ import { FloorplanDraftMeasurement } from '../editor-2d/renderers/floorplan-draft-measurement'
140
186
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
141
187
  import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
142
188
  import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
@@ -144,10 +190,8 @@ import {
144
190
  FloorplanRegistryLayer,
145
191
  RotationAngleOverlay,
146
192
  } from '../editor-2d/renderers/floorplan-registry-layer'
147
- import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
148
193
  import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
149
- import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
150
- import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
194
+ import { buildSvgPolylinePath, formatPolygonPath } from '../editor-2d/svg-paths'
151
195
  import { snapToHalf } from '../tools/item/placement-math'
152
196
  import {
153
197
  isBoxSelectPointerSuppressed,
@@ -177,15 +221,6 @@ import {
177
221
  getAngleToSegmentReference,
178
222
  getSegmentAngleReferenceAtPoint,
179
223
  } from '../tools/shared/segment-angle'
180
- import {
181
- DEFAULT_STAIR_ATTACHMENT_SIDE,
182
- DEFAULT_STAIR_FILL_TO_FLOOR,
183
- DEFAULT_STAIR_HEIGHT,
184
- DEFAULT_STAIR_LENGTH,
185
- DEFAULT_STAIR_STEP_COUNT,
186
- DEFAULT_STAIR_THICKNESS,
187
- DEFAULT_STAIR_WIDTH,
188
- } from '../tools/stair/stair-defaults'
189
224
  import {
190
225
  chainEndJoinsExistingWall,
191
226
  createWallOnCurrentLevel,
@@ -197,8 +232,6 @@ import {
197
232
  WALL_JOIN_SNAP_RADIUS,
198
233
  type WallPlanPoint,
199
234
  } from '../tools/wall/wall-drafting'
200
-
201
- import { PALETTE_COLORS } from '../ui/primitives/color-dot'
202
235
  import { FloorplanCompassButton } from '../viewer/floorplan-compass-button'
203
236
  import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
204
237
  import {
@@ -221,6 +254,7 @@ import {
221
254
  import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
222
255
  import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
223
256
  import { useFloorplanSceneData } from './use-floorplan-scene-data'
257
+ import { sceneSpan } from './views/scene-region'
224
258
 
225
259
  const FALLBACK_VIEW_SIZE = 12
226
260
  const FLOORPLAN_PADDING = 2
@@ -271,7 +305,6 @@ const FLOORPLAN_HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
271
305
  const FLOORPLAN_WALL_HIT_STROKE_WIDTH = 18
272
306
  const FLOORPLAN_WALL_STROKE_WIDTH = '1'
273
307
  const FLOORPLAN_OPENING_HIT_STROKE_WIDTH = 16
274
- const noopFloorplanStairHandler = () => {}
275
308
  const FLOORPLAN_OPENING_STROKE_WIDTH = 0.05
276
309
  const FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH = 18
277
310
  const FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH = 16
@@ -2113,68 +2146,6 @@ function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): P
2113
2146
  return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance))
2114
2147
  }
2115
2148
 
2116
- function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
2117
- const stairType = stair.stairType ?? 'straight'
2118
- const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
2119
-
2120
- if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
2121
- return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
2122
- }
2123
-
2124
- return baseSweepAngle
2125
- }
2126
-
2127
- function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
2128
- if (
2129
- (stair.stairType ?? 'straight') !== 'spiral' ||
2130
- (stair.topLandingMode ?? 'none') !== 'integrated'
2131
- ) {
2132
- return 0
2133
- }
2134
-
2135
- const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
2136
- const width = Math.max(stair.width ?? 1, 0.4)
2137
- const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
2138
-
2139
- return (
2140
- Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
2141
- Math.sign(sweepAngle || 1)
2142
- )
2143
- }
2144
-
2145
- function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
2146
- const stairType = stair.stairType ?? 'straight'
2147
- const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
2148
- const startAngle = -stair.rotation - sweepAngle / 2
2149
- const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
2150
- const center = {
2151
- x: stair.position[0],
2152
- y: stair.position[2],
2153
- }
2154
- const innerRadius = Math.max(
2155
- stairType === 'spiral' ? 0.05 : 0.2,
2156
- stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
2157
- )
2158
- const outerRadius = innerRadius + stair.width
2159
- const outerArcLength = Math.abs(sweepAngle) * outerRadius
2160
- const segmentCount = Math.max(
2161
- 24,
2162
- Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
2163
- Math.ceil(outerArcLength / 0.14),
2164
- )
2165
- const outerPoints: Point2D[] = []
2166
- const innerPoints: Point2D[] = []
2167
-
2168
- for (let index = 0; index <= segmentCount; index += 1) {
2169
- const t = index / segmentCount
2170
- const angle = startAngle + (endAngle - startAngle) * t
2171
- outerPoints.push(getArcPlanPoint(center, outerRadius, angle))
2172
- innerPoints.push(getArcPlanPoint(center, innerRadius, angle))
2173
- }
2174
-
2175
- return [...outerPoints, ...innerPoints.reverse()]
2176
- }
2177
-
2178
2149
  function isPointInsidePolygonWithHoles(
2179
2150
  point: Point2D,
2180
2151
  polygon: Point2D[],
@@ -2320,168 +2291,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
2320
2291
  }
2321
2292
  }
2322
2293
 
2323
- type DraftWallMeasurement = {
2324
- lengthLabel: string
2325
- midpoint: WallPlanPoint
2326
- direction: WallPlanPoint
2327
- angleLabels: {
2328
- id: string
2329
- label: string
2330
- center: WallPlanPoint
2331
- radius: number
2332
- startAngle: number
2333
- endAngle: number
2334
- midAngle: number
2335
- }[]
2336
- }
2337
-
2338
- function FloorplanDraftWallMeasurement({
2339
- measurement,
2340
- measurementStroke,
2341
- labelBackground,
2342
- labelText,
2343
- sceneRotationDeg,
2344
- unitsPerPixel,
2345
- }: {
2346
- measurement: DraftWallMeasurement
2347
- measurementStroke: string
2348
- labelBackground: string
2349
- labelText: string
2350
- sceneRotationDeg: number
2351
- unitsPerPixel: number
2352
- }) {
2353
- const stroke = measurementStroke
2354
- const labelBg = labelBackground
2355
-
2356
- const upx = unitsPerPixel
2357
- const fontSize = Math.max(upx * 10, 0.08)
2358
- const padX = upx * 6
2359
- const padY = upx * 3
2360
-
2361
- // Length plate: rotates to follow the wall direction, but flips 180°
2362
- // when its on-screen orientation would read upside-down (same trick as
2363
- // `floorplan-registry-layer.tsx` for dimension labels).
2364
- const wallAngleDeg =
2365
- (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
2366
- let labelAngleDeg = wallAngleDeg
2367
- let screenDeg = wallAngleDeg + sceneRotationDeg
2368
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
2369
- if (screenDeg > 90) labelAngleDeg -= 180
2370
- else if (screenDeg <= -90) labelAngleDeg += 180
2371
-
2372
- // Push the plate perpendicular to the wall so the dashed footprint
2373
- // stays visible underneath.
2374
- const perpX = -measurement.direction[1]
2375
- const perpY = measurement.direction[0]
2376
- const offset = upx * 18
2377
- const cx = measurement.midpoint[0] + perpX * offset
2378
- const cy = measurement.midpoint[1] + perpY * offset
2379
-
2380
- const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
2381
- const lengthPlateW = lengthTextWidth + padX * 2
2382
- const lengthPlateH = fontSize + padY * 2
2383
-
2384
- const arcSampleCount = 32
2385
-
2386
- return (
2387
- <g pointerEvents="none">
2388
- <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
2389
- <rect
2390
- fill={labelBg}
2391
- height={lengthPlateH}
2392
- opacity={0.92}
2393
- rx={upx * 3}
2394
- ry={upx * 3}
2395
- stroke={stroke}
2396
- strokeWidth={upx * 0.5}
2397
- vectorEffect="non-scaling-stroke"
2398
- width={lengthPlateW}
2399
- x={-lengthPlateW / 2}
2400
- y={-lengthPlateH / 2}
2401
- />
2402
- <text
2403
- dominantBaseline="middle"
2404
- fill={labelText}
2405
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2406
- fontSize={fontSize}
2407
- fontWeight={600}
2408
- textAnchor="middle"
2409
- x={0}
2410
- y={0}
2411
- >
2412
- {measurement.lengthLabel}
2413
- </text>
2414
- </g>
2415
-
2416
- {measurement.angleLabels.map((arc) => {
2417
- // Sample the arc as a polyline — avoids the SVG arc command's
2418
- // sweep-flag direction quirks across negative/positive sweeps.
2419
- const points: string[] = []
2420
- for (let i = 0; i <= arcSampleCount; i += 1) {
2421
- const t = i / arcSampleCount
2422
- const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
2423
- const px = arc.center[0] + Math.cos(a) * arc.radius
2424
- const py = arc.center[1] + Math.sin(a) * arc.radius
2425
- points.push(`${px},${py}`)
2426
- }
2427
-
2428
- const aFontSize = Math.max(upx * 9, 0.075)
2429
- const aPadX = upx * 5
2430
- const aPadY = upx * 2.5
2431
- const aTextWidth = arc.label.length * upx * 6.2
2432
- const aPlateW = aTextWidth + aPadX * 2
2433
- const aPlateH = aFontSize + aPadY * 2
2434
-
2435
- const labelDist = arc.radius + upx * 16
2436
- const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
2437
- const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
2438
-
2439
- return (
2440
- <g key={`draft-angle-${arc.id}`}>
2441
- <polyline
2442
- fill="none"
2443
- points={points.join(' ')}
2444
- stroke={stroke}
2445
- strokeLinecap="round"
2446
- strokeLinejoin="round"
2447
- strokeOpacity={0.95}
2448
- strokeWidth={upx * 1.2}
2449
- vectorEffect="non-scaling-stroke"
2450
- />
2451
- <g transform={`translate(${lx} ${ly})`}>
2452
- <rect
2453
- fill={labelBg}
2454
- height={aPlateH}
2455
- opacity={0.92}
2456
- rx={upx * 3}
2457
- ry={upx * 3}
2458
- stroke={stroke}
2459
- strokeWidth={upx * 0.5}
2460
- vectorEffect="non-scaling-stroke"
2461
- width={aPlateW}
2462
- x={-aPlateW / 2}
2463
- y={-aPlateH / 2}
2464
- />
2465
- <text
2466
- dominantBaseline="middle"
2467
- fill={labelText}
2468
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2469
- fontSize={aFontSize}
2470
- fontWeight={600}
2471
- textAnchor="middle"
2472
- x={0}
2473
- y={0}
2474
- >
2475
- {arc.label}
2476
- </text>
2477
- </g>
2478
- </g>
2479
- )
2480
- })}
2481
- </g>
2482
- )
2483
- }
2484
-
2485
2294
  function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
2486
2295
  return a[0] === b[0] && a[1] === b[1]
2487
2296
  }
@@ -4377,107 +4186,53 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4377
4186
  )
4378
4187
  })
4379
4188
 
4380
- // Static segment for the in-flight stair build preview. No per-render
4381
- // dependency (the geometry only moves / rotates), so it lives at module scope
4382
- // instead of a `useMemo`.
4383
- const FLOORPLAN_PREVIEW_STAIR_SEGMENT = StairSegmentNodeSchema.parse({
4384
- id: 'sseg_floorplan_preview',
4385
- segmentType: 'stair',
4386
- width: DEFAULT_STAIR_WIDTH,
4387
- length: DEFAULT_STAIR_LENGTH,
4388
- height: DEFAULT_STAIR_HEIGHT,
4389
- stepCount: DEFAULT_STAIR_STEP_COUNT,
4390
- attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
4391
- fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
4392
- thickness: DEFAULT_STAIR_THICKNESS,
4393
- position: [0, 0, 0],
4394
- metadata: { isTransient: true, isFloorplanPreview: true },
4395
- })
4396
-
4397
- const EMPTY_FLOORPLAN_ID_SET: ReadonlySet<string> = new Set()
4398
-
4399
- type FloorplanStairLayerPalette = ComponentProps<typeof FloorplanStairLayer>['palette']
4400
-
4401
- // Leaf layer for the stair tool's in-flight 2D build preview. Subscribes to the
4402
- // `useStairBuildPreview` store directly so a per-`grid:move` point update (or an
4403
- // R/T rotation) re-renders ONLY this tiny layer — never the ~120-220ms
4404
- // `FloorplanPanel`. This mirrors how column / elevator placement stays smooth by
4405
- // routing preview state through a store + leaf. Committed stairs render through
4406
- // `FloorplanRegistryLayer`; this layer is non-interactive (noop handlers, empty
4407
- // hit sets), so it never participates in hover / select.
4408
4189
  function FloorplanStairBuildPreviewLayer({
4409
4190
  palette,
4410
- isDeleteMode,
4411
4191
  }: {
4412
- palette: FloorplanStairLayerPalette
4413
- isDeleteMode: boolean
4192
+ palette: FloorplanRenderContextValue['palette']
4414
4193
  }) {
4415
- const phase = useEditor((s) => s.phase)
4416
4194
  const mode = useEditor((s) => s.mode)
4417
4195
  const tool = useEditor((s) => s.tool)
4418
4196
  const point = useStairBuildPreview((s) => s.point)
4419
4197
  const rotation = useStairBuildPreview((s) => s.rotation)
4420
- const isActive = phase === 'structure' && mode === 'build' && tool === 'stair'
4198
+ const rise = useStairBuildPreview((s) => s.rise)
4199
+ const isActive = mode === 'build' && tool === 'stair'
4421
4200
 
4422
- const previewEntry = useMemo(() => {
4423
- if (!(isActive && point)) {
4201
+ const previewGeometry = useMemo(() => {
4202
+ if (!(isActive && point && rise && rise > 0)) {
4424
4203
  return null
4425
4204
  }
4205
+ const previewSegment = {
4206
+ ...createSizedStairFlight(rise),
4207
+ id: 'sseg_floorplan_preview' as const,
4208
+ parentId: 'stair_floorplan_preview' as const,
4209
+ }
4426
4210
  const previewStair = StairNodeSchema.parse({
4427
4211
  id: 'stair_floorplan_preview',
4428
4212
  name: 'Staircase preview',
4429
4213
  position: [point[0], 0, point[1]],
4430
4214
  rotation,
4431
- children: [FLOORPLAN_PREVIEW_STAIR_SEGMENT.id],
4215
+ children: [previewSegment.id],
4432
4216
  metadata: { isTransient: true, isFloorplanPreview: true },
4433
4217
  })
4434
- const entry = buildSharedFloorplanStairEntry(previewStair, [FLOORPLAN_PREVIEW_STAIR_SEGMENT])
4435
- if (!entry) {
4436
- return null
4437
- }
4438
- const hitPolygons =
4439
- (previewStair.stairType ?? 'straight') === 'straight'
4440
- ? entry.segments.map((segmentEntry) => segmentEntry.polygon)
4441
- : [getFloorplanCurvedStairHitPolygon(previewStair)]
4442
-
4443
- return {
4444
- ...entry,
4445
- hitPolygons,
4446
- segments: entry.segments.map((segmentEntry) => ({
4447
- ...segmentEntry,
4448
- innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
4449
- points: formatPolygonPoints(segmentEntry.polygon),
4450
- treadBars: segmentEntry.treadBars.map((polygon) => ({
4451
- points: formatPolygonPoints(polygon),
4452
- polygon,
4453
- })),
4454
- })),
4455
- }
4456
- }, [isActive, point, rotation])
4457
-
4458
- if (!previewEntry) {
4459
- return null
4460
- }
4218
+ const nodes = { [previewStair.id]: previewStair, [previewSegment.id]: previewSegment }
4219
+ return (
4220
+ nodeRegistry.get(previewStair.type)?.floorplan?.(
4221
+ previewStair,
4222
+ buildFloorplanContext(previewStair, nodes, {
4223
+ selected: false,
4224
+ highlighted: false,
4225
+ hovered: false,
4226
+ moving: true,
4227
+ unit: 'metric',
4228
+ palette,
4229
+ }),
4230
+ ) ?? null
4231
+ )
4232
+ }, [isActive, point, rotation, rise, palette])
4461
4233
 
4462
- return (
4463
- <FloorplanStairLayer
4464
- canFocusStairs={false}
4465
- canSelectStairs={false}
4466
- cursor={EDITOR_CURSOR}
4467
- highlightedIdSet={EMPTY_FLOORPLAN_ID_SET}
4468
- hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
4469
- hoveredStairId={null}
4470
- isDeleteMode={isDeleteMode}
4471
- onStairDoubleClick={noopFloorplanStairHandler}
4472
- onStairHoverChange={noopFloorplanStairHandler}
4473
- onStairHoverEnter={noopFloorplanStairHandler}
4474
- onStairPointerDown={noopFloorplanStairHandler}
4475
- onStairSelect={noopFloorplanStairHandler}
4476
- palette={palette}
4477
- selectedIdSet={EMPTY_FLOORPLAN_ID_SET}
4478
- stairEntries={[previewEntry]}
4479
- />
4480
- )
4234
+ if (!previewGeometry) return null
4235
+ return <FloorplanGeometryRenderer geometry={previewGeometry} pointerEventsOverride="none" />
4481
4236
  }
4482
4237
 
4483
4238
  // Leaf overlay for the cursor-following draft preview: the cursor crosshair plus
@@ -4570,7 +4325,7 @@ function FloorplanDraftCursorLayer({
4570
4325
  )}
4571
4326
 
4572
4327
  {cursorPoint && (
4573
- <g>
4328
+ <g pointerEvents="none">
4574
4329
  <circle
4575
4330
  cx={toSvgX(cursorPoint[0])}
4576
4331
  cy={toSvgY(cursorPoint[1])}
@@ -4680,6 +4435,13 @@ function FloorplanLinearDraftLayer({
4680
4435
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4681
4436
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4682
4437
  const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
4438
+ const wallRectangleDraftStart = useFloorplanDraftPreview((s) => s.wallRectangleDraftStart)
4439
+ const wallPolygonDraftPoints = useFloorplanDraftPreview((s) => s.wallPolygonDraftPoints)
4440
+ // The live cursor is the rectangle's opposite corner; select it only while a
4441
+ // rectangle draft is open so idle moves don't re-render this layer.
4442
+ const wallRectangleDraftEnd = useFloorplanDraftPreview((s) =>
4443
+ s.wallRectangleDraftStart ? s.cursorPoint : null,
4444
+ )
4683
4445
 
4684
4446
  const draftPolygon = useMemo(() => {
4685
4447
  if (
@@ -4697,6 +4459,67 @@ function FloorplanLinearDraftLayer({
4697
4459
  return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
4698
4460
  }, [levelId, wallDraftStart, wallDraftEnd])
4699
4461
 
4462
+ // Rectangle mode drafts four walls at once; they read exactly like a line
4463
+ // draft — same mitered footprints, measurement plates and axis guides.
4464
+ const rectangleDraft = useMemo(() => {
4465
+ if (!(levelId && isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd)) {
4466
+ return null
4467
+ }
4468
+ const corners = wallRectangleCorners(wallRectangleDraftStart, wallRectangleDraftEnd)
4469
+ if (corners.length !== 4) return null
4470
+ const draftWalls = corners.map((start, index) =>
4471
+ getSharedFloorplanWall({
4472
+ ...buildDraftWall(levelId, start, corners[(index + 1) % 4]!),
4473
+ id: `wall_draft_${index}` as WallNode['id'],
4474
+ }),
4475
+ )
4476
+ const miterData = calculateLevelMiters(draftWalls)
4477
+ const polygons = draftWalls.map((wall) =>
4478
+ formatPolygonPoints(getWallPlanFootprint(wall, miterData)),
4479
+ )
4480
+ // Measure the two sides meeting at the cursor corner. Corners wind
4481
+ // counter-clockwise, so each side is measured end → start to push its
4482
+ // plate outside the rectangle.
4483
+ const cursorIndex = corners.findIndex(
4484
+ ([x, z]) => x === wallRectangleDraftEnd[0] && z === wallRectangleDraftEnd[1],
4485
+ )
4486
+ const measurements = [cursorIndex - 1, cursorIndex].map((side) => {
4487
+ const from = corners[(side + 5) % 4]!
4488
+ const to = corners[(side + 4) % 4]!
4489
+ const dx = to[0] - from[0]
4490
+ const dy = to[1] - from[1]
4491
+ const length = Math.hypot(dx, dy)
4492
+ return {
4493
+ lengthLabel: formatMeasurement(length, unit, null, metricNotation),
4494
+ midpoint: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as WallPlanPoint,
4495
+ direction: [dx / length, dy / length] as WallPlanPoint,
4496
+ angleLabels: [],
4497
+ }
4498
+ })
4499
+ return { polygons, measurements }
4500
+ }, [
4501
+ isWallBuildActive,
4502
+ levelId,
4503
+ metricNotation,
4504
+ unit,
4505
+ wallRectangleDraftEnd,
4506
+ wallRectangleDraftStart,
4507
+ ])
4508
+
4509
+ // An open Polygon room's placed sides — draft state, drawn like the
4510
+ // rectangle draft (mitered together) until the polygon is written.
4511
+ const polygonRoomDraft = useMemo(() => {
4512
+ if (!(levelId && isWallBuildActive && wallPolygonDraftPoints.length >= 2)) return null
4513
+ const draftWalls = wallPolygonDraftPoints.slice(1).map((end, index) =>
4514
+ getSharedFloorplanWall({
4515
+ ...buildDraftWall(levelId, wallPolygonDraftPoints[index]!, end),
4516
+ id: `wall_polygon_draft_${index}` as WallNode['id'],
4517
+ }),
4518
+ )
4519
+ const miterData = calculateLevelMiters(draftWalls)
4520
+ return draftWalls.map((wall) => formatPolygonPoints(getWallPlanFootprint(wall, miterData)))
4521
+ }, [isWallBuildActive, levelId, wallPolygonDraftPoints])
4522
+
4700
4523
  const draftPolygonPoints = useMemo(() => {
4701
4524
  if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
4702
4525
  const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
@@ -4864,6 +4687,10 @@ function FloorplanLinearDraftLayer({
4864
4687
  if (isWallBuildActive && wallDraftStart && wallDraftEnd) {
4865
4688
  pushDraft(wallDraftStart, wallDraftEnd)
4866
4689
  }
4690
+ if (isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd) {
4691
+ pushCross(wallRectangleDraftStart)
4692
+ pushCross(wallRectangleDraftEnd)
4693
+ }
4867
4694
  if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) {
4868
4695
  pushDraft(fenceDraftStart, fenceDraftEnd)
4869
4696
  }
@@ -4875,6 +4702,8 @@ function FloorplanLinearDraftLayer({
4875
4702
  fenceDraftStart,
4876
4703
  wallDraftEnd,
4877
4704
  wallDraftStart,
4705
+ wallRectangleDraftEnd,
4706
+ wallRectangleDraftStart,
4878
4707
  ])
4879
4708
 
4880
4709
  return (
@@ -4923,7 +4752,7 @@ function FloorplanLinearDraftLayer({
4923
4752
  )}
4924
4753
 
4925
4754
  {draftWallMeasurement && (
4926
- <FloorplanDraftWallMeasurement
4755
+ <FloorplanDraftMeasurement
4927
4756
  labelBackground={isDark ? '#0f172a' : '#ffffff'}
4928
4757
  labelText={isDark ? '#e2e8f0' : '#171717'}
4929
4758
  measurement={draftWallMeasurement}
@@ -4932,6 +4761,50 @@ function FloorplanLinearDraftLayer({
4932
4761
  unitsPerPixel={unitsPerPixel}
4933
4762
  />
4934
4763
  )}
4764
+
4765
+ {polygonRoomDraft?.map((points, index) => (
4766
+ <FloorplanDraftLayer
4767
+ anchorFill={draftStroke}
4768
+ draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
4769
+ draftFill={draftFill}
4770
+ draftPolygonPoints={points}
4771
+ draftStroke={draftStroke}
4772
+ key={`polygon-room-${index}`}
4773
+ linearDraftSegment={null}
4774
+ polygonDraftClosingSegment={null}
4775
+ polygonDraftPolygonPoints={null}
4776
+ polygonDraftPolylinePoints={null}
4777
+ unitsPerPixel={unitsPerPixel}
4778
+ />
4779
+ ))}
4780
+
4781
+ {rectangleDraft?.polygons.map((points, index) => (
4782
+ <FloorplanDraftLayer
4783
+ anchorFill={draftStroke}
4784
+ draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
4785
+ draftFill={draftFill}
4786
+ draftPolygonPoints={points}
4787
+ draftStroke={draftStroke}
4788
+ key={index}
4789
+ linearDraftSegment={null}
4790
+ polygonDraftClosingSegment={null}
4791
+ polygonDraftPolygonPoints={null}
4792
+ polygonDraftPolylinePoints={null}
4793
+ unitsPerPixel={unitsPerPixel}
4794
+ />
4795
+ ))}
4796
+
4797
+ {rectangleDraft?.measurements.map((measurement, index) => (
4798
+ <FloorplanDraftMeasurement
4799
+ key={index}
4800
+ labelBackground={isDark ? '#0f172a' : '#ffffff'}
4801
+ labelText={isDark ? '#e2e8f0' : '#171717'}
4802
+ measurement={measurement}
4803
+ measurementStroke={measurementStroke}
4804
+ sceneRotationDeg={sceneRotationDeg}
4805
+ unitsPerPixel={unitsPerPixel}
4806
+ />
4807
+ ))}
4935
4808
  </>
4936
4809
  )
4937
4810
  }
@@ -5026,6 +4899,12 @@ export function FloorplanPanel({
5026
4899
  const unit = useViewer((state) => state.unit)
5027
4900
  const metricNotation = useViewer((state) => state.metricNotation)
5028
4901
  const showGrid = useViewer((state) => state.showGrid)
4902
+ // Floor plan vs. site plan. Site plan hides the level geometry and draws
4903
+ // the lot instead — see `lib/floorplan/site-plan/site-plan-layer.tsx`. A
4904
+ // persisted site-plan choice on a scene with no lot reads as the floor plan.
4905
+ const sitePlanAvailable = useSitePlanAvailable()
4906
+ const storedDrawingType = useDrawingView((state) => state.drawingType)
4907
+ const activeDrawingType = sitePlanAvailable ? storedDrawingType : 'floor-plan'
5029
4908
  const showGuides = useViewer((state) => state.showGuides)
5030
4909
  const setShowGuides = useViewer((state) => state.setShowGuides)
5031
4910
  const selectedItem = useEditor((state) => state.selectedItem)
@@ -5035,7 +4914,21 @@ export function FloorplanPanel({
5035
4914
  // editor/index.tsx — subscribing here lets us re-fit the viewport when
5036
4915
  // the user closes and re-opens the 2D editor instead of restoring the
5037
4916
  // stale viewport from before they closed it.
5038
- const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
4917
+ const isFloorplanOpen = useEditor((state) => isViewVisible(state, VIEW_2D))
4918
+ // The compass sits at the scene's bottom-left corner, the drawing switch
4919
+ // beside it. With the plan in the right pane both move to its bottom-right
4920
+ // corner (compass outermost), clear of the dock centred over the scene.
4921
+ const planOnRight = useEditor((state) => {
4922
+ const layout = activeViewLayout(state)
4923
+ return layout.split && layout.panes[1] === VIEW_2D
4924
+ })
4925
+ // `null` while no scene view is on screen: the compass has nothing to orient.
4926
+ const sceneLeft = useEditor((state) => sceneSpan(activeViewLayout(state))?.left ?? null)
4927
+ // Mirrored, the pair stays left of the helper toggle in the stage's corner.
4928
+ const compassStyle: CSSProperties = planOnRight
4929
+ ? { right: 56 }
4930
+ : { left: `calc(${(sceneLeft ?? 0) * 100}% + 12px)` }
4931
+ const shownScene = useEditor(visibleScene)
5039
4932
  // Mirror for callbacks that fire outside React's render (the per-frame
5040
4933
  // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in
5041
4934
  // 3D mode) it must NOT re-render on every camera-zoom frame.
@@ -5053,8 +4946,6 @@ export function FloorplanPanel({
5053
4946
  const activeHandleDrag = useActiveHandleDrag()
5054
4947
  const setPhase = useEditor((state) => state.setPhase)
5055
4948
  const setMovingNode = useEditor((state) => state.setMovingNode)
5056
- const structureLayer = useEditor((state) => state.structureLayer)
5057
- const setStructureLayer = useEditor((state) => state.setStructureLayer)
5058
4949
  const setTool = useEditor((state) => state.setTool)
5059
4950
  const tool = useEditor((state) => state.tool)
5060
4951
  const deleteNode = useScene((state) => state.deleteNode)
@@ -5336,12 +5227,6 @@ export function FloorplanPanel({
5336
5227
  height: number
5337
5228
  } | null>(null)
5338
5229
 
5339
- useEffect(() => {
5340
- if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {
5341
- setFloorplanSelectionTool('click')
5342
- }
5343
- }, [floorplanSelectionTool, setFloorplanSelectionTool, structureLayer])
5344
-
5345
5230
  useEffect(() => {
5346
5231
  setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
5347
5232
  }, [])
@@ -5855,21 +5740,21 @@ export function FloorplanPanel({
5855
5740
  )
5856
5741
 
5857
5742
  const isSiteEditActive = phase === 'site'
5858
- const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall'
5859
- const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab'
5860
- const isCeilingBuildActive = phase === 'structure' && mode === 'build' && tool === 'ceiling'
5861
- const isZoneBuildActive = phase === 'structure' && mode === 'build' && tool === 'zone'
5862
- const isDoorBuildActive = phase === 'structure' && mode === 'build' && tool === 'door'
5863
- const isWindowBuildActive = phase === 'structure' && mode === 'build' && tool === 'window'
5743
+ const isWallBuildActive = mode === 'build' && tool === 'wall'
5744
+ const isSlabBuildActive = mode === 'build' && tool === 'slab'
5745
+ const isCeilingBuildActive = mode === 'build' && tool === 'ceiling'
5746
+ const isZoneBuildActive = mode === 'build' && tool === 'zone'
5747
+ const isDoorBuildActive = mode === 'build' && tool === 'door'
5748
+ const isWindowBuildActive = mode === 'build' && tool === 'window'
5864
5749
  const isPolygonBuildActive = isSlabBuildActive || isZoneBuildActive
5865
5750
  const isPolygonDraftBuildActive = isPolygonBuildActive || isCeilingBuildActive
5866
5751
  const isOpeningBuildActive = isDoorBuildActive || isWindowBuildActive
5867
5752
  const isOpeningMoveActive = movingOpeningType !== null
5868
5753
  const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
5869
- const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence'
5754
+ const isFenceBuildActive = mode === 'build' && tool === 'fence'
5870
5755
  const fenceContinuation = useEditor((state) => state.continuationByContext.fence)
5871
- const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof'
5872
- const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
5756
+ const isRoofBuildActive = mode === 'build' && tool === 'roof'
5757
+ const isStairBuildActive = mode === 'build' && tool === 'stair'
5873
5758
  const isStairMoveActive = movingNode?.type === 'stair'
5874
5759
  const isRoofMoveActive = movingNode?.type === 'roof' || movingNode?.type === 'roof-segment'
5875
5760
  const isSlabMoveActive = movingNode?.type === 'slab'
@@ -5995,12 +5880,11 @@ export function FloorplanPanel({
5995
5880
  mode === 'select' &&
5996
5881
  floorplanSelectionTool === 'marquee' &&
5997
5882
  !movingNode &&
5998
- !isFenceEndpointMoveActive &&
5999
- structureLayer !== 'zones'
5883
+ !isFenceEndpointMoveActive
6000
5884
  const isScreenSelectionToolActive =
6001
5885
  mode === 'select' &&
6002
5886
  floorplanSelectionTool === 'click' &&
6003
- (phase === 'structure' || phase === 'furnish') &&
5887
+ phase === 'building' &&
6004
5888
  !movingNode &&
6005
5889
  !isFenceEndpointMoveActive &&
6006
5890
  !referenceScaleDraft &&
@@ -6010,8 +5894,7 @@ export function FloorplanPanel({
6010
5894
  mode === 'select' &&
6011
5895
  floorplanSelectionTool === 'click' &&
6012
5896
  !movingNode &&
6013
- !isFenceEndpointMoveActive &&
6014
- structureLayer !== 'zones'
5897
+ !isFenceEndpointMoveActive
6015
5898
  const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry
6016
5899
  const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry
6017
5900
  const canInteractWithGuides =
@@ -6023,42 +5906,7 @@ export function FloorplanPanel({
6023
5906
  mode === 'select' &&
6024
5907
  floorplanSelectionTool === 'click' &&
6025
5908
  !movingNode &&
6026
- !isFenceEndpointMoveActive &&
6027
- structureLayer === 'zones'
6028
- const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones
6029
- const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones'
6030
- const isFloorplanFurnishContextActive = phase === 'furnish'
6031
- const isFloorplanItemContextActive =
6032
- isFloorplanFurnishContextActive || isFloorplanStructureContextActive
6033
- const canSelectFloorplanStairs =
6034
- (mode === 'select' &&
6035
- floorplanSelectionTool === 'click' &&
6036
- !movingNode &&
6037
- !isFenceEndpointMoveActive &&
6038
- isFloorplanStructureContextActive) ||
6039
- isDeleteMode
6040
- const canSelectFloorplanElevators = canSelectFloorplanStairs
6041
- const canSelectFloorplanSpawns = canSelectFloorplanStairs
6042
- const canSelectFloorplanItems =
6043
- (mode === 'select' &&
6044
- floorplanSelectionTool === 'click' &&
6045
- !movingNode &&
6046
- !isFenceEndpointMoveActive &&
6047
- isFloorplanItemContextActive) ||
6048
- isDeleteMode
6049
- const canFocusFloorplanStairs =
6050
- mode === 'select' &&
6051
- floorplanSelectionTool === 'click' &&
6052
- !movingNode &&
6053
- !isFenceEndpointMoveActive &&
6054
- isFloorplanStructureContextActive
6055
- const canFocusFloorplanSpawns = canFocusFloorplanStairs
6056
- const canFocusFloorplanItems =
6057
- mode === 'select' &&
6058
- floorplanSelectionTool === 'click' &&
6059
- !movingNode &&
6060
- !isFenceEndpointMoveActive &&
6061
- isFloorplanItemContextActive
5909
+ !isFenceEndpointMoveActive
6062
5910
  const visibleSitePolygon = displaySitePolygon
6063
5911
  const canUseSiteBoundaryVertexHandles =
6064
5912
  visibleSitePolygon !== null && siteBoundaryHandlesEnabled({ mode, phase })
@@ -7711,7 +7559,7 @@ export function FloorplanPanel({
7711
7559
  scheduleFloorplanZoomCommit()
7712
7560
  const userRotationDeg = latestFloorplanUserRotationDegRef.current
7713
7561
  floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth }
7714
- if (useEditor.getState().viewMode === 'split') {
7562
+ if (visibleScene(useEditor.getState()) === 'split') {
7715
7563
  publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth)
7716
7564
  }
7717
7565
  },
@@ -7784,6 +7632,115 @@ export function FloorplanPanel({
7784
7632
  [publishFloorplanNavigationPose],
7785
7633
  )
7786
7634
 
7635
+ // WASD and the orbit buttons in 2D-only view. The 3D canvas is paused there
7636
+ // (`renderPaused`), so the plan drives them itself — same keys and speed as
7637
+ // the camera — and hands the pose to 3D when the move ends.
7638
+ const keyboardPanKeysRef = useRef<KeyboardPanState>({
7639
+ forward: false,
7640
+ backward: false,
7641
+ left: false,
7642
+ right: false,
7643
+ })
7644
+ useEffect(() => {
7645
+ if (shownScene !== '2d') return
7646
+ const keys = keyboardPanKeysRef.current
7647
+ const motion = createKeyboardPanMotion()
7648
+ let frame: number | null = null
7649
+ const sceneRotationDeg = (userRotationDeg: number) =>
7650
+ FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg
7651
+ const step = () => {
7652
+ const viewport = latestViewportRef.current ?? latestFittedViewportRef.current
7653
+ if (!(viewport && advanceKeyboardPanMotion(motion, keys, performance.now()))) {
7654
+ frame = null
7655
+ resetKeyboardPanMotion(motion)
7656
+ commitFloorplanPan()
7657
+ return
7658
+ }
7659
+ const speed = keyboardPanSpeed(viewport.width)
7660
+ const next = {
7661
+ centerX: viewport.centerX + motion.stepX * speed,
7662
+ centerY: viewport.centerY - motion.stepY * speed,
7663
+ width: viewport.width,
7664
+ }
7665
+ const userRotationDeg = latestFloorplanUserRotationDegRef.current
7666
+ floorplanViewportInteractionInProgressRef.current = true
7667
+ applyFloorplanViewportImperatively(next)
7668
+ floorplanPanPoseRef.current = {
7669
+ localCenter: rotateSvgPoint(
7670
+ { x: next.centerX, y: next.centerY },
7671
+ -sceneRotationDeg(userRotationDeg),
7672
+ ),
7673
+ userRotationDeg,
7674
+ viewWidth: next.width,
7675
+ }
7676
+ frame = requestAnimationFrame(step)
7677
+ }
7678
+ const start = () => {
7679
+ if (frame === null) frame = requestAnimationFrame(step)
7680
+ }
7681
+ const onKeyDown = (event: KeyboardEvent) => {
7682
+ if (!(isKeyboardPanKey(event.code) && acceptsKeyboardPan(event))) return
7683
+ syncKeyboardPanMotion(motion, keys, performance.now())
7684
+ if (setKeyboardPanKey(keys, event.code, true)) start()
7685
+ event.preventDefault()
7686
+ event.stopPropagation()
7687
+ }
7688
+ const onKeyUp = (event: KeyboardEvent) => {
7689
+ if (!isKeyboardPanKey(event.code)) return
7690
+ syncKeyboardPanMotion(motion, keys, performance.now())
7691
+ if (setKeyboardPanKey(keys, event.code, false)) {
7692
+ event.preventDefault()
7693
+ event.stopPropagation()
7694
+ }
7695
+ }
7696
+ const onBlur = () => clearKeyboardPanKeys(keys)
7697
+ // Same rule as the 3D buttons: snap to the nearest quarter turn, then turn one more.
7698
+ const orbit = (clockwise: boolean) => {
7699
+ const viewport = latestViewportRef.current ?? latestFittedViewportRef.current
7700
+ if (!viewport) return
7701
+ const userRotationDeg = latestFloorplanUserRotationDegRef.current
7702
+ const quarter = Math.PI / 2
7703
+ const azimuth =
7704
+ Math.round(cameraAzimuthFromFloorplanRotation(userRotationDeg) / quarter) * quarter +
7705
+ (clockwise ? -quarter : quarter)
7706
+ const nextRotationDeg = floorplanRotationFromCameraAzimuth(azimuth, userRotationDeg)
7707
+ const localCenter = rotateSvgPoint(
7708
+ { x: viewport.centerX, y: viewport.centerY },
7709
+ -sceneRotationDeg(userRotationDeg),
7710
+ )
7711
+ smoothFloorplanNavigationView(localCenter, nextRotationDeg, viewport.width)
7712
+ publishFloorplanNavigationPose(localCenter, nextRotationDeg, viewport.width)
7713
+ }
7714
+ const orbitClockwise = () => orbit(true)
7715
+ const orbitCounterClockwise = () => orbit(false)
7716
+ document.addEventListener('keydown', onKeyDown)
7717
+ document.addEventListener('keyup', onKeyUp)
7718
+ window.addEventListener('blur', onBlur)
7719
+ emitter.on('camera-controls:orbit-cw', orbitClockwise)
7720
+ emitter.on('camera-controls:orbit-ccw', orbitCounterClockwise)
7721
+ return () => {
7722
+ document.removeEventListener('keydown', onKeyDown)
7723
+ document.removeEventListener('keyup', onKeyUp)
7724
+ window.removeEventListener('blur', onBlur)
7725
+ emitter.off('camera-controls:orbit-cw', orbitClockwise)
7726
+ emitter.off('camera-controls:orbit-ccw', orbitCounterClockwise)
7727
+ // A key released while 3D owns the keys would otherwise still read as
7728
+ // held when the plan takes them back; the camera clears its own the same way.
7729
+ clearKeyboardPanKeys(keys)
7730
+ if (frame !== null) {
7731
+ cancelAnimationFrame(frame)
7732
+ commitFloorplanPan()
7733
+ }
7734
+ }
7735
+ }, [
7736
+ applyFloorplanViewportImperatively,
7737
+ buildingRotationDeg,
7738
+ commitFloorplanPan,
7739
+ publishFloorplanNavigationPose,
7740
+ smoothFloorplanNavigationView,
7741
+ shownScene,
7742
+ ])
7743
+
7787
7744
  useLayoutEffect(() => {
7788
7745
  flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef)
7789
7746
  })
@@ -7833,7 +7790,10 @@ export function FloorplanPanel({
7833
7790
  }
7834
7791
  }, [isFloorplanOpen, stopFloorplanViewAnimation])
7835
7792
 
7793
+ // Clearing an open wall draft drops a Polygon room in progress (nothing of
7794
+ // it was written); paths that keep it commit first.
7836
7795
  const clearWallPlacementDraft = useCallback(() => {
7796
+ discardWallPolygonDraft()
7837
7797
  setDraftStart(null)
7838
7798
  setWallChainFirstVertex(null)
7839
7799
  wallConstructionOptionsRef.current = undefined
@@ -7890,10 +7850,9 @@ export function FloorplanPanel({
7890
7850
  setHoveredSiteHandleId(null)
7891
7851
  }, [clearSiteBoundaryLivePreview])
7892
7852
  const exitSiteEditingToSelect = useCallback(() => {
7893
- setPhase('structure')
7894
- setStructureLayer('elements')
7853
+ setPhase('building')
7895
7854
  setMode('select')
7896
- }, [setMode, setPhase, setStructureLayer])
7855
+ }, [setMode, setPhase])
7897
7856
 
7898
7857
  const clearDraft = useCallback(() => {
7899
7858
  clearWallPlacementDraft()
@@ -7955,10 +7914,8 @@ export function FloorplanPanel({
7955
7914
  }
7956
7915
 
7957
7916
  const { createNode, updateNode, nodes } = useScene.getState()
7958
- const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length
7959
- const zone = ZoneNodeSchema.parse({
7960
- color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],
7961
- name: `Zone ${zoneCount + 1}`,
7917
+ const zone = newZone({
7918
+ name: nextZoneName(nodes),
7962
7919
  polygon: points.map(([x, z]) => [x, z] as [number, number]),
7963
7920
  })
7964
7921
 
@@ -8240,13 +8197,13 @@ export function FloorplanPanel({
8240
8197
 
8241
8198
  if (
8242
8199
  isStairBuildActive &&
8243
- useEditor.getState().viewMode === '2d' &&
8200
+ visibleScene(useEditor.getState()) === '2d' &&
8244
8201
  (event.key === 'r' || event.key === 'R')
8245
8202
  ) {
8246
8203
  useStairBuildPreview.getState().rotateBy(Math.PI / 4)
8247
8204
  } else if (
8248
8205
  isStairBuildActive &&
8249
- useEditor.getState().viewMode === '2d' &&
8206
+ visibleScene(useEditor.getState()) === '2d' &&
8250
8207
  (event.key === 't' || event.key === 'T')
8251
8208
  ) {
8252
8209
  useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
@@ -8957,7 +8914,7 @@ export function FloorplanPanel({
8957
8914
  // tools that subscribe to these grid events.
8958
8915
  const emitFloorplanGridEvent = useCallback(
8959
8916
  (
8960
- eventType: 'move' | 'click' | 'double-click',
8917
+ eventType: 'move' | 'click' | 'double-click' | 'pointerdown' | 'pointerup',
8961
8918
  planPoint: WallPlanPoint,
8962
8919
  nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
8963
8920
  ) => {
@@ -9158,7 +9115,7 @@ export function FloorplanPanel({
9158
9115
  const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg
9159
9116
 
9160
9117
  applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg)
9161
- if (useEditor.getState().viewMode === 'split') {
9118
+ if (visibleScene(useEditor.getState()) === 'split') {
9162
9119
  publishFloorplanNavigationPose(
9163
9120
  rotationState.viewportCenterLocal,
9164
9121
  nextUserRotationDeg,
@@ -9200,7 +9157,7 @@ export function FloorplanPanel({
9200
9157
  userRotationDeg: currentUserRotationDeg,
9201
9158
  viewWidth: currentViewport.width,
9202
9159
  }
9203
- if (useEditor.getState().viewMode === 'split') {
9160
+ if (visibleScene(useEditor.getState()) === 'split') {
9204
9161
  publishFloorplanNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width)
9205
9162
  }
9206
9163
 
@@ -9292,6 +9249,14 @@ export function FloorplanPanel({
9292
9249
  }
9293
9250
 
9294
9251
  if (isFenceBuildActive) {
9252
+ if (useEditor.getState().getContinuation('fence') === 'freehand') {
9253
+ useAlignmentGuides.getState().clear()
9254
+ emitFloorplanGridEvent('move', planPoint, event)
9255
+ setCursorPoint((previousPoint) =>
9256
+ previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
9257
+ )
9258
+ return
9259
+ }
9295
9260
  // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
9296
9261
  // Figma alignment — same endpoint-wins precedence as the wall branch.
9297
9262
  // While a draft is open the segment locks to 15° rays from its start.
@@ -9299,14 +9264,18 @@ export function FloorplanPanel({
9299
9264
  // there is no Shift hold-to-bypass. Alignment follows the magnetic snap
9300
9265
  // mode, not Alt (continuation is cycled through the HUD / C).
9301
9266
  const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
9302
- const fenceSnapped = snapFenceDraftPoint({
9303
- point: planPoint,
9304
- walls,
9305
- fences,
9306
- start: fenceDraftStart ?? undefined,
9307
- angleSnap: fenceAngleSnap,
9308
- magnetic: isMagneticSnapActive(),
9309
- })
9267
+ const fenceSnapped = snapRegisteredDraftPoint(
9268
+ 'fence',
9269
+ {
9270
+ point: planPoint,
9271
+ walls,
9272
+ fences,
9273
+ start: fenceDraftStart ?? undefined,
9274
+ angleSnap: fenceAngleSnap,
9275
+ magnetic: isMagneticSnapActive(),
9276
+ },
9277
+ planPoint,
9278
+ )
9310
9279
  const fenceGridBase = snapWallPointToGrid(planPoint)
9311
9280
  const fenceLocked =
9312
9281
  fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
@@ -9495,7 +9464,7 @@ export function FloorplanPanel({
9495
9464
  const wallAngleSnap = draftStart !== null && isAngleSnapActive()
9496
9465
  const wallSnap = snapWallDraftPointDetailed({
9497
9466
  point: planPoint,
9498
- walls,
9467
+ walls: [...walls, ...wallPolygonDraftWalls()],
9499
9468
  start: draftStart ?? undefined,
9500
9469
  angleSnap: wallAngleSnap,
9501
9470
  magnetic: isMagneticSnapActive(),
@@ -9602,7 +9571,7 @@ export function FloorplanPanel({
9602
9571
  const firstPoint = slabDraftPoints[0]
9603
9572
  if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
9604
9573
  // 2D-only view: the 3D tool can't commit, so close the polygon here.
9605
- if (useEditor.getState().viewMode === '2d') {
9574
+ if (visibleScene(useEditor.getState()) === '2d') {
9606
9575
  createSlabOnCurrentLevel(slabDraftPoints, slabPlacementBaseRef.current)
9607
9576
  }
9608
9577
  clearDraft()
@@ -9652,7 +9621,7 @@ export function FloorplanPanel({
9652
9621
  }
9653
9622
 
9654
9623
  // 2D-only view: the 3D tool can't commit, so create the slab here.
9655
- if (useEditor.getState().viewMode === '2d') {
9624
+ if (visibleScene(useEditor.getState()) === '2d') {
9656
9625
  createSlabOnCurrentLevel(nextPoints, slabPlacementBaseRef.current)
9657
9626
  }
9658
9627
  clearDraft()
@@ -9668,7 +9637,7 @@ export function FloorplanPanel({
9668
9637
 
9669
9638
  const firstPoint = ceilingDraftPoints[0]
9670
9639
  if (firstPoint && ceilingDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
9671
- if (useEditor.getState().viewMode === '2d') {
9640
+ if (visibleScene(useEditor.getState()) === '2d') {
9672
9641
  createCeilingOnCurrentLevel(ceilingDraftPoints)
9673
9642
  }
9674
9643
  clearCeilingPlacementDraft()
@@ -9701,7 +9670,7 @@ export function FloorplanPanel({
9701
9670
  return
9702
9671
  }
9703
9672
 
9704
- if (useEditor.getState().viewMode === '2d') {
9673
+ if (visibleScene(useEditor.getState()) === '2d') {
9705
9674
  createCeilingOnCurrentLevel(nextPoints)
9706
9675
  }
9707
9676
  clearCeilingPlacementDraft()
@@ -9768,6 +9737,14 @@ export function FloorplanPanel({
9768
9737
  useEditor.getState().toolDefaults.wall,
9769
9738
  )
9770
9739
  : undefined
9740
+ // 2D-only owns a Polygon room's draft; split view leaves it to the 3D tool.
9741
+ if (
9742
+ levelId &&
9743
+ visibleScene(useEditor.getState()) === '2d' &&
9744
+ getWallDrawVariant() === 'polygon'
9745
+ ) {
9746
+ startWallPolygonDraft(levelId as AnyNodeId, point, wallConstructionOptionsRef.current)
9747
+ }
9771
9748
  setDraftStart(point)
9772
9749
  setWallChainFirstVertex(point)
9773
9750
  setDraftEnd(point)
@@ -9793,8 +9770,22 @@ export function FloorplanPanel({
9793
9770
  // `display:none`, so the tool never commits. Mirror the slab /
9794
9771
  // ceiling 2D-only committers: create locally here, gated on the
9795
9772
  // view, so split / 3D keep their single-owner tool commit.
9796
- const viewIs2DOnly = useEditor.getState().viewMode === '2d'
9773
+ const viewIs2DOnly = visibleScene(useEditor.getState()) === '2d'
9797
9774
  let createdWall: WallNode | null = null
9775
+ // 2D-only Polygon room: the corner joins the draft; nothing is written
9776
+ // until it closes, seals or tees (then every wall at once).
9777
+ if (viewIs2DOnly && getWallDrawVariant() === 'polygon') {
9778
+ if (addWallPolygonDraftCorner(point) !== 'open') {
9779
+ commitWallPolygonDraft()
9780
+ clearWallPlacementDraft()
9781
+ setCursorPoint(null)
9782
+ return
9783
+ }
9784
+ setDraftStart(point)
9785
+ setDraftEnd(point)
9786
+ setCursorPoint(point)
9787
+ return
9788
+ }
9798
9789
  if (viewIs2DOnly) {
9799
9790
  createdWall = createWallOnCurrentLevel(
9800
9791
  draftStart,
@@ -9998,18 +9989,25 @@ export function FloorplanPanel({
9998
9989
  return
9999
9990
  }
10000
9991
 
9992
+ if (useInteractionScope.getState().scope.kind !== 'idle') return
10001
9993
  const modifierKeys = getSelectionModifierKeys(event)
10002
9994
 
9995
+ if (enterBuildingFromPlanHit(getFloorplanHitIdAtPoint(planPoint))) return
9996
+ if (roomPickingEnabled()) {
9997
+ const hitId = getFloorplanHitIdAtPoint(planPoint)
9998
+ if (selectRoomFromHit(resolvePlanRoomHit(hitId, planPoint), modifierKeys, hitId)) return
9999
+ }
10003
10000
  const backgroundSelection = resolveFloorplanBackgroundSelection({
10004
10001
  canSelectElementFloorplanGeometry,
10005
- canSelectFloorplanZones,
10002
+ canSelectFloorplanZones: canSelectFloorplanZones && !!useViewer.getState().focusedUnitId,
10006
10003
  currentSelectedIds: useViewer.getState().selection.selectedIds,
10007
10004
  expandIdsForNode: expandSessionSelectionForNode,
10008
- getFloorplanHitIdAtPoint,
10005
+ getFloorplanHitIdAtPoint: useViewer.getState().focusedUnitId
10006
+ ? (point) => zoneAtLevelPoint(point[0], point[1])?.id ?? null
10007
+ : getFloorplanHitIdAtPoint,
10009
10008
  isWallBuildActive,
10010
10009
  modifierKeys,
10011
10010
  planPoint,
10012
- structureLayer,
10013
10011
  })
10014
10012
 
10015
10013
  if (backgroundSelection.handled) {
@@ -10057,15 +10055,14 @@ export function FloorplanPanel({
10057
10055
  }
10058
10056
 
10059
10057
  if (backgroundSelection.kind === 'clear-zones') {
10060
- setSelection({ zoneId: null })
10061
- // Return to structure select (same as 3D grid click)
10062
- useEditor.getState().setStructureLayer('elements')
10063
- useEditor.getState().setMode('select')
10058
+ useEditor.getState().clearRoom()
10059
+ setSelection({ selectedIds: [], zoneId: null })
10064
10060
  return
10065
10061
  }
10066
10062
 
10067
10063
  if (!backgroundSelection.preserveSelection) {
10068
- setSelection({ selectedIds: [] })
10064
+ useEditor.getState().clearRoom()
10065
+ setSelection({ selectedIds: [], zoneId: null })
10069
10066
  }
10070
10067
  return
10071
10068
  }
@@ -10083,7 +10080,6 @@ export function FloorplanPanel({
10083
10080
  referenceScaleDraft,
10084
10081
  setSelectedReferenceId,
10085
10082
  setSelection,
10086
- structureLayer,
10087
10083
  getFloorplanHitIdAtPoint,
10088
10084
  unit,
10089
10085
  emitFloorplanGridEvent,
@@ -10103,6 +10099,49 @@ export function FloorplanPanel({
10103
10099
  },
10104
10100
  [handleBackgroundClick],
10105
10101
  )
10102
+ const handleSvgPointerDown = useCallback(
10103
+ (event: ReactPointerEvent<SVGSVGElement>) => {
10104
+ if (
10105
+ isFenceBuildActive &&
10106
+ useEditor.getState().getContinuation('fence') === 'freehand' &&
10107
+ event.button === 0
10108
+ ) {
10109
+ const point = getPlanPointFromClientPoint(event.clientX, event.clientY)
10110
+ if (point) {
10111
+ clearFencePlacementDraft()
10112
+ event.currentTarget.setPointerCapture(event.pointerId)
10113
+ emitFloorplanGridEvent('pointerdown', point, event)
10114
+ }
10115
+ }
10116
+ handlePointerDown(event)
10117
+ },
10118
+ [
10119
+ clearFencePlacementDraft,
10120
+ emitFloorplanGridEvent,
10121
+ getPlanPointFromClientPoint,
10122
+ handlePointerDown,
10123
+ isFenceBuildActive,
10124
+ ],
10125
+ )
10126
+ const handleSvgPointerUp = useCallback(
10127
+ (event: ReactPointerEvent<SVGSVGElement>) => {
10128
+ if (
10129
+ isFenceBuildActive &&
10130
+ useEditor.getState().getContinuation('fence') === 'freehand' &&
10131
+ event.button === 0
10132
+ ) {
10133
+ const point = getPlanPointFromClientPoint(event.clientX, event.clientY)
10134
+ if (point) emitFloorplanGridEvent('pointerup', point, event)
10135
+ }
10136
+ endFloorplanNavigation(event)
10137
+ },
10138
+ [
10139
+ emitFloorplanGridEvent,
10140
+ endFloorplanNavigation,
10141
+ getPlanPointFromClientPoint,
10142
+ isFenceBuildActive,
10143
+ ],
10144
+ )
10106
10145
  const handleBackgroundDoubleClick = useCallback(
10107
10146
  (event: ReactMouseEvent<SVGSVGElement>) => {
10108
10147
  if (!(isPolygonDraftBuildActive && !isRoofBuildActive)) {
@@ -10253,28 +10292,18 @@ export function FloorplanPanel({
10253
10292
  if (floorplanScreenSelectionOwnsInputDraggingRef.current) {
10254
10293
  useViewer.getState().setInputDragging(false)
10255
10294
  floorplanScreenSelectionOwnsInputDraggingRef.current = false
10295
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'box-select')
10256
10296
  }
10257
10297
  }, [syncPreviewSelectedIds])
10258
10298
 
10259
10299
  const commitFloorplanScreenSelection = useCallback(
10260
10300
  (nextSelectedIds: string[], event: PointerEvent) => {
10261
10301
  const modifierKeys = getSelectionModifierKeys(event)
10262
- const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
10263
-
10264
10302
  setSelectedReferenceId(null)
10265
10303
 
10266
- if (phase === 'structure' && structureLayer === 'zones') {
10267
- if (nextSelectedIds.length > 0) {
10268
- setSelection({ zoneId: nextSelectedIds[0] as ZoneNodeType['id'] })
10269
- } else if (!shouldAppend) {
10270
- setSelection({ zoneId: null })
10271
- }
10272
- return
10273
- }
10274
-
10275
10304
  addFloorplanSelection(nextSelectedIds, modifierKeys)
10276
10305
  },
10277
- [addFloorplanSelection, phase, setSelectedReferenceId, setSelection, structureLayer],
10306
+ [addFloorplanSelection, setSelectedReferenceId],
10278
10307
  )
10279
10308
 
10280
10309
  useEffect(() => {
@@ -10324,6 +10353,7 @@ export function FloorplanPanel({
10324
10353
  if (!state.isDragging && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) {
10325
10354
  state.isDragging = true
10326
10355
  floorplanScreenSelectionOwnsInputDraggingRef.current = true
10356
+ useInteractionScope.getState().begin({ kind: 'box-select' })
10327
10357
  useViewer.getState().setInputDragging(true)
10328
10358
  markBoxSelectHandled()
10329
10359
 
@@ -10728,11 +10758,16 @@ export function FloorplanPanel({
10728
10758
  const hasFloorplanCursorIndicator =
10729
10759
  Boolean(movingOpeningType) ||
10730
10760
  (mode === 'build' && tool !== null) ||
10731
- (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') ||
10761
+ (mode === 'select' && floorplanSelectionTool === 'marquee') ||
10732
10762
  mode === 'delete'
10733
10763
 
10734
10764
  const handleSvgPointerMove = useCallback(
10735
10765
  (event: ReactPointerEvent<SVGSVGElement>) => {
10766
+ if (roomPickingEnabled() && !(event.target as Element).closest('.floorplan-registry-entry')) {
10767
+ const point = getPlanPointFromClientPoint(event.clientX, event.clientY)
10768
+ if (point)
10769
+ hoverRoomFromHit(resolvePlanRoomHit(null, point), getSelectionModifierKeys(event), null)
10770
+ }
10736
10771
  if (
10737
10772
  hasFloorplanCursorIndicator &&
10738
10773
  !isSpacePanPressed &&
@@ -10765,6 +10800,7 @@ export function FloorplanPanel({
10765
10800
  [
10766
10801
  handlePointerMove,
10767
10802
  hasFloorplanCursorIndicator,
10803
+ getPlanPointFromClientPoint,
10768
10804
  isSpacePanPressed,
10769
10805
  siteVertexDragState,
10770
10806
  setFloorplanCursorPosition,
@@ -10772,6 +10808,7 @@ export function FloorplanPanel({
10772
10808
  )
10773
10809
 
10774
10810
  const handleSvgPointerLeave = useCallback(() => {
10811
+ useEditor.getState().setHoveredRoom(null)
10775
10812
  setFloorplanCursorPosition(null)
10776
10813
  setHoveredGuideCorner(null)
10777
10814
  handlePointerLeave()
@@ -11082,8 +11119,7 @@ export function FloorplanPanel({
11082
11119
  )
11083
11120
  })()
11084
11121
 
11085
- setPhase('structure')
11086
- setStructureLayer('elements')
11122
+ setPhase('building')
11087
11123
  setMode('select')
11088
11124
 
11089
11125
  const nextSelection: Parameters<typeof setSelection>[0] = {
@@ -11100,16 +11136,7 @@ export function FloorplanPanel({
11100
11136
  }
11101
11137
 
11102
11138
  setSelection(nextSelection)
11103
- }, [
11104
- currentBuildingId,
11105
- floorplanLevels,
11106
- levelNode,
11107
- setMode,
11108
- setPhase,
11109
- setSelection,
11110
- setStructureLayer,
11111
- site,
11112
- ])
11139
+ }, [currentBuildingId, floorplanLevels, levelNode, setMode, setPhase, setSelection, site])
11113
11140
  const activeDraftAnchorPoint =
11114
11141
  referenceScaleDraft?.start ??
11115
11142
  draftStart ??
@@ -11189,8 +11216,10 @@ export function FloorplanPanel({
11189
11216
  pill, plus the group pill for multi-selections. */}
11190
11217
  <FloorplanRegistryActionMenu />
11191
11218
  <FloorplanGroupActionMenu />
11219
+ <FloorplanDrawingTypeSwitch className={planOnRight ? 'right-24 left-auto' : undefined} />
11192
11220
 
11193
11221
  {!isStudioWorkspace &&
11222
+ sceneLeft !== null &&
11194
11223
  (levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11195
11224
  (compassHost ? (
11196
11225
  createPortal(
@@ -11198,6 +11227,7 @@ export function FloorplanPanel({
11198
11227
  needleRef={compassNeedleRef}
11199
11228
  northRotationDeg={floorplanUserRotationDeg}
11200
11229
  onAlignNorth={alignFloorplanViewToNorth}
11230
+ style={compassStyle}
11201
11231
  />,
11202
11232
  compassHost,
11203
11233
  )
@@ -11338,7 +11368,7 @@ export function FloorplanPanel({
11338
11368
  // renderer, handle layers) must NOT render/reconcile while hidden —
11339
11369
  // otherwise every scene/selection change in pure 3D re-rendered the
11340
11370
  // whole floorplan tree (profiler: 150–200ms on a wall-endpoint drag).
11341
- // `isFloorplanOpen` is `viewMode !== '3d'`, so this still renders fully
11371
+ // `isFloorplanOpen` is "the plan is in a pane", so this still renders fully
11342
11372
  // in both 2D and split. Viewport state lives on the still-mounted
11343
11373
  // panel, so pan/zoom is preserved across the toggle.
11344
11374
  <svg
@@ -11348,11 +11378,11 @@ export function FloorplanPanel({
11348
11378
  onContextMenu={(event) => event.preventDefault()}
11349
11379
  onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick}
11350
11380
  onPointerCancel={endFloorplanNavigation}
11351
- onPointerDown={handlePointerDown}
11381
+ onPointerDown={handleSvgPointerDown}
11352
11382
  onPointerDownCapture={handleNavigationPointerDown}
11353
11383
  onPointerLeave={handleSvgPointerLeave}
11354
11384
  onPointerMove={handleSvgPointerMove}
11355
- onPointerUp={endFloorplanNavigation}
11385
+ onPointerUp={handleSvgPointerUp}
11356
11386
  ref={svgRef}
11357
11387
  style={{
11358
11388
  cursor:
@@ -11449,7 +11479,7 @@ export function FloorplanPanel({
11449
11479
  subscribes to the `useStairBuildPreview` store directly, so a
11450
11480
  per-`grid:move` cursor update re-renders only that tiny layer
11451
11481
  rather than this whole panel. */}
11452
- <FloorplanStairBuildPreviewLayer isDeleteMode={isDeleteMode} palette={palette} />
11482
+ <FloorplanStairBuildPreviewLayer palette={floorplanRegistryPalette} />
11453
11483
 
11454
11484
  <FloorplanReferenceScaleLayer
11455
11485
  draft={referenceScaleDraft}
@@ -11500,7 +11530,13 @@ export function FloorplanPanel({
11500
11530
  whose extent is derived from the current viewBox and
11501
11531
  would create a measure→fit→measure loop. */}
11502
11532
  <g ref={floorplanContentRef}>
11503
- <FloorplanRegistryLayer />
11533
+ {/* Site-plan mode swaps the level drawing for the lot /
11534
+ setback / footprint drawing, in SITE coordinates. */}
11535
+ {activeDrawingType === 'site-plan' ? (
11536
+ <FloorplanSitePlanLayer />
11537
+ ) : (
11538
+ <FloorplanRegistryLayer />
11539
+ )}
11504
11540
  {/* Faint footprint ghost of the node being placed by a
11505
11541
  registry placement tool (e.g. column), following the
11506
11542
  cursor. The 3D mesh preview is hidden in 2D, so this is
@@ -11514,6 +11550,7 @@ export function FloorplanPanel({
11514
11550
  `floorplan-wall-move-ghost-layer.tsx`. */}
11515
11551
  <FloorplanWallMoveGhostLayer />
11516
11552
  </g>
11553
+ {activeDrawingType !== 'site-plan' && <FloorplanOpenWallEndsLayer />}
11517
11554
  <FloorplanMeasurementToolLayer />
11518
11555
  <FloorplanRegisteredToolLayer />
11519
11556
  {floorplanSceneSlot}
@@ -11530,42 +11567,54 @@ export function FloorplanPanel({
11530
11567
  paint on top of node geometry. */}
11531
11568
  <FloorplanAlignmentGuideLayer />
11532
11569
 
11533
- <FloorplanSiteLayer
11534
- dimmed={selectedIds.length > 1 || previewSelectedIds.length > 1}
11535
- isHighlighted={isSiteBoundaryHighlighted}
11536
- palette={palette}
11537
- sitePolygon={visibleSitePolygon}
11538
- />
11570
+ {/* The property line, its vertex handles and its edge labels are
11571
+ drawn in BUILDING-LOCAL metres (the frame the level's walls
11572
+ live in). The site-plan drawing type draws the lot itself, in
11573
+ SITE metres, so mounting these there paints a second lot
11574
+ offset by the building's own position — the "two dashed
11575
+ boxes" — and it shifts whenever the house moves. */}
11576
+ {activeDrawingType !== 'site-plan' && (
11577
+ <FloorplanSiteLayer
11578
+ dimmed={selectedIds.length > 1 || previewSelectedIds.length > 1}
11579
+ isHighlighted={isSiteBoundaryHighlighted}
11580
+ palette={palette}
11581
+ sitePolygon={visibleSitePolygon}
11582
+ />
11583
+ )}
11539
11584
 
11540
- <FloorplanPolygonHandleLayer
11541
- edgeHandles={siteEdgeHandles}
11542
- hoveredHandleId={hoveredSiteHandleId}
11543
- midpointHandles={siteMidpointHandles}
11544
- onHandleHoverChange={setHoveredSiteHandleId}
11545
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
11546
- handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
11547
- }
11548
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
11549
- handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
11550
- }
11551
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
11552
- handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
11553
- }
11554
- palette={palette}
11555
- unitsPerPixel={floorplanUnitsPerPixel}
11556
- vertexHandles={siteVertexHandles}
11557
- />
11585
+ {activeDrawingType !== 'site-plan' && (
11586
+ <FloorplanPolygonHandleLayer
11587
+ edgeHandles={siteEdgeHandles}
11588
+ hoveredHandleId={hoveredSiteHandleId}
11589
+ midpointHandles={siteMidpointHandles}
11590
+ onHandleHoverChange={setHoveredSiteHandleId}
11591
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
11592
+ handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
11593
+ }
11594
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
11595
+ handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
11596
+ }
11597
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
11598
+ handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
11599
+ }
11600
+ palette={palette}
11601
+ unitsPerPixel={floorplanUnitsPerPixel}
11602
+ vertexHandles={siteVertexHandles}
11603
+ />
11604
+ )}
11558
11605
 
11559
- <FloorplanSiteEdgeLabelLayer
11560
- labelBackground={isDark ? '#0f172a' : '#ffffff'}
11561
- labelText={isDark ? '#e2e8f0' : '#171717'}
11562
- palette={palette}
11563
- sceneRotationDeg={floorplanSceneRotationDeg}
11564
- shouldShow={shouldShowSiteEdgeLabels}
11565
- sitePolygon={visibleSitePolygon}
11566
- unit={unit}
11567
- unitsPerPixel={floorplanUnitsPerPixel}
11568
- />
11606
+ {activeDrawingType !== 'site-plan' && (
11607
+ <FloorplanSiteEdgeLabelLayer
11608
+ labelBackground={isDark ? '#0f172a' : '#ffffff'}
11609
+ labelText={isDark ? '#e2e8f0' : '#171717'}
11610
+ palette={palette}
11611
+ sceneRotationDeg={floorplanSceneRotationDeg}
11612
+ shouldShow={shouldShowSiteEdgeLabels}
11613
+ sitePolygon={visibleSitePolygon}
11614
+ unit={unit}
11615
+ unitsPerPixel={floorplanUnitsPerPixel}
11616
+ />
11617
+ )}
11569
11618
 
11570
11619
  {/* "Magnetic" wall-snap beacon — per-kind glyph at the active
11571
11620
  draft / endpoint-move snap point. Same store + coord space as