@pascal-app/editor 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -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
+ })
@@ -0,0 +1,42 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type LiveTransformLike,
5
+ nodeRegistry,
6
+ type PlacementNotice,
7
+ useLiveTransforms,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useMemo } from 'react'
11
+
12
+ /** The notice `nodeId`'s kind gives for where it stands (`placementNotice`), if it declares one. */
13
+ export function placementNoticeOf(
14
+ nodes: Readonly<Record<string, AnyNode>>,
15
+ nodeId: string,
16
+ live?: LiveTransformLike,
17
+ ): PlacementNotice | null {
18
+ const node = nodes[nodeId as AnyNodeId]
19
+ const notice = node ? nodeRegistry.get(node.type)?.placementNotice : undefined
20
+ if (!(node && notice)) return null
21
+ return notice(node as never, live ? { nodes, live } : { nodes })
22
+ }
23
+
24
+ /**
25
+ * What the node in hand, or the selected node, says about where it stands, read at its live pose
26
+ * while it is placed or moved. Subscribes to the scene only while a node is given, so an
27
+ * always-mounted caller doesn't re-render on every write.
28
+ */
29
+ export function usePlacementNotice(nodeId: string | null | undefined): PlacementNotice | null {
30
+ const nodes = useScene((state) => (nodeId ? state.nodes : null))
31
+ const live = useLiveTransforms((state) => (nodeId ? state.get(nodeId) : undefined))
32
+ return useMemo(
33
+ () => (nodeId && nodes ? placementNoticeOf(nodes, nodeId, live) : null),
34
+ [nodes, live, nodeId],
35
+ )
36
+ }
37
+
38
+ /** The node a construction tool is placing: its transient draft. */
39
+ export const transientDraftId = (nodes: Readonly<Record<string, AnyNode>>) =>
40
+ Object.values(nodes).find(
41
+ (node) => !!(node.metadata as { isTransient?: boolean } | null)?.isTransient,
42
+ )?.id ?? null
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useSyncExternalStore } from 'react'
6
+ import { selectionEnabled } from '../lib/interaction/scope'
7
+ import {
8
+ type RoomKey,
9
+ RoomSelectionIndex,
10
+ type RoomSelectionRecord,
11
+ resolveRoomHit,
12
+ } from '../lib/room-selection'
13
+ import { shouldInterceptRoom } from '../lib/room-selection-commands'
14
+ import { type SelectionModifierKeys, selectionModifiersFromEvent } from '../lib/selection-routing'
15
+ import useEditor from '../store/use-editor'
16
+ import useInteractionScope from '../store/use-interaction-scope'
17
+
18
+ const indexes = new Map<string, RoomSelectionIndex>()
19
+ const subscriptions = new Map<string, { listeners: Set<() => void>; dispose: () => void }>()
20
+ const EMPTY: RoomSelectionRecord[] = []
21
+
22
+ export function getRoomSelectionIndex(levelId: string) {
23
+ let index = indexes.get(levelId)
24
+ if (!index) {
25
+ index = new RoomSelectionIndex(levelId)
26
+ indexes.set(levelId, index)
27
+ }
28
+ index.update(useScene.getState().nodes)
29
+ return index
30
+ }
31
+
32
+ function subscribeLevel(levelId: string | null, listener: () => void) {
33
+ if (!levelId) return () => {}
34
+ let subscription = subscriptions.get(levelId)
35
+ if (!subscription) {
36
+ const listeners = new Set<() => void>()
37
+ const dispose = useScene.subscribe((state, previous) => {
38
+ if (state.nodes === previous.nodes) return
39
+ const rooms = getRoomSelectionIndex(levelId).update(state.nodes)
40
+ const editor = useEditor.getState()
41
+ if (
42
+ editor.room?.levelId === levelId &&
43
+ !rooms.some((room) => room.key.zoneId === editor.room?.zoneId)
44
+ )
45
+ editor.clearRoom()
46
+ for (const notify of listeners) notify()
47
+ })
48
+ subscription = { listeners, dispose }
49
+ subscriptions.set(levelId, subscription)
50
+ }
51
+ subscription.listeners.add(listener)
52
+ return () => {
53
+ subscription.listeners.delete(listener)
54
+ if (!subscription.listeners.size) {
55
+ subscription.dispose()
56
+ subscriptions.delete(levelId)
57
+ indexes.delete(levelId)
58
+ }
59
+ }
60
+ }
61
+
62
+ export function useRoomRecords(levelId: string | null) {
63
+ const subscribe = useCallback((notify: () => void) => subscribeLevel(levelId, notify), [levelId])
64
+ const snapshot = useCallback(
65
+ () => (levelId ? getRoomSelectionIndex(levelId).update(useScene.getState().nodes) : EMPTY),
66
+ [levelId],
67
+ )
68
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
69
+ }
70
+
71
+ export function useSelectedRoom() {
72
+ const key = useEditor((state) => state.room)
73
+ const rooms = useRoomRecords(key?.levelId ?? null)
74
+ return rooms.find((room) => room.key.zoneId === key?.zoneId) ?? null
75
+ }
76
+
77
+ /**
78
+ * The room a selected zone is, for the room panel: its record when the room
79
+ * index knows it, `{ record: null }` for a room the walls do not close, null
80
+ * for anything that is not a room.
81
+ */
82
+ export function useZoneRoom(zoneId: string | null): { record: RoomSelectionRecord | null } | null {
83
+ const levelId = useScene((state) => {
84
+ const zone = zoneId ? state.nodes[zoneId as AnyNodeId] : undefined
85
+ return zone?.type === 'zone' && zone.spaceRole === 'room' ? (zone.parentId ?? null) : null
86
+ })
87
+ const rooms = useRoomRecords(levelId)
88
+ if (!(zoneId && levelId)) return null
89
+ return { record: rooms.find((room) => room.key.zoneId === zoneId) ?? null }
90
+ }
91
+
92
+ export function useHighlightedRoom() {
93
+ const key = useEditor((state) => state.hoveredRoom ?? state.room)
94
+ const rooms = useRoomRecords(key?.levelId ?? null)
95
+ return rooms.find((room) => room.key.zoneId === key?.zoneId) ?? null
96
+ }
97
+
98
+ const hitPoint: [number, number] = [0, 0]
99
+
100
+ export function resolveEditorRoomHit(
101
+ node: AnyNode | null,
102
+ levelId: string,
103
+ point: readonly [number, number],
104
+ view: '2d' | '3d',
105
+ ) {
106
+ if (node && node.type !== 'wall' && node.type !== 'slab' && node.type !== 'ceiling') return null
107
+ const index = getRoomSelectionIndex(levelId)
108
+ hitPoint[0] = point[0]
109
+ hitPoint[1] = point[1]
110
+ const room = resolveRoomHit(index, levelId, node, hitPoint, view)
111
+ return room?.key ?? null
112
+ }
113
+
114
+ /** Rooms pick inside the building; from site a click picks the whole building. */
115
+ export function roomPickingEnabled() {
116
+ const editor = useEditor.getState()
117
+ return (
118
+ editor.phase !== 'site' &&
119
+ editor.mode === 'select' &&
120
+ !useViewer.getState().focusedUnitId &&
121
+ selectionEnabled(useInteractionScope.getState().scope)
122
+ )
123
+ }
124
+
125
+ export function hoverRoomFromHit(
126
+ hit: RoomKey | null,
127
+ modifiers: SelectionModifierKeys,
128
+ nodeId: string | null,
129
+ ) {
130
+ const editor = useEditor.getState()
131
+ const room = shouldInterceptRoom(hit, modifiers, nodeId) ? hit : null
132
+ editor.setHoveredRoom(room)
133
+ if (room) useViewer.getState().setHoveredId(null)
134
+ return !!room
135
+ }
136
+
137
+ export function resolvePlanRoomHit(nodeId: string | null, point: readonly [number, number]) {
138
+ const node = nodeId ? useScene.getState().nodes[nodeId as AnyNodeId] : null
139
+ const levelId = node?.parentId ?? useViewer.getState().selection.levelId
140
+ return levelId ? resolveEditorRoomHit(node ?? null, levelId, point, '2d') : null
141
+ }
142
+
143
+ export { selectionModifiersFromEvent }