@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,725 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ area,
7
+ getLevelDisplayName,
8
+ useScene,
9
+ type ZoneNode,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { Check, ChevronDown, Plus } from 'lucide-react'
13
+ import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react'
14
+ import { startCeilingEdit } from '../../../lib/ceiling-edit-session'
15
+ import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
16
+ import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements'
17
+ import { addMezzanineStairs } from '../../../lib/mezzanine-stairs'
18
+ import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands'
19
+ import { mezzanineElevationBounds } from '../../../lib/room-handle-drag'
20
+ import type { RoomSelectionRecord } from '../../../lib/room-selection'
21
+ import {
22
+ baseRoomName,
23
+ numberedRoomName,
24
+ type RoomNameSection,
25
+ roomNameSections,
26
+ roomNameUseCount,
27
+ } from '../../../lib/room-name-catalog'
28
+ import { renameRoom, requestRoomDeletion } from '../../../lib/room-structure-commands'
29
+ import { addZoneToNewUnit, assignZoneToUnit, zoneUnits } from '../../../lib/units'
30
+ import { cn } from '../../../lib/utils'
31
+ import useEditor from '../../../store/use-editor'
32
+ import useInteractionScope from '../../../store/use-interaction-scope'
33
+ import {
34
+ DropdownMenu,
35
+ DropdownMenuContent,
36
+ DropdownMenuItem,
37
+ DropdownMenuSeparator,
38
+ DropdownMenuTrigger,
39
+ } from '../primitives/dropdown-menu'
40
+ import { ColorDot } from '../primitives/color-dot'
41
+ import { Popover, PopoverAnchor, PopoverContent } from '../primitives/popover'
42
+ import { ShortcutToken } from '../primitives/shortcut-token'
43
+ import { PanelWrapper } from './panel-wrapper'
44
+ import { RoomOpeningsField } from './room-openings-field'
45
+ import {
46
+ ConstructionPart,
47
+ FloorHeightField,
48
+ PartField,
49
+ pill,
50
+ RoomConstructionRows,
51
+ roomInsideHeight,
52
+ } from './room-construction-rows'
53
+ import { type RoomNameOption, roomNameOptions } from './room-name-options'
54
+
55
+ /** "Room · Ground floor", "Mezzanine · in Kitchen", "Room · not enclosed". */
56
+ export function roomKicker(
57
+ nodes: Record<string, AnyNode>,
58
+ zoneId: string,
59
+ options: { mezzanine?: boolean; open?: boolean } = {},
60
+ ): string {
61
+ const zone = nodes[zoneId]
62
+ if (options.mezzanine && zone?.type === 'zone') {
63
+ const host = zone.hostZoneId ? nodes[zone.hostZoneId] : undefined
64
+ const hostName = host?.type === 'zone' ? host.name?.trim() : ''
65
+ return hostName ? `Mezzanine · in ${hostName}` : 'Mezzanine'
66
+ }
67
+ if (options.open) return 'Room · not enclosed'
68
+ const level = zone?.parentId ? nodes[zone.parentId] : undefined
69
+ return level?.type === 'level' ? `Room · ${getLevelDisplayName(level)}` : 'Room'
70
+ }
71
+
72
+ export function RoomPanel({
73
+ room,
74
+ onClose = () => useEditor.getState().clearRoom(),
75
+ }: {
76
+ room: RoomSelectionRecord
77
+ onClose?: () => void
78
+ }) {
79
+ const nodes = useScene((state) => state.nodes)
80
+ const sections = useMemo(() => roomNameSections(nodes, room.zoneId), [nodes, room.zoneId])
81
+ const name = useScene((state) => state.nodes[room.zoneId as AnyNodeId]?.name ?? '')
82
+ const visible = useInteractionScope(
83
+ (state) => resolveOverlayPolicy(state.scope).conflictingControls === 'shown',
84
+ )
85
+ if (!visible) return null
86
+ const mezzanine = !!room.mezzanine
87
+ return (
88
+ <PanelWrapper
89
+ kicker={roomKicker(nodes, room.zoneId, { mezzanine })}
90
+ onClose={onClose}
91
+ title={name.trim() || (mezzanine ? 'Mezzanine' : 'Room')}
92
+ titleContent={
93
+ <div className="flex min-w-0 flex-1 items-center gap-2">
94
+ <RoomColorDot zoneId={room.zoneId} />
95
+ <RoomName
96
+ key={room.zoneId}
97
+ mezzanine={mezzanine}
98
+ name={name}
99
+ sections={sections}
100
+ zoneId={room.zoneId}
101
+ />
102
+ </div>
103
+ }
104
+ >
105
+ <div className="flex flex-col px-4 pt-3 pb-4 text-sm" data-room-panel={room.key.zoneId}>
106
+ {!mezzanine && <RoomSubline room={room} />}
107
+ <RoomStats room={room} />
108
+ <RoomUnitRow zoneId={room.zoneId} />
109
+ {mezzanine ? (
110
+ <MezzanineRows key={room.zoneId} room={room} />
111
+ ) : (
112
+ <RoomConstructionRows key={room.zoneId} room={room} />
113
+ )}
114
+ </div>
115
+ </PanelWrapper>
116
+ )
117
+ }
118
+
119
+ /**
120
+ * A room the walls do not close (yet): the same title and kicker, and what
121
+ * needs no closed outline — its name, its unit, deleting it.
122
+ */
123
+ export function OpenRoomPanel({ zoneId, onClose }: { zoneId: string; onClose: () => void }) {
124
+ const nodes = useScene((state) => state.nodes)
125
+ const sections = useMemo(() => roomNameSections(nodes, zoneId), [nodes, zoneId])
126
+ const name = useScene((state) => state.nodes[zoneId as AnyNodeId]?.name ?? '')
127
+ const unit = useViewer((s) => s.unit)
128
+ const zone = nodes[zoneId as AnyNodeId]
129
+ if (zone?.type !== 'zone') return null
130
+ return (
131
+ <PanelWrapper
132
+ kicker={roomKicker(nodes, zoneId, { open: true })}
133
+ onClose={onClose}
134
+ title={name.trim() || 'Room'}
135
+ titleContent={
136
+ <div className="flex min-w-0 flex-1 items-center gap-2">
137
+ <RoomColorDot zoneId={zoneId} />
138
+ <RoomName
139
+ key={zoneId}
140
+ mezzanine={false}
141
+ name={name}
142
+ sections={sections}
143
+ zoneId={zoneId}
144
+ />
145
+ </div>
146
+ }
147
+ >
148
+ <div className="flex flex-col px-4 pt-3 pb-4 text-sm" data-open-room-panel={zoneId}>
149
+ <p className="text-muted-foreground text-xs">
150
+ {formatAreaLabel(area([{ outer: zone.polygon, holes: zone.holes }]), unit, 2)} · Close
151
+ its walls to build its floor and ceiling.
152
+ </p>
153
+ <RoomUnitRow zoneId={zoneId} />
154
+ <button
155
+ className="mt-3 w-full rounded-full border border-border/50 py-2 font-medium text-[13px] text-foreground transition-colors hover:bg-accent/50"
156
+ data-delete-open-room
157
+ onClick={() => requestRoomDeletion(zoneId)}
158
+ type="button"
159
+ >
160
+ Delete room
161
+ </button>
162
+ </div>
163
+ </PanelWrapper>
164
+ )
165
+ }
166
+
167
+ /** "Unit: Flat A ›" — which unit the room belongs to; change it, add it to one, or take it out. */
168
+ export function RoomUnitRow({ zoneId }: { zoneId: string }) {
169
+ const nodes = useScene((scene) => scene.nodes)
170
+ const { buildingId, units, current } = useMemo(() => zoneUnits(nodes, zoneId), [nodes, zoneId])
171
+ if (!buildingId) return null
172
+ return (
173
+ <div className="mt-3 flex min-h-8 items-center justify-between gap-3" data-room-unit={zoneId}>
174
+ <span className="text-muted-foreground text-xs">Unit</span>
175
+ <DropdownMenu>
176
+ <DropdownMenuTrigger asChild>
177
+ <button
178
+ aria-label="Unit"
179
+ className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/50 px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
180
+ type="button"
181
+ >
182
+ {current && (
183
+ <span
184
+ aria-hidden
185
+ className="size-2 shrink-0 rounded-full"
186
+ style={{ backgroundColor: current.color }}
187
+ />
188
+ )}
189
+ <span className="truncate">
190
+ {current ? current.name || 'Unit' : units.length ? 'None' : 'Add to a unit'}
191
+ </span>
192
+ <ChevronDown className="size-3 shrink-0 text-muted-foreground" />
193
+ </button>
194
+ </DropdownMenuTrigger>
195
+ <DropdownMenuContent align="end" className="min-w-40">
196
+ {units.map((unit) => (
197
+ <DropdownMenuItem
198
+ key={unit.id}
199
+ onSelect={() => assignZoneToUnit(zoneId as ZoneNode['id'], unit.id)}
200
+ >
201
+ <span
202
+ aria-hidden
203
+ className="size-2 shrink-0 rounded-full"
204
+ style={{ backgroundColor: unit.color }}
205
+ />
206
+ <span className="flex-1 truncate">{unit.name || 'Unit'}</span>
207
+ {unit.id === current?.id && <Check className="size-3.5" />}
208
+ </DropdownMenuItem>
209
+ ))}
210
+ {units.length > 0 && <DropdownMenuSeparator />}
211
+ <DropdownMenuItem onSelect={() => addZoneToNewUnit(zoneId as ZoneNode['id'])}>
212
+ New unit
213
+ </DropdownMenuItem>
214
+ {current && (
215
+ <DropdownMenuItem onSelect={() => assignZoneToUnit(zoneId as ZoneNode['id'], null)}>
216
+ Remove from {current.name || 'unit'}
217
+ </DropdownMenuItem>
218
+ )}
219
+ </DropdownMenuContent>
220
+ </DropdownMenu>
221
+ </div>
222
+ )
223
+ }
224
+
225
+ const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
226
+
227
+ /** "2 doors · 3 windows" (the level is in the kicker). */
228
+ function RoomSubline({ room }: { room: RoomSelectionRecord }) {
229
+ const text = useScene((scene) => {
230
+ const walls = new Set(room.boundaryWallIds)
231
+ let doors = 0
232
+ let windows = 0
233
+ for (const node of Object.values(scene.nodes)) {
234
+ if (node.type !== 'door' && node.type !== 'window') continue
235
+ const wallId = node.parentId ?? ('wallId' in node ? (node.wallId as string) : '')
236
+ if (!walls.has(wallId)) continue
237
+ if (node.type === 'door') doors++
238
+ else windows++
239
+ }
240
+ return `${count(doors, 'door')} · ${count(windows, 'window')}`
241
+ })
242
+ return (
243
+ <p className="text-muted-foreground text-xs" data-room-subline>
244
+ {text}
245
+ </p>
246
+ )
247
+ }
248
+
249
+ function ringPerimeter(ring: readonly (readonly [number, number])[]) {
250
+ let total = 0
251
+ for (let i = 0; i < ring.length; i++) {
252
+ const a = ring[i]!
253
+ const b = ring[(i + 1) % ring.length]!
254
+ total += Math.hypot(b[0] - a[0], b[1] - a[1])
255
+ }
256
+ return total
257
+ }
258
+
259
+ /** Area, perimeter and inside height, in the user's units. */
260
+ function RoomStats({ room }: { room: RoomSelectionRecord }) {
261
+ const unit = useViewer((s) => s.unit)
262
+ const metricNotation = useViewer((s) => s.metricNotation)
263
+ const nodes = useScene((scene) => scene.nodes)
264
+ const zone = nodes[room.zoneId as AnyNodeId]
265
+ const perimeter =
266
+ zone?.type === 'zone'
267
+ ? [zone.polygon, ...(zone.holes ?? [])].reduce((sum, ring) => sum + ringPerimeter(ring), 0)
268
+ : 0
269
+ const { height } = roomInsideHeight(room, nodes)
270
+ const stats = [
271
+ { label: 'Area', value: formatAreaLabel(room.area, unit, 2) },
272
+ { label: 'Perimeter', value: formatLinearMeasurement(perimeter, unit, metricNotation) },
273
+ {
274
+ label: 'Height',
275
+ value: height === null ? '—' : formatLinearMeasurement(height, unit, metricNotation),
276
+ },
277
+ ]
278
+ return (
279
+ <div
280
+ className="mt-3 grid grid-cols-3 divide-x divide-border/50 rounded-lg border border-border/50"
281
+ data-room-stats
282
+ >
283
+ {stats.map((stat) => (
284
+ <div className="flex min-w-0 flex-col gap-0.5 px-2.5 py-2" key={stat.label}>
285
+ <span className="text-[11px] text-muted-foreground">{stat.label}</span>
286
+ <span className="truncate font-medium font-mono text-[13px] text-foreground tabular-nums">
287
+ {stat.value}
288
+ </span>
289
+ </div>
290
+ ))}
291
+ </div>
292
+ )
293
+ }
294
+
295
+ /**
296
+ * A mezzanine is built the same way, adapted: its own floor at its height
297
+ * (editable within what core accepts), stairs up to it, and the ceiling over
298
+ * it when there is headroom. No walls of its own, so no walls row.
299
+ */
300
+ function MezzanineRows({ room }: { room: RoomSelectionRecord }) {
301
+ const zoneId = room.zoneId
302
+ const nodes = useScene((scene) => scene.nodes)
303
+ const unit = useViewer((s) => s.unit)
304
+ const metricNotation = useViewer((s) => s.metricNotation)
305
+ const [expanded, setExpanded] = useState<'floor' | 'ceiling' | null>(null)
306
+ const [stairMessage, setStairMessage] = useState<string | null>(null)
307
+ const length = (meters: number) => formatLinearMeasurement(meters, unit, metricNotation)
308
+ const elevation = roomRelativeFloorHeight(nodes, zoneId)
309
+ const base = roomFloorBase(nodes, zoneId)
310
+ const absoluteBounds = mezzanineElevationBounds(nodes, zoneId)
311
+ const bounds = absoluteBounds && {
312
+ min: absoluteBounds.min - base,
313
+ max: absoluteBounds.max - base,
314
+ }
315
+ const hasCeiling = Object.values(nodes).some(
316
+ (node) => node.type === 'ceiling' && node.zoneId === zoneId,
317
+ )
318
+ const { ceiling } = roomInsideHeight(room, nodes)
319
+ const toggle = (part: 'floor' | 'ceiling') => () =>
320
+ setExpanded((current) => (current === part ? null : part))
321
+ return (
322
+ <div className="flex flex-col" data-mezzanine-rows={zoneId}>
323
+ <h3 className="mt-4 mb-1.5 font-medium text-muted-foreground text-xs">Built with</h3>
324
+ <div className="flex flex-col gap-1">
325
+ <ConstructionPart
326
+ expanded={expanded === 'floor'}
327
+ icon="/icons/floor.webp"
328
+ label="Floor"
329
+ meta={`${length(elevation)} up`}
330
+ on
331
+ onToggleExpanded={toggle('floor')}
332
+ part="floor"
333
+ >
334
+ <FloorHeightField bounds={bounds} zoneId={zoneId} />
335
+ <RoomOpeningsField surface="floor" zoneId={zoneId} />
336
+ </ConstructionPart>
337
+ <ConstructionPart
338
+ control={
339
+ <button
340
+ className={pill}
341
+ data-add-mezzanine-stairs
342
+ onClick={() => {
343
+ const result = addMezzanineStairs(zoneId)
344
+ setStairMessage(result.ok ? null : result.message)
345
+ }}
346
+ type="button"
347
+ >
348
+ Add stairs
349
+ </button>
350
+ }
351
+ expanded={false}
352
+ icon="/icons/stairs.webp"
353
+ label="Stairs"
354
+ meta="Up from the floor below"
355
+ on
356
+ onToggleExpanded={() => {}}
357
+ part="stairs"
358
+ />
359
+ <ConstructionPart
360
+ expanded={expanded === 'ceiling'}
361
+ icon="/icons/ceiling.webp"
362
+ label="Ceiling"
363
+ meta={
364
+ hasCeiling
365
+ ? ceiling === null
366
+ ? 'Over the mezzanine'
367
+ : `${length(ceiling)} headroom`
368
+ : 'Not enough headroom'
369
+ }
370
+ on
371
+ onToggleExpanded={toggle('ceiling')}
372
+ part="ceiling"
373
+ >
374
+ {hasCeiling ? (
375
+ <PartField label="Shape">
376
+ <button className={pill} onClick={() => startCeilingEdit(zoneId)} type="button">
377
+ Edit shape
378
+ </button>
379
+ </PartField>
380
+ ) : undefined}
381
+ </ConstructionPart>
382
+ </div>
383
+ {stairMessage && (
384
+ <span className="mt-2 text-destructive text-xs" role="status">
385
+ {stairMessage}
386
+ </span>
387
+ )}
388
+ </div>
389
+ )
390
+ }
391
+
392
+ /**
393
+ * The room's colour, as the scene graph shows and edits it: the plan fill and
394
+ * the label tint, never a finish (finishes are painted).
395
+ */
396
+ function RoomColorDot({ zoneId }: { zoneId: string }) {
397
+ const color = useScene((state) => {
398
+ const zone = state.nodes[zoneId as AnyNodeId]
399
+ return zone?.type === 'zone' ? zone.color : undefined
400
+ })
401
+ return (
402
+ <ColorDot
403
+ color={color ?? '#3b82f6'}
404
+ label="Room colour — used in the plan and labels"
405
+ onChange={(next) => useScene.getState().updateNode(zoneId as AnyNodeId, { color: next })}
406
+ side="bottom"
407
+ />
408
+ )
409
+ }
410
+
411
+ /**
412
+ * The name, edited in place: it reads as the panel's heading until focused.
413
+ * Focusing opens every suggestion — the current name is not a filter until
414
+ * the user types — with the caret at the end so the name can be fine-tuned.
415
+ * Picking a name (click or Enter) writes it as one rename — numbered when
416
+ * another room on the level has it ("Bedroom 2") — and lets go of the field;
417
+ * Escape reverts.
418
+ */
419
+ function RoomName({
420
+ zoneId,
421
+ name,
422
+ sections,
423
+ mezzanine,
424
+ }: {
425
+ zoneId: string
426
+ name: string
427
+ sections: readonly RoomNameSection[]
428
+ mezzanine: boolean
429
+ }) {
430
+ const nodes = useScene((state) => state.nodes)
431
+ const [value, setValue] = useState(name)
432
+ const [open, setOpen] = useState(false)
433
+ const [typing, setTyping] = useState(false)
434
+ const [active, setActive] = useState(-1)
435
+ const valueRef = useRef(name)
436
+ // Enter/click commit and then blur, and blur commits too; this keeps it one rename.
437
+ const committedRef = useRef(name)
438
+ const originalRef = useRef(name)
439
+ const inputRef = useRef<HTMLInputElement>(null)
440
+ const listId = useId()
441
+ const { typed: typedRow, groups, options } = roomNameOptions(value, sections, typing)
442
+ const expanded = open && options.length > 0
443
+ const activeId = expanded && options[active] ? `${listId}-${active}` : undefined
444
+ const current = baseRoomName(value).toLowerCase()
445
+
446
+ // A rename from elsewhere (undo, a collaborator, the plan) lands in the field
447
+ // unless the user is editing it (the field stays mounted across renames).
448
+ useEffect(() => {
449
+ if (document.activeElement === inputRef.current) return
450
+ valueRef.current = name
451
+ committedRef.current = name
452
+ setValue(name)
453
+ }, [name])
454
+
455
+ // Opening and the arrow keys bring the active row into view; the pointer
456
+ // only highlights (scrolling under it would move the row being clicked).
457
+ const scrollToActive = useRef(false)
458
+ useEffect(() => {
459
+ if (!(activeId && scrollToActive.current)) return
460
+ scrollToActive.current = false
461
+ // The list mounts in the same commit that opens it: wait a frame so the
462
+ // current name, lower in the list, is scrolled to on open.
463
+ const frame = requestAnimationFrame(() =>
464
+ document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' }),
465
+ )
466
+ return () => cancelAnimationFrame(frame)
467
+ }, [activeId])
468
+
469
+ const caretToEnd = () => {
470
+ const input = inputRef.current
471
+ if (!input) return
472
+ const end = input.value.length
473
+ input.setSelectionRange(end, end)
474
+ }
475
+ const showAll = () => {
476
+ setTyping(false)
477
+ setOpen(true)
478
+ const all = roomNameOptions(valueRef.current, sections, false).options
479
+ const here = baseRoomName(valueRef.current).toLowerCase()
480
+ scrollToActive.current = true
481
+ setActive(Math.max(0, all.findIndex((option) => option.value.toLowerCase() === here)))
482
+ }
483
+ const type = (next: string) => {
484
+ valueRef.current = next
485
+ setValue(next)
486
+ }
487
+ const commit = () => {
488
+ const next = valueRef.current
489
+ if (next === committedRef.current) return
490
+ committedRef.current = next
491
+ renameRoom(zoneId, next)
492
+ }
493
+ const pick = (option: RoomNameOption) => {
494
+ const next =
495
+ option.kind === 'typed' ? option.value : numberedRoomName(nodes, zoneId, option.value)
496
+ type(next)
497
+ commit()
498
+ setOpen(false)
499
+ setTyping(false)
500
+ // A pick is the answer: the field lets go. To fine-tune the name, click it
501
+ // again — the caret lands at the end.
502
+ inputRef.current?.blur()
503
+ }
504
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
505
+ const count = options.length
506
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
507
+ event.preventDefault()
508
+ event.stopPropagation()
509
+ if (!expanded) {
510
+ showAll()
511
+ return
512
+ }
513
+ if (!count) return
514
+ const down = event.key === 'ArrowDown'
515
+ scrollToActive.current = true
516
+ setActive((index) => (down ? (index + 1) % count : index <= 0 ? count - 1 : index - 1))
517
+ } else if (event.key === 'Enter') {
518
+ event.preventDefault()
519
+ event.stopPropagation()
520
+ const chosen = expanded ? options[active] : undefined
521
+ if (chosen) pick(chosen)
522
+ else inputRef.current?.blur()
523
+ } else if (event.key === 'Escape') {
524
+ event.preventDefault()
525
+ event.stopPropagation()
526
+ if (expanded) {
527
+ type(originalRef.current)
528
+ setOpen(false)
529
+ setTyping(false)
530
+ requestAnimationFrame(() => inputRef.current?.select())
531
+ } else inputRef.current?.blur()
532
+ } else if (event.key === 'Tab') setOpen(false)
533
+ }
534
+
535
+ const renderOption = (option: RoomNameOption, index: number) => {
536
+ const isCurrent = option.kind === 'suggestion' && option.value.toLowerCase() === current
537
+ const used = option.kind === 'suggestion' ? roomNameUseCount(nodes, zoneId, option.value) : 0
538
+ return (
539
+ <div
540
+ aria-selected={index === active}
541
+ className={cn(
542
+ 'flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] text-foreground',
543
+ index === active && 'bg-accent',
544
+ option.kind === 'typed' && 'mb-0.5 rounded-b-none border-border border-b',
545
+ )}
546
+ data-room-name-option={option.value}
547
+ id={`${listId}-${index}`}
548
+ key={`${option.kind}:${index}:${option.value}`}
549
+ role="option"
550
+ tabIndex={-1}
551
+ onClick={() => pick(option)}
552
+ onMouseDown={(event) => event.preventDefault()}
553
+ onMouseMove={() => setActive(index)}
554
+ >
555
+ {option.kind === 'typed' && <Plus aria-hidden className="size-3.5 shrink-0" />}
556
+ <RoomNameOptionLabel option={option} />
557
+ {isCurrent ? (
558
+ <Check aria-hidden className="size-3.5 shrink-0" />
559
+ ) : used > 0 ? (
560
+ <span
561
+ className="shrink-0 whitespace-nowrap font-mono text-[11px] text-muted-foreground"
562
+ title="Picking it adds a number"
563
+ >
564
+ {used} on this level
565
+ </span>
566
+ ) : null}
567
+ </div>
568
+ )
569
+ }
570
+
571
+ return (
572
+ <Popover open={expanded} onOpenChange={setOpen}>
573
+ <PopoverAnchor asChild>
574
+ {/* A stable hook for host-app onboarding to point at; nothing here reads it. */}
575
+ <div
576
+ className={cn(
577
+ '-mx-1.5 flex min-w-0 flex-1 cursor-text items-center gap-1 rounded-md border border-transparent py-0.5 pr-0.5 pl-1.5 transition-colors hover:bg-accent/50',
578
+ (expanded || open) && 'border-ring/60 bg-accent/40',
579
+ 'focus-within:border-ring/60 focus-within:bg-accent/40',
580
+ )}
581
+ data-guide-target="room-name"
582
+ data-room-name-combobox
583
+ title={name.trim() ? `${name.trim()} · Rename` : 'Rename'}
584
+ >
585
+ <input
586
+ aria-activedescendant={activeId}
587
+ aria-autocomplete="list"
588
+ aria-controls={expanded ? listId : undefined}
589
+ aria-expanded={expanded}
590
+ aria-label={mezzanine ? 'Mezzanine name' : 'Room name'}
591
+ autoComplete="off"
592
+ className="min-w-0 flex-1 text-ellipsis bg-transparent p-0 font-semibold text-foreground text-sm leading-snug tracking-tight outline-none placeholder:text-foreground"
593
+ data-room-name
594
+ maxLength={40}
595
+ placeholder={mezzanine ? 'Mezzanine' : 'Room'}
596
+ ref={inputRef}
597
+ role="combobox"
598
+ spellCheck={false}
599
+ type="text"
600
+ value={value}
601
+ onBlur={() => {
602
+ setOpen(false)
603
+ setTyping(false)
604
+ commit()
605
+ }}
606
+ onChange={(event) => {
607
+ type(event.target.value)
608
+ setTyping(true)
609
+ setOpen(true)
610
+ const next = roomNameOptions(event.target.value, sections, true)
611
+ scrollToActive.current = true
612
+ setActive(next.typed && next.options.length > 1 ? 1 : 0)
613
+ }}
614
+ onFocus={() => {
615
+ originalRef.current = valueRef.current
616
+ showAll()
617
+ }}
618
+ onKeyDown={onKeyDown}
619
+ onMouseUp={(event) => {
620
+ // The click that focused the field lands the caret at the end;
621
+ // a click in an already-focused field reopens the whole list.
622
+ if (event.detail === 1 && !open) showAll()
623
+ if (!typing) caretToEnd()
624
+ }}
625
+ />
626
+ <button
627
+ aria-label={mezzanine ? 'Show mezzanine names' : 'Show room names'}
628
+ className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
629
+ data-room-name-open
630
+ tabIndex={-1}
631
+ type="button"
632
+ onMouseDown={(event) => {
633
+ event.preventDefault()
634
+ const input = inputRef.current
635
+ if (!input) return
636
+ if (expanded) setOpen(false)
637
+ else if (document.activeElement === input) showAll()
638
+ else input.focus()
639
+ }}
640
+ >
641
+ <ChevronDown
642
+ aria-hidden
643
+ className={cn('size-3 transition-transform', expanded && 'rotate-180')}
644
+ />
645
+ </button>
646
+ </div>
647
+ </PopoverAnchor>
648
+ <PopoverContent
649
+ align="start"
650
+ aria-label={mezzanine ? 'Mezzanine names' : 'Room names'}
651
+ className="flex max-h-85 w-(--radix-popover-trigger-width) min-w-60 flex-col overflow-hidden p-0"
652
+ id={listId}
653
+ role="listbox"
654
+ onCloseAutoFocus={(event) => event.preventDefault()}
655
+ // Escape belongs to the field: it reverts the name (the popover's own
656
+ // dismiss would close the list first and turn it into a commit).
657
+ onEscapeKeyDown={(event) => event.preventDefault()}
658
+ onInteractOutside={(event) => {
659
+ if (event.target instanceof Node && inputRef.current?.parentElement?.contains(event.target)) {
660
+ event.preventDefault()
661
+ }
662
+ }}
663
+ onOpenAutoFocus={(event) => event.preventDefault()}
664
+ // React events bubble out of the portal into the panel header, whose
665
+ // pointerdown starts a panel drag and captures the pointer — the
666
+ // release would then land on the header instead of the picked row.
667
+ onPointerDown={(event) => event.stopPropagation()}
668
+ >
669
+ <div className="no-scrollbar min-h-0 flex-1 overflow-y-auto px-1.5 pt-1.5 pb-1">
670
+ {typedRow && renderOption(typedRow, 0)}
671
+ {groups.map((group) => {
672
+ const first = options.indexOf(group.options[0]!)
673
+ return (
674
+ <div aria-labelledby={`${listId}-${group.id}`} key={group.id} role="group">
675
+ <div
676
+ className="px-2.5 pt-2.5 pb-1 text-[11px] text-muted-foreground"
677
+ id={`${listId}-${group.id}`}
678
+ role="presentation"
679
+ >
680
+ {group.title}
681
+ </div>
682
+ {group.options.map((option, offset) => renderOption(option, first + offset))}
683
+ </div>
684
+ )
685
+ })}
686
+ </div>
687
+ <div className="flex shrink-0 flex-wrap gap-x-3 gap-y-1 border-border border-t px-3 py-2 text-[11px] text-muted-foreground">
688
+ {(
689
+ [
690
+ [['↑', '↓'], 'Browse'],
691
+ [['Enter'], 'Pick'],
692
+ [['Esc'], 'Revert'],
693
+ ] as const
694
+ ).map(([keys, action]) => (
695
+ <span className="flex items-center gap-1" key={action}>
696
+ {keys.map((key) => (
697
+ <ShortcutToken className="h-4 min-w-4 justify-center px-1 text-[9.5px]" key={key} value={key} />
698
+ ))}
699
+ {action}
700
+ </span>
701
+ ))}
702
+ </div>
703
+ </PopoverContent>
704
+ </Popover>
705
+ )
706
+ }
707
+
708
+ function RoomNameOptionLabel({ option }: { option: RoomNameOption }) {
709
+ if (option.kind === 'typed') {
710
+ return (
711
+ <span className="min-w-0 flex-1 truncate">
712
+ <span className="text-muted-foreground">Use </span>“{option.value}”
713
+ </span>
714
+ )
715
+ }
716
+ const { value, match } = option
717
+ if (!match) return <span className="min-w-0 flex-1 truncate">{value}</span>
718
+ return (
719
+ <span className="min-w-0 flex-1 truncate">
720
+ {value.slice(0, match.start)}
721
+ <span className="font-semibold underline decoration-muted-foreground underline-offset-3">{value.slice(match.start, match.end)}</span>
722
+ {value.slice(match.end)}
723
+ </span>
724
+ )
725
+ }