@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,509 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ getWallBaseElevationForNodes,
7
+ getWallEffectiveHeightForNodes,
8
+ sceneRegistry,
9
+ useLiveNodeOverrides,
10
+ useScene,
11
+ type WallFace,
12
+ type WallFaceRegion,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { getWallFaceBaseAt, getWallFinishData, useViewer } from '@pascal-app/viewer'
16
+ import { createPortal, useFrame, useThree } from '@react-three/fiber'
17
+ import { useEffect, useMemo, useRef, useState } from 'react'
18
+ import {
19
+ BufferAttribute,
20
+ BufferGeometry,
21
+ Color,
22
+ DoubleSide,
23
+ type Group,
24
+ type Mesh,
25
+ type Object3D,
26
+ OrthographicCamera,
27
+ Ray,
28
+ Vector3,
29
+ } from 'three'
30
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
31
+ import type { WallRegionBounds } from '../../lib/paint-regions'
32
+ import { updateWallRegion } from '../../lib/paint-regions'
33
+ import { sfxEmitter } from '../../lib/sfx-bus'
34
+ import { resolveWallFaceBase } from '../../lib/wall-region-face'
35
+ import {
36
+ buildOutlineRibbon,
37
+ faceSurfacePoint,
38
+ getWallFaceSurface,
39
+ intersectFaceSurface,
40
+ type RegionEdge,
41
+ type RegionHandlePlacement,
42
+ regionHandlePlacements,
43
+ regionOutline,
44
+ resolveRegionBoundDrag,
45
+ resolveRegionBounds,
46
+ type WallFaceSurface,
47
+ wallLocalMatrix,
48
+ withRegionBounds,
49
+ } from '../../lib/wall-region-handles'
50
+ import { wallRegionSnapTargets } from '../../lib/wall-region-snap'
51
+ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
52
+ import useInteractionScope from '../../store/use-interaction-scope'
53
+ import {
54
+ handleWallRegionDeleteKey,
55
+ installWallRegionSceneSync,
56
+ syncWallRegionSelection,
57
+ useWallRegionSelection,
58
+ } from '../../store/use-wall-region-handles-selection'
59
+ import {
60
+ ARROW_COLOR,
61
+ ARROW_HOVER_COLOR,
62
+ ARROW_SCALE,
63
+ HandleArrow,
64
+ InvisibleHandleHitArea,
65
+ NO_RAYCAST,
66
+ useInvisibleHitAreaMaterial,
67
+ } from './handles/handle-arrow'
68
+ import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
69
+
70
+ // Resize handles for the paint regions of the one selected wall: a thin
71
+ // outline per region and an arrow on each bounded edge, shown only while the
72
+ // region is active, hovered or being resized so they do not crowd the wall's
73
+ // own arrows. Dragging an edge previews through a live override and commits
74
+ // one `updateWallRegion`.
75
+ // Clicking an outline or handle makes that region active, and Delete then
76
+ // removes the region instead of the wall.
77
+
78
+ export const PAINT_REGION_HANDLE = 'paint-region-bound'
79
+
80
+ const OUTLINE_HALF_WIDTH = 0.006
81
+ const OUTLINE_ACTIVE_HALF_WIDTH = 0.011
82
+ const OUTLINE_HIT_HALF_WIDTH = 0.04
83
+ const NOOP_DRAG_CONTROLS: HandleDragControls = { onStart: () => {}, onEnd: () => {} }
84
+ // The arrows sit on the outline: the delay lets the pointer cross from one to the other.
85
+ const ARROW_HIDE_DELAY_MS = 200
86
+
87
+ const outlineMaterials = new Map<boolean, MeshBasicNodeMaterial>()
88
+ function outlineMaterial(active: boolean) {
89
+ let material = outlineMaterials.get(active)
90
+ if (!material) {
91
+ material = new MeshBasicNodeMaterial({
92
+ color: new Color(active ? ARROW_HOVER_COLOR : ARROW_COLOR),
93
+ side: DoubleSide,
94
+ transparent: true,
95
+ opacity: active ? 1 : 0.8,
96
+ depthTest: false,
97
+ depthWrite: false,
98
+ })
99
+ outlineMaterials.set(active, material)
100
+ }
101
+ return material
102
+ }
103
+
104
+ function isOwnScope(
105
+ scope: ReturnType<typeof useInteractionScope.getState>['scope'],
106
+ wallId: string | undefined,
107
+ ) {
108
+ return (
109
+ scope.kind === 'handle-drag' && scope.handle === PAINT_REGION_HANDLE && scope.nodeId === wallId
110
+ )
111
+ }
112
+
113
+ export function WallRegionHandles() {
114
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
115
+ const mode = useEditor((state) => state.mode)
116
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
117
+ const scope = useInteractionScope((state) => state.scope)
118
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
119
+ const wall = useScene((state) => {
120
+ const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
121
+ return node?.type === 'wall' && node.faceRegions?.length ? node : null
122
+ })
123
+
124
+ useEffect(() => syncWallRegionSelection(selectedIds), [selectedIds])
125
+
126
+ // Capture phase, so an active region takes Delete before the global
127
+ // use-keyboard arm deletes the wall.
128
+ useEffect(() => {
129
+ const onKeyDown = (event: KeyboardEvent) => {
130
+ handleWallRegionDeleteKey(event)
131
+ }
132
+ window.addEventListener('keydown', onKeyDown, true)
133
+ const uninstallSceneSync = installWallRegionSceneSync()
134
+ return () => {
135
+ window.removeEventListener('keydown', onKeyDown, true)
136
+ uninstallSceneSync()
137
+ }
138
+ }, [])
139
+
140
+ const ownScope = isOwnScope(scope, wall?.id)
141
+ const shouldRender =
142
+ Boolean(wall) &&
143
+ mode === 'select' &&
144
+ (ownScope || (scope.kind === 'idle' && !isFloorplanHovered))
145
+
146
+ if (!(shouldRender && wall)) return null
147
+ return <WallRegionHandlesForWall wall={wall} />
148
+ }
149
+
150
+ function useLevelObject(parentId: string | null | undefined) {
151
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
152
+ parentId ? (sceneRegistry.nodes.get(parentId) ?? null) : null,
153
+ )
154
+ useEffect(() => {
155
+ let frameId = 0
156
+ const resolve = () => {
157
+ const next = parentId ? (sceneRegistry.nodes.get(parentId) ?? null) : null
158
+ setLevelObject(next)
159
+ if (!next) frameId = window.requestAnimationFrame(resolve)
160
+ }
161
+ resolve()
162
+ return () => {
163
+ if (frameId) window.cancelAnimationFrame(frameId)
164
+ }
165
+ }, [parentId])
166
+ return levelObject
167
+ }
168
+
169
+ type WallFrame = {
170
+ wall: WallNode
171
+ baseElevation: number
172
+ wallHeight: number
173
+ angle: number
174
+ levelObject: Object3D
175
+ }
176
+
177
+ function faceBaseSampler(wallId: string, face: WallFace) {
178
+ const mesh = sceneRegistry.nodes.get(wallId as AnyNodeId) as Mesh | undefined
179
+ const data = getWallFinishData(mesh?.geometry)
180
+ return (u: number) => getWallFaceBaseAt(data, face, u)
181
+ }
182
+
183
+ function regionFaceHeight(
184
+ region: WallFaceRegion,
185
+ length: number,
186
+ wallHeight: number,
187
+ baseAt: (u: number) => number,
188
+ ) {
189
+ const midU = ((region.u0 ?? 0) + (region.u1 ?? length)) / 2
190
+ return Math.max(0, wallHeight - baseAt(midU))
191
+ }
192
+
193
+ function WallRegionHandlesForWall({ wall }: { wall: WallNode }) {
194
+ const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id))
195
+ const effectiveWall = useMemo(
196
+ () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall),
197
+ [wall, liveOverride],
198
+ )
199
+ const nodes = useScene((state) => state.nodes)
200
+ const levelObject = useLevelObject(wall.parentId)
201
+ if (!levelObject) return null
202
+
203
+ const frame: WallFrame = {
204
+ wall: effectiveWall,
205
+ baseElevation: getWallBaseElevationForNodes(effectiveWall, nodes),
206
+ wallHeight: getWallEffectiveHeightForNodes(effectiveWall, nodes),
207
+ angle: Math.atan2(
208
+ effectiveWall.end[1] - effectiveWall.start[1],
209
+ effectiveWall.end[0] - effectiveWall.start[0],
210
+ ),
211
+ levelObject,
212
+ }
213
+
214
+ return createPortal(
215
+ <group
216
+ position={[effectiveWall.start[0], frame.baseElevation, effectiveWall.start[1]]}
217
+ rotation={[0, -frame.angle, 0]}
218
+ >
219
+ <FaceRegionHandles face="a" frame={frame} />
220
+ <FaceRegionHandles face="b" frame={frame} />
221
+ </group>,
222
+ levelObject,
223
+ )
224
+ }
225
+
226
+ function FaceRegionHandles({ face, frame }: { face: WallFace; frame: WallFrame }) {
227
+ const { wall } = frame
228
+ const groupRef = useRef<Group>(null)
229
+ const cameraPosition = useRef(new Vector3())
230
+ const [facing, setFacing] = useState(true)
231
+ const { camera } = useThree()
232
+ // Only the frame-defining fields: region edits must not rebuild the surface.
233
+ const { start, end, curveOffset, thickness, justification } = wall
234
+ const surface = useMemo(
235
+ () => getWallFaceSurface({ start, end, curveOffset, thickness, justification }, face),
236
+ [start, end, curveOffset, thickness, justification, face],
237
+ )
238
+ const regions = (wall.faceRegions ?? []).filter((region) => region.face === face)
239
+
240
+ // A face turned away from the camera hides its handles: they draw on top of
241
+ // everything, so they would read as belonging to the near face.
242
+ useFrame(() => {
243
+ const group = groupRef.current
244
+ if (!group) return
245
+ if (isOwnScope(useInteractionScope.getState().scope, wall.id)) return
246
+ const local = group.worldToLocal(camera.getWorldPosition(cameraPosition.current))
247
+ const point = faceSurfacePoint(surface, surface.length / 2)
248
+ const next = (local.x - point.x) * point.nx + (local.z - point.z) * point.nz > 0
249
+ if (next !== facing) setFacing(next)
250
+ })
251
+
252
+ if (regions.length === 0) return null
253
+ const baseAt = faceBaseSampler(wall.id, face)
254
+ return (
255
+ <group ref={groupRef}>
256
+ {facing &&
257
+ regions.map((region) => (
258
+ <RegionEditor
259
+ baseAt={baseAt}
260
+ frame={frame}
261
+ key={region.id}
262
+ region={region}
263
+ surface={surface}
264
+ />
265
+ ))}
266
+ </group>
267
+ )
268
+ }
269
+
270
+ function useRibbonGeometry(positions: Float32Array) {
271
+ const geometry = useMemo(() => {
272
+ const next = new BufferGeometry()
273
+ next.setAttribute('position', new BufferAttribute(positions, 3))
274
+ next.computeBoundingSphere()
275
+ return next
276
+ }, [positions])
277
+ useEffect(() => () => geometry.dispose(), [geometry])
278
+ return geometry
279
+ }
280
+
281
+ function RegionEditor({
282
+ region,
283
+ surface,
284
+ frame,
285
+ baseAt,
286
+ }: {
287
+ region: WallFaceRegion
288
+ surface: WallFaceSurface
289
+ frame: WallFrame
290
+ baseAt: (u: number) => number
291
+ }) {
292
+ const { wall } = frame
293
+ const [outlineHovered, setOutlineHovered] = useState(false)
294
+ const [hoveredArrow, setHoveredArrow] = useState<RegionEdge | null>(null)
295
+ const [draggingEdge, setDraggingEdge] = useState<RegionEdge | null>(null)
296
+ const [arrowsLinger, setArrowsLinger] = useState(false)
297
+ const isActive = useWallRegionSelection(
298
+ (state) => state.active?.wallId === wall.id && state.active.regionId === region.id,
299
+ )
300
+ const faceHeight = regionFaceHeight(region, surface.length, frame.wallHeight, baseAt)
301
+ const boundsKey = [region.u0, region.u1, region.v0, region.v1, faceHeight, surface.length].join(
302
+ ':',
303
+ )
304
+ // biome-ignore lint/correctness/useExhaustiveDependencies: boundsKey captures every input
305
+ const outline = useMemo(
306
+ () => regionOutline(surface, region, faceHeight, baseAt),
307
+ [boundsKey, surface],
308
+ )
309
+ const placements = regionHandlePlacements(surface, region, faceHeight, baseAt, draggingEdge)
310
+ const hovered =
311
+ outlineHovered ||
312
+ (hoveredArrow !== null && placements.some((placement) => placement.edge === hoveredArrow))
313
+ const showArrows = isActive || hovered || arrowsLinger || draggingEdge !== null
314
+ const halfWidth = isActive || hovered ? OUTLINE_ACTIVE_HALF_WIDTH : OUTLINE_HALF_WIDTH
315
+ const ribbon = useRibbonGeometry(
316
+ useMemo(() => buildOutlineRibbon(outline, halfWidth), [outline, halfWidth]),
317
+ )
318
+ const hitRibbon = useRibbonGeometry(
319
+ useMemo(() => buildOutlineRibbon(outline, OUTLINE_HIT_HALF_WIDTH), [outline]),
320
+ )
321
+ const hitMaterial = useInvisibleHitAreaMaterial()
322
+ const select = () =>
323
+ useWallRegionSelection.getState().setActive({ wallId: wall.id, regionId: region.id })
324
+ const activateTap = useHandleDrag({ kind: 'tap', onTap: select })
325
+ const onArrowHoverChange = (edge: RegionEdge, next: boolean) =>
326
+ setHoveredArrow((current) => (next ? edge : current === edge ? null : current))
327
+
328
+ useEffect(() => {
329
+ if (hovered) {
330
+ setArrowsLinger(true)
331
+ return
332
+ }
333
+ const timer = window.setTimeout(() => setArrowsLinger(false), ARROW_HIDE_DELAY_MS)
334
+ return () => window.clearTimeout(timer)
335
+ }, [hovered])
336
+
337
+ useEffect(
338
+ () => () => {
339
+ if (document.body.style.cursor === 'pointer') document.body.style.cursor = ''
340
+ },
341
+ [],
342
+ )
343
+
344
+ return (
345
+ <>
346
+ <mesh
347
+ frustumCulled={false}
348
+ geometry={ribbon}
349
+ material={outlineMaterial(isActive || hovered)}
350
+ raycast={NO_RAYCAST}
351
+ renderOrder={1001}
352
+ />
353
+ <InvisibleHandleHitArea
354
+ geometry={hitRibbon}
355
+ material={hitMaterial}
356
+ onPointerDown={activateTap}
357
+ onPointerEnter={(event) => {
358
+ event.stopPropagation()
359
+ setOutlineHovered(true)
360
+ document.body.style.cursor = 'pointer'
361
+ }}
362
+ onPointerLeave={(event) => {
363
+ event.stopPropagation()
364
+ setOutlineHovered(false)
365
+ if (document.body.style.cursor === 'pointer') document.body.style.cursor = ''
366
+ }}
367
+ scale={1}
368
+ />
369
+ {showArrows &&
370
+ placements.map((placement) => (
371
+ <RegionEdgeHandle
372
+ frame={frame}
373
+ key={placement.edge}
374
+ onDraggingChange={setDraggingEdge}
375
+ onHoverChange={onArrowHoverChange}
376
+ onSelect={select}
377
+ placement={placement}
378
+ regionId={region.id}
379
+ />
380
+ ))}
381
+ </>
382
+ )
383
+ }
384
+
385
+ function RegionEdgeHandle({
386
+ frame,
387
+ regionId,
388
+ placement,
389
+ onSelect,
390
+ onDraggingChange,
391
+ onHoverChange,
392
+ }: {
393
+ frame: WallFrame
394
+ regionId: string
395
+ placement: RegionHandlePlacement
396
+ onSelect: () => void
397
+ onDraggingChange: (edge: RegionEdge | null) => void
398
+ onHoverChange: (edge: RegionEdge, hovered: boolean) => void
399
+ }) {
400
+ const { wall, levelObject } = frame
401
+ const [hovered, setHovered] = useState(false)
402
+ const [dragging, setDragging] = useState(false)
403
+ const { camera } = useThree()
404
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
405
+ const edge: RegionEdge = placement.edge
406
+ const cursor = edge[0] === 'u' ? 'ew-resize' : 'ns-resize'
407
+
408
+ const activate = useHandleDrag({
409
+ kind: 'drag',
410
+ cursor,
411
+ dragControls: NOOP_DRAG_CONTROLS,
412
+ handleIndex: 0,
413
+ node: wall,
414
+ rideObject: levelObject,
415
+ setIsDragging: (next) => {
416
+ setDragging(next)
417
+ onDraggingChange(next ? edge : null)
418
+ },
419
+ onStart: ({ event, getPointerRay, initialNode, nodeId, rideObject }) => {
420
+ if (initialNode.type !== 'wall') return null
421
+ const regions = initialNode.faceRegions ?? []
422
+ const region = regions.find((entry) => entry.id === regionId)
423
+ if (!region) return null
424
+ onSelect()
425
+ const nodes = useScene.getState().nodes
426
+ const face = region.face
427
+ const faceSurface = getWallFaceSurface(initialNode, face)
428
+ const baseAt = faceBaseSampler(initialNode.id, face)
429
+ const wallHeight = getWallEffectiveHeightForNodes(initialNode, nodes)
430
+ const faceHeight = regionFaceHeight(region, faceSurface.length, wallHeight, baseAt)
431
+ const bounds = resolveRegionBounds(region, faceSurface.length, faceHeight)
432
+ const midU = (bounds.u0 + bounds.u1) / 2
433
+ const mesh = sceneRegistry.nodes.get(initialNode.id) as Mesh | undefined
434
+ const runs = resolveWallFaceBase(initialNode, mesh?.geometry, nodes)?.[face] ?? null
435
+ const faceTargets = wallRegionSnapTargets(initialNode, face, nodes, runs, {
436
+ excludeRegionId: regionId,
437
+ })
438
+ const snapTargets = edge[0] === 'u' ? faceTargets.u : faceTargets.v
439
+
440
+ rideObject.updateWorldMatrix(true, false)
441
+ const toWallLocal = rideObject.matrixWorld
442
+ .clone()
443
+ .multiply(wallLocalMatrix(initialNode, getWallBaseElevationForNodes(initialNode, nodes)))
444
+ .invert()
445
+ const ray = new Ray()
446
+ const pointerValue = (worldRay: Ray) => {
447
+ ray.copy(worldRay).applyMatrix4(toWallLocal)
448
+ const hit = intersectFaceSurface(faceSurface, ray.origin, ray.direction)
449
+ if (!hit) return null
450
+ return edge[0] === 'u' ? hit.u : hit.y - baseAt(midU)
451
+ }
452
+ const initialValue = bounds[edge]
453
+ const initialPointer =
454
+ pointerValue(getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, ray)) ??
455
+ initialValue
456
+ let lastBounds: WallRegionBounds | null = null
457
+
458
+ return {
459
+ onBegin: () => {
460
+ useInteractionScope
461
+ .getState()
462
+ .begin({ kind: 'handle-drag', nodeId, handle: PAINT_REGION_HANDLE })
463
+ },
464
+ onEnd: () => {
465
+ useInteractionScope.getState().endIf((scope) => isOwnScope(scope, nodeId))
466
+ },
467
+ move: ({ event: moveEvent, getPointerRay: getMoveRay }) => {
468
+ const value = pointerValue(getMoveRay(moveEvent.clientX, moveEvent.clientY, new Ray()))
469
+ if (value === null) return null
470
+ const next = resolveRegionBoundDrag({
471
+ region,
472
+ edge,
473
+ raw: initialValue + (value - initialPointer),
474
+ length: faceSurface.length,
475
+ faceHeight,
476
+ gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : null,
477
+ edgeSnap: isMagneticSnapActive(),
478
+ snapTargets,
479
+ })
480
+ if (lastBounds && lastBounds[edge] !== next[edge]) sfxEmitter.emit('sfx:resize')
481
+ lastBounds = next
482
+ return { faceRegions: withRegionBounds(regions, regionId, next) } as Partial<AnyNode>
483
+ },
484
+ commit: () => {
485
+ if (lastBounds) updateWallRegion(nodeId, regionId, lastBounds)
486
+ },
487
+ }
488
+ },
489
+ })
490
+
491
+ return (
492
+ <group rotation={[0, placement.yaw, 0]} position={placement.position}>
493
+ <HandleArrow
494
+ activeCursor={cursor}
495
+ cursor={cursor}
496
+ hover={hovered || dragging}
497
+ indicatorRotation={[Math.PI / 2, placement.tipAngle, 0]}
498
+ onHoverChange={(next) => {
499
+ setHovered(next)
500
+ onHoverChange(edge, next)
501
+ }}
502
+ onPointerDown={activate}
503
+ placement={{ position: [0, 0, 0], baseScale: zoom * ARROW_SCALE }}
504
+ shape="chevron"
505
+ thin
506
+ />
507
+ </group>
508
+ )
509
+ }
@@ -155,6 +155,9 @@ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
155
155
  wall.curveOffset ?? 0,
