@pascal-app/editor 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { Link2 } from 'lucide-react'
4
+
5
+ /**
6
+ * The "Join walls" action on an open wall end — shared by the floor plan and
7
+ * the 3D view so both offer the same pill. A refusal ("A door or window is in
8
+ * the way") sits in front of the button.
9
+ */
10
+ export function JoinWallsPill({
11
+ onHoverEnd,
12
+ onHoverStart,
13
+ onJoin,
14
+ refusal,
15
+ }: {
16
+ onHoverEnd: () => void
17
+ onHoverStart: () => void
18
+ onJoin: () => void
19
+ refusal: string | null
20
+ }) {
21
+ return (
22
+ <div
23
+ className="pointer-events-auto flex items-center gap-1 whitespace-nowrap rounded-full border border-border bg-background/95 p-1 text-xs shadow-xl backdrop-blur-md"
24
+ data-open-wall-end=""
25
+ onPointerDown={(event) => event.stopPropagation()}
26
+ onPointerEnter={onHoverStart}
27
+ onPointerLeave={onHoverEnd}
28
+ onPointerUp={(event) => event.stopPropagation()}
29
+ >
30
+ {refusal ? <span className="px-2 text-muted-foreground">{refusal}</span> : null}
31
+ <button
32
+ className="flex items-center gap-1.5 rounded-full px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-accent"
33
+ onClick={(event) => {
34
+ event.stopPropagation()
35
+ onJoin()
36
+ }}
37
+ type="button"
38
+ >
39
+ <Link2 className="h-3.5 w-3.5" />
40
+ Join walls
41
+ </button>
42
+ </div>
43
+ )
44
+ }
@@ -1,8 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
- import type { MouseEventHandler, PointerEventHandler } from 'react'
4
+ import {
5
+ Copy,
6
+ Group,
7
+ Move,
8
+ PencilRuler,
9
+ RotateCcw,
10
+ RotateCw,
11
+ Search,
12
+ Spline,
13
+ Trash2,
14
+ Ungroup,
15
+ } from 'lucide-react'
16
+ import type { MouseEventHandler, PointerEventHandler, ReactNode } from 'react'
17
+ import { ActionMenuButton } from './action-menu-button'
6
18
  import { RegistryActionContributions } from './registry-action-contributions'
7
19
 
