@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
@@ -5,6 +5,7 @@ import {
5
5
  nodeRegistry,
6
6
  pauseSpaceDetection,
7
7
  resumeSpaceDetection,
8
+ toggleNodeMechanism,
8
9
  useScene,
9
10
  } from '@pascal-app/core'
10
11
  import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
@@ -23,16 +24,20 @@ import {
23
24
  rotateGroupPatches,
24
25
  } from '../components/editor/group-transform-shared'
25
26
  import { steppedRotation } from '../components/tools/item/placement-math'
27
+ import { keyCyclableContinuationContext } from '../lib/continuation'
26
28
  import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
27
- import { toggleDoorOpenState } from '../lib/door-interaction'
29
+ import { cancelGestures } from '../lib/gesture-lifecycle'
28
30
  import { guideEmitter } from '../lib/guide-events'
29
31
  import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history'
30
32
  import { isActive } from '../lib/interaction/scope'
33
+ import { paintRegionModeActive } from '../lib/paint-region-mode'
34
+ import { popRoomSelection } from '../lib/room-selection-commands'
31
35
  import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
32
36
  import { sfxEmitter } from '../lib/sfx-bus'
37
+ import { openSidebarPanel } from '../lib/sidebar-panel'
33
38
  import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
34
39
  import { leaveUnitFocus } from '../lib/units'
35
- import { toggleWindowOpenState } from '../lib/window-interaction'
40
+ import { selectWallDrawVariant } from '../lib/wall-draw-variant'
36
41
  import useDeleteConfirmation from '../store/use-delete-confirmation'
37
42
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
38
43
  import useInteractionScope, {
@@ -108,9 +113,13 @@ export const markToolCancelConsumed = () => {
108
113
  // preset/item placement rely on this — they pass no coordinator onCancel, and
109
114
  // it is the mode switch unmounting them that destroys the draft.
110
115
  const exitToSelectAfterUnconsumedCancel = () => {
111
- const currentPhase = useEditor.getState().phase
112
- const currentStructureLayer = useEditor.getState().structureLayer
113
-
116
+ const editor = useEditor.getState()
117
+ if (
118
+ editor.mode === 'select' &&
119
+ useInteractionScope.getState().scope.kind === 'idle' &&
120
+ popRoomSelection()
121
+ )
122
+ return
114
123
  useInteractionScope
115
124
  .getState()
116
125
  .endIf(
@@ -119,14 +128,8 @@ const exitToSelectAfterUnconsumedCancel = () => {
119
128
  (sc.kind === 'reshaping' && sc.reshape === 'hole'),
120
129
  )
121
130
 
122
- // From zone mode, return to structure select
123
- if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
124
- useEditor.getState().setStructureLayer('elements')
125
- useEditor.getState().armToolMode({ mode: 'select' })
126
- } else {
127
- // Return to the default select tool while keeping the active building/level context.
128
- useEditor.getState().armToolMode({ mode: 'select' })
129
- }
131
+ // Back to the select tool, keeping the active building/level context.
132
+ useEditor.getState().armToolMode({ mode: 'select' })
130
133
 
131
134
  useEditor.getState().setFloorplanSelectionTool('click')
132
135
 
@@ -160,6 +163,11 @@ const cancelInteractionForHistoryShortcut = () => {
160
163
  guideEmitter.emit('guide:cancel-reference-scale')
161
164
  return true
162
165
  }
166
+ // A live gesture reads ⌘Z as "abort", never as a jump under the pointer.
167
+ if (cancelGestures('history')) {
168
+ cancelPerfAction()
169
+ return true
170
+ }
163
171
  const activeScope = useInteractionScope.getState().scope
164
172
  if (shouldCancelDraftOnHistoryJump()) return false
165
173
  if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false
@@ -191,8 +199,42 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
191
199
  return true
192
200
  }
193
201
 
194
- /** Whether an armed tool owns the rotation key (`R` or `T`) instead of the selection. */
195
- export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
202
+ /**
203
+ * B: the wall tool on Rectangle — the quickest room — with the Build panel
204
+ * showing, so the lit tile says what is in hand. The panel's tiles still pick
205
+ * another shape for the session. The panel opens before the tool is armed: a
206
+ * host may drop the armed tool when the panel changes.
207
+ */
208
+ export const armWallToolFromShortcut = () => {
209
+ selectWallDrawVariant('rectangle')
210
+ openSidebarPanel(['build'])
211
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
212
+ }
213
+
214
+ /** 1: site, 2: the building — each back on the select tool. Returns whether `key` was one. */
215
+ export const switchPhaseFromShortcut = (key: string): boolean => {
216
+ const phase = key === '1' ? 'site' : key === '2' ? 'building' : null
217
+ if (!phase) return false
218
+ useEditor.getState().setPhase(phase)
219
+ useEditor.getState().armToolMode({ mode: 'select' })
220
+ return true
221
+ }
222
+
223
+ /** F: the item tool with the items catalog showing, opened first as B's is. */
224
+ export const openItemCatalogFromShortcut = () => {
225
+ openSidebarPanel(['items'])
226
+ useEditor.getState().setActiveSidebarPanel('items')
227
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
228
+ }
229
+
230
+ /** P: paint mode, with the Paint panel showing (the Build panel where a host has no Paint tab). */
231
+ export const armPaintFromShortcut = () => {
232
+ useEditor.getState().armMaterialPaint()
233
+ openSidebarPanel(['paint', 'build'])
234
+ }
235
+
236
+ /** Whether an armed tool owns the rotation keys (`R` / `T`) instead of the selection. */
237
+ export const isToolOwnedRotation = () => {
196
238
  const editor = useEditor.getState()
197
239
  const moving = getMovingNode()
198
240
  if (
@@ -212,10 +254,7 @@ export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
212
254
  // exist. Without this check, selecting an existing item in the 2D plan
213
255
  // while the item tool is armed silently drops the global rotate key.
214
256
  (editor.tool === 'item' && editor.selectedItem !== null) ||
215
- editor.tool === 'lean-to-extension' ||
216
- // R toggles the wall tool between line and rectangle drawing; T stays
217
- // the selection's.
218
- (editor.tool === 'wall' && key === 'r'))
257
+ editor.tool === 'lean-to-extension')
219
258
  )
220
259
  }