156
156
  wall.thickness,
157
157
  wall.supportSlabId,
158
+ undefined,
159
+ undefined,
160
+ wall.justification,
158
161
  )
159
162
  const planeTop = getWallPlaneTop(wall, levelId, nodes as Record<string, AnyNode>)
160
163
  return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
4
  import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
5
+ import { wallCursorIcon } from '../../lib/tool-cursor-icon'
5
6
  import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
6
7
  import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
7
8
  import { furnishTools } from '../ui/action-menu/furnish-tools'
@@ -45,8 +46,8 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
45
46
  }: FloorplanCursorIndicatorOverlayProps) {
46
47
  const mode = useEditor((state) => state.mode)
47
48
  const tool = useEditor((state) => state.tool)
48
- const structureLayer = useEditor((state) => state.structureLayer)
49
49
  const catalogCategory = useEditor((state) => state.catalogCategory)
50
+ const wallMode = useEditor((state) => state.continuationByContext.wall)
50
51
  const cursorPoint = useFloorplanDraftPreview((state) => state.cursorPoint)
51
52
  const anchorRef = useRef<HTMLDivElement>(null)
52
53
  const [projectedCursorPosition, setProjectedCursorPosition] =
@@ -65,15 +66,18 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
65
66
  return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
66
67
  }