8
20
  type NodeActionMenuProps = {
@@ -13,6 +25,9 @@ type NodeActionMenuProps = {
13
25
  onMove?: MouseEventHandler<HTMLButtonElement>
14
26
  onEditMesh?: MouseEventHandler<HTMLButtonElement>
15
27
  onCurve?: MouseEventHandler<HTMLButtonElement>
28
+ /** Quarter turns (a room); sit in the Curve slot (a room has no curve). */
29
+ onRotateLeft?: MouseEventHandler<HTMLButtonElement>
30
+ onRotateRight?: MouseEventHandler<HTMLButtonElement>
16
31
  /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */
17
32
  onGroup?: MouseEventHandler<HTMLButtonElement>
18
33
  /** Dissolve session group (Ctrl/Cmd+Shift+G). */
@@ -21,6 +36,16 @@ type NodeActionMenuProps = {
21
36
  onPointerUp?: PointerEventHandler<HTMLDivElement>
22
37
  onPointerEnter?: PointerEventHandler<HTMLDivElement>
23
38
  onPointerLeave?: PointerEventHandler<HTMLDivElement>
39
+ /**
40
+ * Buttons a caller contributes next to the registry contributions — for a
41
+ * selection that is not a scene-node selection (the room), whose actions the
42
+ * registry cannot find.
43
+ */
44
+ children?: ReactNode
45
+ /** Tooltip / accessible name of the delete button. */
46
+ deleteLabel?: string
47
+ /** Shows the delete button greyed out, its tooltip saying why. */
48
+ deleteDisabledReason?: string
24
49
  }
25
50
 
26
51
  export function NodeActionMenu({
@@ -31,12 +56,17 @@ export function NodeActionMenu({
31
56
  onMove,
32
57
  onEditMesh,
33
58
  onCurve,
59
+ onRotateLeft,
60
+ onRotateRight,
34
61
  onGroup,
35
62
  onUngroup,
36
63
  onPointerDown,
37
64
  onPointerUp,
38
65
  onPointerEnter,
39
66
  onPointerLeave,
67
+ children,
68
+ deleteLabel = 'Delete',
69
+ deleteDisabledReason,
40
70
  }: NodeActionMenuProps) {
41
71
  return (
42
72
  <div
@@ -47,104 +77,72 @@ export function NodeActionMenu({
47
77
  onPointerUp={onPointerUp}
48
78
  >
49
79
  {onFind && (
50
- <button
51
- aria-label="Find in catalog"
52
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
53
- onClick={onFind}
54
- title="Find in catalog"
55
- type="button"
56
- >
80
+ <ActionMenuButton label="Find in catalog" onClick={onFind}>
57
81
  <Search className="h-4 w-4" />
58
- </button>
82
+ </ActionMenuButton>
59
83
  )}
60
84
  {onMove && (
61
- <button
62
- aria-label="Move"
63
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
64
- onClick={onMove}
65
- title="Move"
66
- type="button"
67
- >
85
+ <ActionMenuButton label="Move" onClick={onMove}>
68
86
  <Move className="h-4 w-4" />
69
- </button>
87
+ </ActionMenuButton>
70
88
  )}
71
89
  {onEditMesh && (
72
- <button
73
- aria-label="Edit mesh"
74
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
75
- onClick={onEditMesh}
76
- title="Edit mesh"
77
- type="button"
78
- >
90
+ <ActionMenuButton label="Edit mesh" onClick={onEditMesh}>
79
91
  <PencilRuler className="h-4 w-4" />
80
- </button>
92
+ </ActionMenuButton>
81
93
  )}
82
94
  {onGroup && (
83
- <button
84
- aria-label="Group selection"
85
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
86
- onClick={onGroup}
87
- title="Group (Ctrl/Cmd+G)"
88
- type="button"
89
- >
95
+ <ActionMenuButton keys={['Cmd/Ctrl', 'G']} label="Group selection" onClick={onGroup}>
90
96
  <Group className="h-4 w-4" />
91
- </button>
97
+ </ActionMenuButton>
92
98
  )}
93
99
  {onUngroup && (
94
- <button
95
- aria-label="Ungroup selection"
96
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
100
+ <ActionMenuButton
101
+ keys={['Cmd/Ctrl', 'Shift', 'G']}
102
+ label="Ungroup selection"
97
103
  onClick={onUngroup}
98
- title="Ungroup (Ctrl/Cmd+Shift+G)"
99
- type="button"
100
104
  >
101
105
  <Ungroup className="h-4 w-4" />
102
- </button>
106
+ </ActionMenuButton>
103
107
  )}
104
108
  {onCurve && (
105
- <button
106
- aria-label="Curve"
107
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
108
- onClick={onCurve}
109
- title="Curve"
110
- type="button"
111
- >
109
+ <ActionMenuButton label="Curve" onClick={onCurve}>
112
110
  <Spline className="h-4 w-4" />
113
- </button>
111
+ </ActionMenuButton>
112
+ )}
113
+ {onRotateLeft && (
114
+ <ActionMenuButton label="Rotate left" onClick={onRotateLeft}>
115
+ <RotateCcw className="h-4 w-4" />
116
+ </ActionMenuButton>
117
+ )}
118
+ {onRotateRight && (
119
+ <ActionMenuButton label="Rotate right" onClick={onRotateRight}>
120
+ <RotateCw className="h-4 w-4" />
121
+ </ActionMenuButton>
114
122
  )}
115
123
  <RegistryActionContributions />
124
+ {children}
116
125
  {onDuplicate && (
117
- <button
118
- aria-label="Duplicate"
119
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
120
- onClick={onDuplicate}
121
- title="Duplicate"
122
- type="button"
123
- >
126
+ <ActionMenuButton label="Duplicate" onClick={onDuplicate}>
124
127
  <Copy className="h-4 w-4" />
125
- </button>
128
+ </ActionMenuButton>
126
129
  )}
127
130
  {onAddHole && (
128
- <button
129
- aria-label="Cut Out"
130
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
131
- onClick={onAddHole}
132
- title="Cut Out"
133
- type="button"
134
- >
131
+ <ActionMenuButton label="Cut out" onClick={onAddHole}>
135
132
  <Icon height={16} icon="carbon:cut-out" width={16} />
136
- </button>
133
+ </ActionMenuButton>
137
134
  )}
138
135
  {onDelete && (
139
- <button
140
- aria-label="Delete"
141
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
136
+ <ActionMenuButton
137
+ destructive
138
+ disabled={!!deleteDisabledReason}
139
+ disabledReason={deleteDisabledReason}
140
+ keys={['Delete / Backspace']}
141
+ label={deleteLabel}
142
142
  onClick={onDelete}
143
- title="Delete"
144
- type="button"
145
143
  >
146
144
  <Trash2 className="h-4 w-4" />
147
- </button>
145
+ </ActionMenuButton>
148
146
  )}
149
147
  </div>
150
148
  )
@@ -6,6 +6,7 @@ import {
6
6
  type ArcResizeHandle,
7
7
  type CornerRadiusHandle,
8
8
  type Cursor,
9
+ createAngleAccumulator,
9
10
  createSceneApi,
10
11
  DEFAULT_ANGLE_STEP,
11
12
  type HandleDescriptor,
@@ -42,6 +43,7 @@ import {
42
43
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
43
44
 
44
45
  import { MeshBasicNodeMaterial } from 'three/webgpu'
46
+ import { useCeilingEditCeilingId } from '../../lib/ceiling-edit-session'
45
47
  import { EDITOR_LAYER } from '../../lib/constants'
46
48
  import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
47
49
  import { createEditorApi } from '../../lib/editor-api'
@@ -241,7 +243,12 @@ export function NodeArrowHandles() {
241
243
  const activeGroup = useHandleGroup((s) =>
242
244
  s.active && s.active.nodeId === node?.id ? s.active.group : null,
243
245
  )
244
- const def = node ? nodeRegistry.get(node.type) : null
246
+ // A ceiling's handles belong to its Edit ceiling session; a plain selection shows none.
247
+ const editCeilingId = useCeilingEditCeilingId()
248
+ const def =
249
+ node && (node.type !== 'ceiling' || node.id === editCeilingId)
250
+ ? nodeRegistry.get(node.type)
251
+ : null
245
252
  const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
246
253
  const descriptors = useMemo(() => {
247
254
  if (!(node && def?.handles)) return null
@@ -816,8 +823,16 @@ function LinearArrow({
816
823
  }) => {
817
824
  dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
818
825
  _resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
819
- axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
820
- const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
826
+ const dragAxis =
827
+ descriptor.kind === 'linear-resize'
828
+ ? descriptor.dragAxis?.(initialNode as never, sceneApi)
829
+ : undefined
830
+ if (dragAxis) _resizeAxisW.set(...dragAxis).normalize()
831
+ else axisVector(descriptor.axis, _resizeAxisW)
832
+ const localToWorldScale = dragAxis
833
+ ? _resizeAxisW.clone().multiply(_resizeScale).length()
834
+ : axisScale(descriptor.axis, _resizeScale)
835
+ _resizeAxisW.applyQuaternion(_resizeQuaternion).normalize()
821
836
  if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
822
837
 
823
838
  const initialPointer =
@@ -1350,6 +1365,7 @@ function ArcArrow({
1350
1365
  return null
1351
1366
  }
1352
1367
  const initialAngle = angleOf(hitWorld)
1368
+ const accumulateAngle = createAngleAccumulator(initialAngle)
1353
1369
 
1354
1370
  // A distinct label selects rotation snapping instead of resize measurements.
1355
1371
  if (isRotateShape) {
@@ -1369,9 +1385,12 @@ function ArcArrow({
1369
1385
  const hit = new Vector3()
1370
1386
  if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
1371
1387
  const currentAngle = angleOf(hit)
1372
- let delta = currentAngle - initialAngle
1373
- while (delta > Math.PI) delta -= 2 * Math.PI
1374
- while (delta < -Math.PI) delta += 2 * Math.PI
1388
+ let delta = isRotateShape
1389
+ ? Math.atan2(
1390
+ Math.sin(currentAngle - initialAngle),
1391
+ Math.cos(currentAngle - initialAngle),
1392
+ )
1393
+ : accumulateAngle(currentAngle)
1375
1394
 
1376
1395
  if (
1377
1396
  !descriptor.continuous &&
@@ -0,0 +1,273 @@
1
+ 'use client'
2
+
3
+ import { type OpenWallEnd, sceneRegistry } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { memo, useEffect, useMemo, useRef } from 'react'
8
+ import { BoxGeometry, CircleGeometry, type Group, RingGeometry } from 'three'
9
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
10
+ import {
11
+ useOpenWallEndFocus,
12
+ useOpenWallEnds,
13
+ useOpenWallEndsSuppressed,
14
+ } from '../../hooks/use-open-wall-ends'
15
+ import { EDITOR_LAYER } from '../../lib/constants'
16
+ import { openWallEndKey, openWallEndLabel } from '../../lib/floorplan/open-wall-ends'
17
+ import { MEASUREMENT_DANGLING_COLOR } from '../../lib/measurements'
18
+ import { cn } from '../../lib/utils'
19
+ import useEditor from '../../store/use-editor'
20
+ import { isViewVisible, VIEW_3D, visibleScene } from '../../store/view-layout'
21
+ import { JoinWallsPill } from './join-walls-pill'
22
+ import { OpenWallEndsHint } from './open-wall-ends-hint'
23
+
24
+ /**
25
+ * The 3D twin of `FloorplanOpenWallEndsLayer`: at each wall end that isn't
26
+ * joined, a red disc on the floor with a dashed ribbon to the wall it nearly
27
+ * meets, and a screen-space dot carrying the same label ("4 cm gap") and, on
28
+ * hover or click, the same "Join walls" pill. The floor geometry ignores depth
29
+ * so it reads through cutaway walls like the snap beacon; the dot is DOM, so
30
+ * it stays clickable where a wall stands between it and the camera.
31
+ *
32
+ * Shares the analysis with the floor plan (`useOpenWallEnds`). Mounted inside
33
+ * ToolManager's building-local group — wall points are building-local plan
34
+ * coordinates — and lifted to the active level's floor each frame.
35
+ */
36
+
37
+ const FLOOR_LIFT = 0.02
38
+ const DISC_RADIUS = 0.07
39
+ const DISC_OUTLINE = 0.095
40
+ const DASH_LEN = 0.06
41
+ const DASH_GAP = 0.04
42
+ const LINE_WIDTH = 0.025
43
+ const MAX_DASHES = 40
44
+ const NO_RAYCAST = () => null
45
+
46
+ function createMarkerResources() {
47
+ const materialOptions = {
48
+ depthTest: false,
49
+ depthWrite: false,
50
+ toneMapped: false,
51
+ transparent: true,
52
+ }
53
+ return {
54
+ redMaterial: new MeshBasicNodeMaterial({
55
+ ...materialOptions,
56
+ color: MEASUREMENT_DANGLING_COLOR,
57
+ }),
58
+ outlineMaterial: new MeshBasicNodeMaterial({ ...materialOptions, color: 0xff_ff_ff }),
59
+ discGeometry: new CircleGeometry(1, 24),
60
+ ringGeometry: new RingGeometry(0.7, 1, 24),
61
+ dashGeometry: new BoxGeometry(1, 1, 1),
62
+ }
63
+ }
64
+
65
+ type MarkerResources = ReturnType<typeof createMarkerResources>
66
+
67
+ type Vec3 = [number, number, number]
68
+
69
+ export const OpenWallEnds3DLayer = memo(function OpenWallEnds3DLayer() {
70
+ const visible = useEditor((state) => isViewVisible(state, VIEW_3D))
71
+ const suppressed = useOpenWallEndsSuppressed()
72
+ return visible && !suppressed ? <ActiveOpenWallEnds3DLayer /> : null
73
+ })
74
+
75
+ const ActiveOpenWallEnds3DLayer = memo(function ActiveOpenWallEnds3DLayer() {
76
+ const levelId = useViewer((state) => state.selection.levelId)
77
+ const unit = useViewer((state) => state.unit)
78
+ const { drafting, ends } = useOpenWallEnds()
79
+ // Split view shows the hint once, over the floor plan.
80
+ const hintHere = useEditor((state) => visibleScene(state) === '3d')
81
+ const focus = useOpenWallEndFocus(ends, drafting)
82
+ const groupRef = useRef<Group>(null)
83
+ const hasEnds = ends.length > 0
84
+ const resources = useMemo(() => (hasEnds ? createMarkerResources() : null), [hasEnds])
85
+ // Resources passed as mesh props are shared and aren't disposed by R3F.
86
+ useEffect(
87
+ () => () => {
88
+ if (resources) for (const resource of Object.values(resources)) resource.dispose()
89
+ },
90
+ [resources],
91
+ )
92
+
93
+ useFrame(() => {
94
+ const group = groupRef.current
95
+ if (!group) return
96
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
97
+ group.position.y = levelMesh ? levelMesh.position.y : 0
98
+ })
99
+
100
+ if (!resources) return null
101
+ return (
102
+ <group ref={groupRef}>
103
+ {ends.map((end) => {
104
+ const key = openWallEndKey(end)
105
+ const active = focus.activeEnd !== null && key === focus.activeKey
106
+ return (
107
+ <OpenWallEndMarker3D
108
+ active={active}
109
+ end={end}
110
+ interactive={!!end.candidate && !drafting}
111
+ key={key}
112
+ label={openWallEndLabel(end, unit)}
113
+ onHoverEnd={focus.releaseHover}
114
+ onHoverStart={() => focus.keepHover(key)}
115
+ onJoin={() => focus.join(end)}
116
+ onPin={() => focus.togglePin(key)}
117
+ refusal={active ? focus.refusal : null}
118
+ resources={resources}
119
+ />
120
+ )
121
+ })}
122
+ {hintHere ? (
123
+ // Html only to reach the DOM from inside the canvas; the hint portals
124
+ // itself to the body and centres on the 3D view.
125
+ <Html style={{ pointerEvents: 'none' }}>
126
+ <OpenWallEndsHint ends={ends} onShow={(end) => focus.pin(openWallEndKey(end))} />
127
+ </Html>
128
+ ) : null}
129
+ </group>
130
+ )
131
+ })
132
+
133
+ function OpenWallEndMarker3D({
134
+ active,
135
+ end,
136
+ interactive,
137
+ label,
138
+ onHoverEnd,
139
+ onHoverStart,
140
+ onJoin,
141
+ onPin,
142
+ refusal,
143
+ resources,
144
+ }: {
145
+ active: boolean
146
+ end: OpenWallEnd
147
+ interactive: boolean
148
+ label: string | null
149
+ onHoverEnd: () => void
150
+ onHoverStart: () => void
151
+ onJoin: () => void
152
+ onPin: () => void
153
+ refusal: string | null
154
+ resources: MarkerResources
155
+ }) {
156
+ const { dashGeometry, discGeometry, ringGeometry, redMaterial, outlineMaterial } = resources
157
+ const [x, z] = end.point
158
+ const target = end.candidate?.point
159
+ const { dashes, angleY, dashLength } = useMemo(() => {
160
+ if (!target) return { dashes: [] as Vec3[], angleY: 0, dashLength: 0 }
161
+ const dx = target[0] - x
162
+ const dz = target[1] - z
163
+ const length = Math.hypot(dx, dz)
164
+ const angle = -Math.atan2(dz, dx)
165
+ if (length < 1e-4) return { dashes: [] as Vec3[], angleY: angle, dashLength: 0 }
166
+ const period = Math.max(DASH_LEN + DASH_GAP, length / MAX_DASHES)
167
+ const centres: Vec3[] = []
168
+ for (let d = Math.min(period, length) / 2; d < length; d += period) {
169
+ centres.push([x + (dx / length) * d, FLOOR_LIFT, z + (dz / length) * d])
170
+ }
171
+ return { dashes: centres, angleY: angle, dashLength: Math.min(DASH_LEN, length) }
172
+ }, [target, x, z])
173
+ const stop = (event: { stopPropagation: () => void }) => event.stopPropagation()
174
+
175
+ return (
176
+ <>
177
+ {dashes.map((centre, index) => (
178
+ <mesh
179
+ geometry={dashGeometry}
180
+ key={index}
181
+ layers={EDITOR_LAYER}
182
+ material={redMaterial}
183
+ position={centre}
184
+ raycast={NO_RAYCAST}
185
+ renderOrder={1005}
186
+ rotation={[0, angleY, 0]}
187
+ scale={[dashLength, 0.002, LINE_WIDTH]}
188
+ />
189
+ ))}
190
+ {target ? (
191
+ <mesh
192
+ geometry={ringGeometry}
193
+ layers={EDITOR_LAYER}
194
+ material={redMaterial}
195
+ position={[target[0], FLOOR_LIFT + 0.001, target[1]]}
196
+ raycast={NO_RAYCAST}
197
+ renderOrder={1006}
198
+ rotation={[-Math.PI / 2, 0, 0]}
199
+ scale={[DISC_RADIUS * 0.8, DISC_RADIUS * 0.8, 1]}
200
+ />
201
+ ) : null}
202
+ <mesh
203
+ geometry={discGeometry}
204
+ layers={EDITOR_LAYER}
205
+ material={outlineMaterial}
206
+ position={[x, FLOOR_LIFT + 0.002, z]}
207
+ raycast={NO_RAYCAST}
208
+ renderOrder={1006}
209
+ rotation={[-Math.PI / 2, 0, 0]}
210
+ scale={[DISC_OUTLINE, DISC_OUTLINE, 1]}
211
+ />
212
+ <mesh
213
+ geometry={discGeometry}
214
+ layers={EDITOR_LAYER}
215
+ material={redMaterial}
216
+ position={[x, FLOOR_LIFT + 0.003, z]}
217
+ raycast={NO_RAYCAST}
218
+ renderOrder={1007}
219
+ rotation={[-Math.PI / 2, 0, 0]}
220
+ scale={[DISC_RADIUS, DISC_RADIUS, 1]}
221
+ />
222
+ <Html
223
+ center
224
+ position={[x, FLOOR_LIFT, z]}
225
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
226
+ zIndexRange={[25, 0]}
227
+ >
228
+ <div className="relative flex items-center justify-center">
229
+ <div className="pointer-events-none absolute bottom-full left-1/2 mb-1.5 flex -translate-x-1/2 flex-col items-center gap-1.5">
230
+ {active ? (
231
+ <JoinWallsPill
232
+ onHoverEnd={onHoverEnd}
233
+ onHoverStart={onHoverStart}
234
+ onJoin={onJoin}
235
+ refusal={refusal}
236
+ />
237
+ ) : null}
238
+ {label ? (
239
+ <div
240
+ className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
241
+ style={{ backgroundColor: MEASUREMENT_DANGLING_COLOR }}
242
+ >
243
+ {label}
244
+ </div>
245
+ ) : null}
246
+ </div>
247
+ <button
248
+ aria-label={label ?? 'Wall end not joined'}
249
+ className={cn(
250
+ 'h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm transition-transform',
251
+ interactive
252
+ ? 'pointer-events-auto cursor-pointer hover:scale-125'
253
+ : 'pointer-events-none',
254
+ active && 'scale-125',
255
+ )}
256
+ data-open-wall-end=""
257
+ onClick={(event) => {
258
+ event.stopPropagation()
259
+ onPin()
260
+ }}
261
+ onPointerDown={stop}
262
+ onPointerEnter={interactive ? onHoverStart : undefined}
263
+ onPointerLeave={interactive ? onHoverEnd : undefined}
264
+ onPointerUp={stop}
265
+ style={{ backgroundColor: MEASUREMENT_DANGLING_COLOR }}
266
+ tabIndex={interactive ? 0 : -1}
267
+ type="button"
268
+ />
269
+ </div>
270
+ </Html>
271
+ </>
272
+ )
273
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type OpenWallEnd } from '@pascal-app/core'
4
+ import { useEffect, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { openWallEndsSummary } from '../../lib/floorplan/open-wall-ends'
7
+ import { MEASUREMENT_DANGLING_COLOR } from '../../lib/measurements'
8
+
9
+ // The viewer area holds the floor plan and the 3D view side by side, so its
10
+ // top centre clears both views' corner controls in 2D, 3D and split alike.
11
+ const viewerArea = () => document.querySelector('[data-pascal-viewer-3d]')?.parentElement ?? null
12
+
13
+ // The views slide in and out without resizing, so the anchor's box is read
14
+ // per frame while the hint is up, like the Join pill's anchor.
15
+ function useViewerAreaTopCenter(): { left: number; top: number } | null {
16
+ const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null)
17
+ useEffect(() => {
18
+ let raf = 0
19
+ const tick = () => {
20
+ raf = requestAnimationFrame(tick)
21
+ const rect = viewerArea()?.getBoundingClientRect()
22
+ const next =
23
+ rect && rect.width > 0 ? { left: rect.left + rect.width / 2, top: rect.top } : null
24
+ setAnchor((current) =>
25
+ current && next && current.left === next.left && current.top === next.top ? current : next,
26
+ )
27
+ }
28
+ tick()
29
+ return () => cancelAnimationFrame(raf)
30
+ }, [])
31
+ return anchor
32
+ }
33
+
34
+ /**
35
+ * "2 wall ends aren't joined — rooms can't close", centred at the top of the
36
+ * viewer area. Mounted by one view only (the floor plan when it shows, else
37
+ * the 3D view), so split view shows it once.
38
+ * Show walks the joinable ends one by one: it focuses the 3D camera on the
39
+ * wall and asks the view to pin that end's Join pill.
40
+ */
41
+ export function OpenWallEndsHint({
42
+ ends,
43
+ onShow,
44
+ }: {
45
+ ends: readonly OpenWallEnd[]
46
+ onShow: (end: OpenWallEnd) => void
47
+ }) {
48
+ const anchor = useViewerAreaTopCenter()
49
+ const [cursor, setCursor] = useState(0)
50
+ const joinable = ends.filter((end) => end.candidate)
51
+ const summary = openWallEndsSummary(joinable.length)
52
+ if (!(anchor && summary)) return null
53
+
54
+ const showNext = () => {
55
+ const target = joinable[cursor % joinable.length]
56
+ if (!target) return
57
+ setCursor((value) => value + 1)
58
+ onShow(target)
59
+ emitter.emit('camera-controls:focus', { nodeId: target.wallId as AnyNodeId })
60
+ }
61
+
62
+ return createPortal(
63
+ <div
64
+ className="pointer-events-auto fixed z-30 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border bg-background/95 py-1 pr-1 pl-3 text-xs shadow-lg backdrop-blur-md"
65
+ data-open-wall-end=""
66
+ onPointerDown={(event) => event.stopPropagation()}
67
+ onPointerUp={(event) => event.stopPropagation()}
68
+ style={{ left: anchor.left, top: anchor.top + 16 }}
69
+ >
70
+ <span
71
+ aria-hidden
72
+ className="h-2 w-2 shrink-0 rounded-full"
73
+ style={{ backgroundColor: MEASUREMENT_DANGLING_COLOR }}
74
+ />
75
+ <span className="text-foreground">{summary}</span>
76
+ <button
77
+ className="rounded-full px-3 py-1 font-medium text-foreground transition-colors hover:bg-accent"
78
+ onClick={(event) => {
79
+ event.stopPropagation()
80
+ showNext()
81
+ }}
82
+ type="button"
83
+ >
84
+ Show
85
+ </button>
86
+ </div>,
87
+ document.body,
88
+ )
89
+ }