221
260
 
@@ -238,6 +277,16 @@ export function blocksSnappingShortcut(
238
277
  return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
239
278
  }
240
279
 
280
+ /** E on one selected node: the kind's own E action, else its mechanism. False when neither applies. */
281
+ export function runNodeInteraction(node: AnyNode): boolean {
282
+ const action = nodeRegistry.get(node.type)?.keyboardActions?.e
283
+ if (action?.appliesTo(node)) {
284
+ action.run(node)
285
+ return true
286
+ }
287
+ return toggleNodeMechanism(node)
288
+ }
289
+
241
290
  export const useKeyboard = ({
242
291
  isVersionPreviewMode = false,
243
292
  disabled = false,
@@ -309,7 +358,12 @@ export const useKeyboard = ({
309
358
  return
310
359
  }
311
360
 
312
- if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
361
+ if (
362
+ e.key === 'Shift' &&
363
+ !e.repeat &&
364
+ useEditor.getState().mode === 'material-paint' &&
365
+ !paintRegionModeActive('material-paint')
366
+ ) {
313
367
  // In paint mode Shift cycles the application scope (this surface →
314
368
  // whole item / all matching / room) — the paint-mode analogue of the
315
369
  // snapping-mode cycle below. The scope chip mirrors this key.
@@ -361,7 +415,10 @@ export const useKeyboard = ({
361
415
  const current = useEditor.getState().getContinuation('fence')
362
416
  useEditor
363
417
  .getState()
364
- .setContinuation('fence', current === 'curved' ? 'continuous' : 'curved')
418
+ .setContinuation(
419
+ 'fence',
420
+ current === 'curved' ? 'freehand' : current === 'freehand' ? 'continuous' : 'curved',
421
+ )
365
422
  sfxEmitter.emit('sfx:grid-snap')
366
423
  return
367
424
  }
@@ -375,12 +432,13 @@ export const useKeyboard = ({
375
432
  !e.shiftKey &&
376
433
  !e.altKey
377
434
  ) {
378
- const context = getActiveContinuationContext()
435
+ // The wall's drawing mode is picked in the Build panel, not cycled here.
436
+ const context = keyCyclableContinuationContext(getActiveContinuationContext())
379
437
  if (context) {
380
438
  e.preventDefault()
381
439
  if (context === 'fence') {
382
440
  const current = useEditor.getState().getContinuation('fence')
383
- if (current !== 'curved') {
441
+ if (current !== 'curved' && current !== 'freehand') {
384
442
  useEditor
385
443
  .getState()
386
444
  .setContinuation('fence', current === 'single' ? 'continuous' : 'single')
@@ -407,37 +465,21 @@ export const useKeyboard = ({
407
465
  // Only switch to select mode if no tool had an active mid-action to cancel.
408
466
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
409
467
  cancelActiveTool()
410
- } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
411
- e.preventDefault()
412
- useEditor.getState().setPhase('site')
413
- useEditor.getState().armToolMode({ mode: 'select' })
414
- } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
468
+ } else if (!e.metaKey && !e.ctrlKey && switchPhaseFromShortcut(e.key)) {
415
469
  e.preventDefault()
416
- useEditor.getState().setPhase('structure')
417
- useEditor.getState().armToolMode({ mode: 'select' })
418
- } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
419
- e.preventDefault()
420
- useEditor.getState().setPhase('furnish')
421
- useEditor.getState().armToolMode({ mode: 'select' })
422
470
  } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
423
471
  if (isVersionPreviewMode) return
424
472
  if (isToolOwnedCanopyForm()) return
425
473
  e.preventDefault()
426
- useEditor.getState().setPhase('furnish')
427
- useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
428
- useEditor.getState().setActiveSidebarPanel('items')
474
+ openItemCatalogFromShortcut()
429
475
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
430
476
  if (isVersionPreviewMode) return
431
477
  e.preventDefault()
432
- useEditor.getState().setPhase('structure')
433
- useEditor.getState().setStructureLayer('zones')
434
478
  useEditor.getState().armToolMode({ mode: 'build', tool: 'zone' })
435
479
  } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
436
480
  if (isVersionPreviewMode) return
437
481
  e.preventDefault()
438
482
  const editor = useEditor.getState()
439
- editor.setPhase('structure')
440
- editor.setStructureLayer('elements')
441
483
  editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
442
484
  editor.armToolMode({ mode: 'build', tool: 'measurement' })
443
485
  }
@@ -448,9 +490,7 @@ export const useKeyboard = ({
448
490
  } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
449
491
  if (isVersionPreviewMode) return
450
492
  e.preventDefault()
451
- useEditor.getState().setPhase('structure')
452
- useEditor.getState().setStructureLayer('elements')
453
- useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
493
+ armWallToolFromShortcut()
454
494
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
455
495
  if (isVersionPreviewMode) return
456
496
  e.preventDefault()
@@ -458,9 +498,7 @@ export const useKeyboard = ({
458
498
  } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
459
499
  if (isVersionPreviewMode) return
460
500
  e.preventDefault()
461
- useEditor.getState().setPhase('structure')
462
- useEditor.getState().setStructureLayer('elements')
463
- useEditor.getState().armMaterialPaint()
501
+ armPaintFromShortcut()
464
502
  } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
465
503
  if (isVersionPreviewMode) return
466
504
  e.preventDefault()
@@ -632,7 +670,7 @@ export const useKeyboard = ({
632
670
  } else if (
633
671
  (e.key === 't' || e.key === 'T') &&
634
672
  !isVersionPreviewMode &&
635
- !isToolOwnedRotation('t') &&
673
+ !isToolOwnedRotation() &&
636
674
  canRunGlobalRotationShortcut()
637
675
  ) {
638
676
  // Rotate selected node counter-clockwise
@@ -692,34 +730,14 @@ export const useKeyboard = ({
692
730
  }
693
731
  }
694
732
  } else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
695
- // Toggle door / operable-window open/closed state. Moved off R,
696
- // which now flips the opening (side + π rotation).
733
+ // E runs the kind's interaction or mechanism (doors and windows open and
734
+ // close). Moved off R, which now flips the opening (side + π rotation).
697
735
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
698
736
  if (selectedNodeIds.length === 1) {
699
737
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
700
- const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e
701
- if (node && registryE?.appliesTo(node)) {
738
+ if (node && runNodeInteraction(node)) {
702
739
  // Registry-driven E interaction. Same shape as the R/T arms.
703
740
  e.preventDefault()
704
- registryE.run(node)
705
- sfxEmitter.emit('sfx:item-rotate')
706
- } else if (node?.type === 'door' && node.openingKind !== 'opening') {
707
- e.preventDefault()
708
- toggleDoorOpenState(node.id)
709
- sfxEmitter.emit('sfx:item-rotate')
710
- } else if (
711
- node?.type === 'window' &&
712
- node.openingKind !== 'opening' &&
713
- (node.windowType === 'sliding' ||
714
- node.windowType === 'casement' ||
715
- node.windowType === 'awning' ||
716
- node.windowType === 'hopper' ||
717
- node.windowType === 'single-hung' ||
718
- node.windowType === 'double-hung' ||
719
- node.windowType === 'louvered')
720
- ) {
721
- e.preventDefault()
722
- toggleWindowOpenState(node.id)
723
741
  sfxEmitter.emit('sfx:item-rotate')
724
742
  }
725
743
  }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, DoorNode, type OpenWallEnd, WallNode } from '@pascal-app/core'
3
+ import { analyseOpenWallEnds } from './use-open-wall-ends'
4
+
5
+ const wall = (id: string, start: [number, number], end: [number, number]) =>
6
+ ({
7
+ id,
8
+ type: 'wall',
9
+ object: 'node',
10
+ parentId: 'level_a',
11
+ visible: true,
12
+ children: [],
13
+ metadata: {},
14
+ start,
15
+ end,
16
+ thickness: 0.1,
17
+ }) as unknown as WallNode
18
+
19
+ // A 4 x 4 room whose last corner is left 4 cm open (bodies are 0.1 thick).
20
+ const walls = () => [
21
+ wall('wall_1', [0, 0], [4, 0]),
22
+ wall('wall_2', [4, 0], [4, 4]),
23
+ wall('wall_3', [4, 4], [0, 4]),
24
+ wall('wall_4', [0, 4], [0, 0.09]),
25
+ ]
26
+
27
+ describe('open wall end analysis shared by the floor plan and 3D view', () => {
28
+ test('finds the open corner', () => {
29
+ const ends = analyseOpenWallEnds('level_a', walls())
30
+ expect(ends.some((end) => end.wallId === 'wall_4' && end.end === 'end')).toBe(true)
31
+ })
32
+
33
+ test('two views reading the same walls get one result', () => {
34
+ const shared = walls()
35
+ const first = analyseOpenWallEnds('level_a', [...shared])
36
+ expect(analyseOpenWallEnds('level_a', [...shared])).toBe(first)
37
+ })
38
+
39
+ test('a changed wall is analysed again', () => {
40
+ const shared = walls()
41
+ const first = analyseOpenWallEnds('level_a', shared)
42
+ const closed = [...shared.slice(0, 3), wall('wall_4', [0, 4], [0, 0])]
43
+ const next = analyseOpenWallEnds('level_a', closed)
44
+ expect(next).not.toBe(first)
45
+ expect(next.some((end) => end.wallId === 'wall_4')).toBe(false)
46
+ })
47
+ })
48
+
49
+ describe('the open-end preview reads what the walls host', () => {
50
+ // An 88° near-miss T: the source wall stops 9 cm short of the long target wall.
51
+ const level = 'level_t'
52
+ const tWall = (id: string, start: [number, number], end: [number, number], thickness = 0.1) =>
53
+ WallNode.parse({ id, parentId: level, start, end, thickness })
54
+ const scene = () => {
55
+ const tip: [number, number] = [2 + (3 - 0.09) / Math.tan((88 * Math.PI) / 180), 0.09]
56
+ const source = tWall('wall_source', [2, 3], tip, 0.01)
57
+ return {
58
+ source,
59
+ walls: [
60
+ source,
61
+ tWall('wall_target', [-20, 0], [30, 0], 0.01),
62
+ tWall('wall_anchor', [2, 3], [-20, 3]),
63
+ tWall('wall_left', [-20, 3], [-20, 0]),
64
+ ],
65
+ }
66
+ }
67
+ const sourceEnd = (ends: OpenWallEnd[]) =>
68
+ ends.find((end) => end.wallId === 'wall_source' && end.end === 'end')
69
+
70
+ test('without an opening the preview squares the corner', () => {
71
+ const point = sourceEnd(analyseOpenWallEnds(level, scene().walls))?.candidate?.point
72
+ expect(point?.[0]).toBeCloseTo(2, 6)
73
+ expect(point?.[1]).toBeCloseTo(0, 6)
74
+ })
75
+
76
+ test('a door flush with the anchored end keeps the join straight, and only the door changing re-runs it', () => {
77
+ const { source, walls } = scene()
78
+ const door = DoorNode.parse({
79
+ id: 'door_source',
80
+ parentId: source.id,
81
+ wallId: source.id,
82
+ position: [0.4, 1.05, 0],
83
+ width: 0.8,
84
+ })
85
+ const hosted: AnyNode[] = [
86
+ { ...source, children: [door.id] } as AnyNode,
87
+ ...walls.slice(1),
88
+ door as AnyNode,
89
+ ]
90
+ const first = analyseOpenWallEnds(level, hosted)
91
+ const point = sourceEnd(first)?.candidate?.point
92
+ expect(point?.[0]).toBeCloseTo(2 + 3 / Math.tan((88 * Math.PI) / 180), 6)
93
+ expect(point?.[1]).toBeCloseTo(0, 6)
94
+
95
+ const narrower = { ...door, width: 0.6 } as AnyNode
96
+ const next = analyseOpenWallEnds(level, [...hosted.slice(0, -1), narrower])
97
+ expect(next).not.toBe(first)
98
+ })
99
+ })
@@ -0,0 +1,238 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, findOpenWallEnds, type OpenWallEnd, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useEffect, useMemo, useRef, useState } from 'react'
6
+ import { useShallow } from 'zustand/react/shallow'
7
+ import {
8
+ collapseMutualOpenWallEnds,
9
+ joinOpenWallEnd,
10
+ openWallEndKey,
11
+ visibleOpenWallEnds,
12
+ } from '../lib/floorplan/open-wall-ends'
13
+ import { sfxEmitter } from '../lib/sfx-bus'
14
+ import useEditor from '../store/use-editor'
15
+ import { useFloorplanDraftPreview } from '../store/use-floorplan-draft-preview'
16
+ import useInteractionScope from '../store/use-interaction-scope'
17
+
18
+ type Boundary = Extract<AnyNode, { type: 'wall' | 'separator' }>
19
+
20
+ const NO_ENDS: OpenWallEnd[] = []
21
+
22
+ const isBoundary = (node: AnyNode): node is Boundary =>
23
+ node.type === 'wall' || node.type === 'separator'
24
+
25
+ let lastAnalysis: {
26
+ levelId: string
27
+ nodes: readonly AnyNode[]
28
+ ends: OpenWallEnd[]
29
+ } | null = null
30
+
31
+ /**
32
+ * The room-graph pass behind the open-end markers, shared by the floor plan
33
+ * and the 3D view: split view mounts both, and they must not pay for it twice.
34
+ * `nodes` is the level's walls and separators plus what the walls host: an
35
+ * opening can stop core squaring a corner, and the preview must land where
36
+ * Join walls will. Recomputed only when one of those nodes changes (compared
37
+ * by reference), never per frame or on unrelated edits.
38
+ */
39
+ export function analyseOpenWallEnds(levelId: string, nodes: readonly AnyNode[]): OpenWallEnd[] {
40
+ const last = lastAnalysis
41
+ if (
42
+ last &&
43
+ last.levelId === levelId &&
44
+ last.nodes.length === nodes.length &&
45
+ last.nodes.every((node, index) => node === nodes[index])
46
+ ) {
47
+ return last.ends
48
+ }
49
+ const ends = findOpenWallEnds(Object.fromEntries(nodes.map((node) => [node.id, node])), levelId)
50
+ lastAnalysis = { levelId, nodes, ends }
51
+ return ends
52
+ }
53
+
54
+ /** Moving, placing, reshaping or dragging a handle: the markers step aside. */
55
+ export function useOpenWallEndsSuppressed(): boolean {
56
+ return useInteractionScope(
57
+ (state) =>
58
+ state.scope.kind === 'moving' ||
59
+ state.scope.kind === 'placing' ||
60
+ state.scope.kind === 'reshaping' ||
61
+ state.scope.kind === 'handle-drag',
62
+ )
63
+ }
64
+
65
+ export type OpenWallEndsState = {
66
+ /** The wall tool (any variant) or Divide is active. */
67
+ drawing: boolean
68
+ /** A wall or divide segment is mid-draft: markers stay visible but out of the way. */
69
+ drafting: boolean
70
+ /** Ends to mark, mutual pairs collapsed to the end that slides straight. */
71
+ ends: OpenWallEnd[]
72
+ /** Walls on the active level that bound no room. */
73
+ roomlessWallIds: string[]
74
+ /** Whether the overlay is up at all (drawing, or a near miss to show). */
75
+ shown: boolean
76
+ }
77
+
78
+ /**
79
+ * Open wall ends on the active level and when to show them. On while walls or
80
+ * rooms are being drawn; otherwise only when a wall that bounds no room has a
81
+ * near miss.
82
+ */
83
+ export function useOpenWallEnds(): OpenWallEndsState {
84
+ const levelId = useViewer((state) => state.selection.levelId)
85
+ const mode = useEditor((state) => state.mode)
86
+ const tool = useEditor((state) => state.tool)
87
+ const spaces = useEditor(
88
+ useShallow((state) => Object.values(state.spaces).filter((space) => space.levelId === levelId)),
89
+ )
90
+ const isDividing = useInteractionScope((state) => state.scope.kind === 'room-divide')
91
+ const dividingDraft = useInteractionScope(
92
+ (state) => state.scope.kind === 'room-divide' && state.scope.points.length > 0,
93
+ )
94
+ const wallDrafting = useFloorplanDraftPreview(
95
+ (state) =>
96
+ state.wallDraftStart !== null ||
97
+ state.wallRectangleDraftStart !== null ||
98
+ state.wallPolygonDraftPoints.length > 0,
99
+ )
100
+ // Walls and separators first, then every node a wall hosts — one flat list,
101
+ // so a door moving re-runs the analysis but an unrelated item edit does not.
102
+ const analysisNodes = useScene(
103
+ useShallow((state) => {
104
+ const level = levelId ? state.nodes[levelId as AnyNode['id']] : undefined
105
+ if (level?.type !== 'level') return [] as AnyNode[]
106
+ const boundaries = level.children
107
+ .map((id) => state.nodes[id])
108
+ .filter((node): node is Boundary => !!node && isBoundary(node))
109
+ const hosted = boundaries.flatMap((node) =>
110
+ node.type === 'wall'
111
+ ? node.children
112
+ .map((id) => state.nodes[id])
113
+ .filter((child): child is AnyNode => child !== undefined)
114
+ : [],
115
+ )
116
+ return [...boundaries, ...hosted]
117
+ }),
118
+ )
119
+ const boundaries = useMemo(() => analysisNodes.filter(isBoundary), [analysisNodes])
120
+
121
+ const drawing = (mode === 'build' && tool === 'wall') || isDividing
122
+ const roomWallIds = useMemo(() => {
123
+ const ids = new Set<string>()
124
+ for (const space of spaces) for (const wallId of space.wallIds) ids.add(wallId)
125
+ return ids
126
+ }, [spaces])
127
+ const roomlessWallIds = useMemo(
128
+ () =>
129
+ boundaries
130
+ .filter((node) => node.type === 'wall' && !roomWallIds.has(node.id))
131
+ .map((node) => node.id as string),
132
+ [boundaries, roomWallIds],
133
+ )
134
+ const hasWalls = boundaries.some((node) => node.type === 'wall')
135
+ const shouldAnalyse = !!levelId && hasWalls && (drawing || roomlessWallIds.length > 0)
136
+ const openEnds = useMemo(
137
+ () => (shouldAnalyse && levelId ? analyseOpenWallEnds(levelId, analysisNodes) : NO_ENDS),
138
+ [shouldAnalyse, levelId, analysisNodes],
139
+ )
140
+ const ends = useMemo(() => {
141
+ const walls = new Map(boundaries.map((node) => [node.id as string, node]))
142
+ return collapseMutualOpenWallEnds(
143
+ visibleOpenWallEnds(openEnds, roomWallIds, drawing),
144
+ (wallId) => {
145
+ const wall = walls.get(wallId)
146
+ return wall?.type === 'wall'
147
+ ? [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]]
148
+ : null
149
+ },
150
+ )
151
+ }, [openEnds, roomWallIds, drawing, boundaries])
152
+
153
+ return {
154
+ drawing,
155
+ drafting: wallDrafting || dividingDraft,
156
+ ends,
157
+ roomlessWallIds,
158
+ shown: drawing || ends.length > 0,
159
+ }
160
+ }
161
+
162
+ const HOVER_RELEASE_MS = 250
163
+
164
+ function clearReleaseTimer(timer: { current: ReturnType<typeof setTimeout> | null }) {
165
+ if (timer.current) clearTimeout(timer.current)
166
+ timer.current = null
167
+ }
168
+
169
+ /**
170
+ * Hover / pin / join for one view's markers — the floor plan and the 3D view
171
+ * each keep their own, so hovering a dot in one does not pop a pill in the
172
+ * other. A pinned pill closes on Escape or a press outside any
173
+ * `[data-open-wall-end]` element.
174
+ */
175
+ export function useOpenWallEndFocus(ends: readonly OpenWallEnd[], drafting: boolean) {
176
+ const [hoveredKey, setHoveredKey] = useState<string | null>(null)
177
+ const [pinnedKey, setPinnedKey] = useState<string | null>(null)
178
+ const [refusal, setRefusal] = useState<{ key: string; message: string } | null>(null)
179
+ const releaseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
180
+
181
+ const clearTimer = () => clearReleaseTimer(releaseTimer)
182
+ useEffect(() => () => clearReleaseTimer(releaseTimer), [])
183
+
184
+ const activeKey = pinnedKey ?? hoveredKey
185
+ const activeEnd = drafting
186
+ ? null
187
+ : (ends.find((end) => end.candidate && openWallEndKey(end) === activeKey) ?? null)
188
+
189
+ useEffect(() => {
190
+ if (!pinnedKey) return
191
+ const onPointerDown = (event: PointerEvent) => {
192
+ const target = event.target instanceof Element ? event.target : null
193
+ if (target?.closest('[data-open-wall-end]')) return
194
+ setPinnedKey(null)
195
+ }
196
+ window.addEventListener('pointerdown', onPointerDown, true)
197
+ return () => window.removeEventListener('pointerdown', onPointerDown, true)
198
+ }, [pinnedKey])
199
+
200
+ useEffect(() => {
201
+ if (!activeEnd) return
202
+ const onKeyDown = (event: KeyboardEvent) => {
203
+ if (event.key !== 'Escape') return
204
+ event.stopPropagation()
205
+ clearReleaseTimer(releaseTimer)
206
+ setPinnedKey(null)
207
+ setHoveredKey(null)
208
+ setRefusal(null)
209
+ }
210
+ window.addEventListener('keydown', onKeyDown, true)
211
+ return () => window.removeEventListener('keydown', onKeyDown, true)
212
+ }, [activeEnd])
213
+
214
+ return {
215
+ activeKey,
216
+ activeEnd,
217
+ refusal: activeEnd && refusal?.key === openWallEndKey(activeEnd) ? refusal.message : null,
218
+ keepHover: (key: string) => {
219
+ clearTimer()
220
+ setHoveredKey(key)
221
+ },
222
+ releaseHover: () => {
223
+ clearTimer()
224
+ releaseTimer.current = setTimeout(() => setHoveredKey(null), HOVER_RELEASE_MS)
225
+ },
226
+ togglePin: (key: string) => setPinnedKey((current) => (current === key ? null : key)),
227
+ pin: (key: string) => setPinnedKey(key),
228
+ join: (end: OpenWallEnd) => {
229
+ const key = openWallEndKey(end)
230
+ const message = joinOpenWallEnd(end)
231
+ setRefusal(message ? { key, message } : null)
232
+ if (message) return
233
+ sfxEmitter.emit('sfx:structure-build')
234
+ setPinnedKey(null)
235
+ setHoveredKey(null)
236
+ },
237
+ }
238
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, registerNode } from '@pascal-app/core'
3
+ import { placementNoticeOf, transientDraftId } from './use-placement-notice'
4
+
5
+ // The editor asks a node's definition for its placement notice; it names no kind.
6
+ function registerKind(placementNotice?: unknown): string {
7
+ const kind = `test:notice-${crypto.randomUUID()}`
8
+ registerNode({
9
+ kind,
10
+ schemaVersion: 1,
11
+ category: 'furnish',
12
+ defaults: () => ({}),
13
+ capabilities: {},
14
+ ...(placementNotice ? { placementNotice } : {}),
15
+ } as never)
16
+ return kind
17
+ }
18
+
19
+ const nodeOf = (id: string, type: string, metadata?: Record<string, unknown>) =>
20
+ ({ id, type, parentId: 'level_a', metadata: metadata ?? {} }) as unknown as AnyNode
21
+
22
+ describe('the placement notice of the node in hand', () => {
23
+ test("comes from the node's definition, at its live pose", () => {
24
+ const kind = registerKind((node: AnyNode, ctx: { live?: { position: number[] } }) => ({
25
+ line: `${node.id} at ${ctx.live?.position.join(',') ?? 'rest'}`,
26
+ }))
27
+ const nodes = { a: nodeOf('a', kind) }
28
+ expect(placementNoticeOf(nodes, 'a')).toEqual({ line: 'a at rest' })
29
+ expect(placementNoticeOf(nodes, 'a', { position: [1, 0, 2], rotation: 0 })).toEqual({
30
+ line: 'a at 1,0,2',
31
+ })
32
+ })
33
+
34
+ test('is null for a kind that declares none, and for a node not in the scene', () => {
35
+ const nodes = { b: nodeOf('b', registerKind()) }
36
+ expect(placementNoticeOf(nodes, 'b')).toBeNull()
37
+ expect(placementNoticeOf(nodes, 'missing')).toBeNull()
38
+ })
39
+
40
+ test("a tool's transient draft is found whatever its kind", () => {
41
+ const nodes = {
42
+ kept: nodeOf('kept', registerKind()),
43
+ draft: nodeOf('draft', registerKind(), { isTransient: true }),
44
+ }
45
+ expect(transientDraftId(nodes) as string | null).toBe('draft')
46
+ expect(transientDraftId({ kept: nodes.kept })).toBeNull()
47
+ })
48
+ })