67
68
 
69
+ // The wall shows its Rooms variant, like the 3D cursor bubble.
70
+ if (tool === 'wall') return wallCursorIcon(wallMode)
71
+
68
72
  return structureTools.find((entry) => entry.id === tool) ?? null
69
- }, [catalogCategory, mode, movingOpeningType, tool])
73
+ }, [catalogCategory, mode, movingOpeningType, tool, wallMode])
70
74
 
71
75
  const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
72
76
  if (activeFloorplanToolConfig) {
73
77
  return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
74
78
  }
75
79
 
76
- if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
80
+ if (mode === 'select' && floorplanSelectionTool === 'marquee') {
77
81
  return { kind: 'icon', icon: 'mdi:select-drag' }
78
82
  }
79
83
 
@@ -86,7 +90,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
86
90
  }
87
91
 
88
92
  return null
89
- }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
93
+ }, [activeFloorplanToolConfig, floorplanSelectionTool, mode])
90
94
 
91
95
  useLayoutEffect(() => {
92
96
  const anchor = anchorRef.current
@@ -0,0 +1,86 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { type AnyNode, LevelNode, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { act } from '@react-three/fiber'
5
+ import useEditor from '../../store/use-editor'
6
+ import useInteractionScope from '../../store/use-interaction-scope'
7
+ import { sceneLayout } from '../../store/view-layout'
8
+ import { withSelectionHarness } from '../../test-utils/selection-harness'
9
+ import { OpenWallEnds3DLayer } from '../editor/open-wall-ends-3d-layer'
10
+ import { FloorplanOpenWallEndsLayer } from './floorplan-open-wall-ends-layer'
11
+
12
+ test('hidden open-end overlays do not read 2,000 walls during moves, handles, any reshape, or 3D', async () => {
13
+ await withSelectionHarness(async ({ render }) => {
14
+ const wall = WallNode.parse({ id: 'wall_a', parentId: 'level_a', start: [0, 0], end: [2, 0] })
15
+ const walls = [
16
+ wall,
17
+ ...Array.from({ length: 1999 }, (_, index) => ({
18
+ ...wall,
19
+ id: `wall_${index}` as typeof wall.id,
20
+ start: [index * 4 + 4, 0] as [number, number],
21
+ end: [index * 4 + 6, 0] as [number, number],
22
+ })),
23
+ ]
24
+ const level = LevelNode.parse({ id: 'level_a', children: walls.map((node) => node.id) })
25
+ let reads = 0
26
+ const nodes = new Proxy<Record<string, AnyNode>>(
27
+ Object.fromEntries([level, ...walls].map((node) => [node.id, node])),
28
+ {
29
+ get(target, key, receiver) {
30
+ reads++
31
+ return Reflect.get(target, key, receiver)
32
+ },
33
+ },
34
+ )
35
+ useScene.setState({ nodes })
36
+ useViewer.setState({
37
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
38
+ })
39
+ useEditor.setState({ viewLayouts: { edit: sceneLayout('split') }, mode: 'build', tool: 'wall' })
40
+ const scopes: ReturnType<typeof useInteractionScope.getState>['scope'][] = [
41
+ { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '2d' },
42
+ {
43
+ kind: 'placing',
44
+ node: wall,
45
+ nodeId: wall.id,
46
+ nodeType: 'wall',
47
+ view: '2d',
48
+ pressDrag: false,
49
+ driver: 'move-tool',
50
+ },
51
+ { kind: 'handle-drag', nodeId: wall.id, handle: 'height' },
52
+ ...(
53
+ ['endpoint', 'curve', 'control-point', 'tangent', 'boundary', 'hole', 'split'] as const
54
+ ).map((reshape) => ({
55
+ kind: 'reshaping' as const,
56
+ reshape,
57
+ nodeId: wall.id,
58
+ driver: 'floorplan' as const,
59
+ })),
60
+ ]
61
+ for (const scope of scopes) {
62
+ await act(async () => useInteractionScope.setState({ scope }))
63
+ reads = 0
64
+ await render(
65
+ <group>
66
+ <FloorplanOpenWallEndsLayer />
67
+ <OpenWallEnds3DLayer />
68
+ </group>,
69
+ )
70
+ expect(reads).toBe(0)
71
+ }
72
+ await render(null)
73
+ await act(async () => {
74
+ useEditor.setState({ viewLayouts: { edit: sceneLayout('3d') } })
75
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
76
+ })
77
+ reads = 0
78
+ await render(<FloorplanOpenWallEndsLayer />)
79
+ expect(reads).toBe(0)
80
+ await render(null)
81
+ await act(async () => useEditor.setState({ viewLayouts: { edit: sceneLayout('2d') } }))
82
+ reads = 0
83
+ await render(<OpenWallEnds3DLayer />)
84
+ expect(reads).toBe(0)
85
+ })
86
+ })