@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,870 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ DEFAULT_WALL_HEIGHT,
6
+ type FenceNode,
7
+ getWallCurveFrameAt,
8
+ getWallThickness,
9
+ isCurvedWall,
10
+ sceneRegistry,
11
+ useLiveNodeOverrides,
12
+ useScene,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
17
+ import { useEffect, useMemo, useRef, useState } from 'react'
18
+ import {
19
+ BufferGeometry,
20
+ Color,
21
+ CylinderGeometry,
22
+ DoubleSide,
23
+ ExtrudeGeometry,
24
+ type Group,
25
+ type Object3D,
26
+ OrthographicCamera,
27
+ Plane,
28
+ Quaternion,
29
+ Shape,
30
+ Vector2,
31
+ Vector3,
32
+ } from 'three'
33
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
34
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
35
+ import { isHistoryShortcut } from '../../lib/history'
36
+ import { endpointReshapeScope } from '../../lib/interaction/scope'
37
+ import { sfxEmitter } from '../../lib/sfx-bus'
38
+ import useEditor from '../../store/use-editor'
39
+ import useInteractionScope, {
40
+ useEndpointReshape,
41
+ useIsCurveReshape,
42
+ useMovingNode,
43
+ } from '../../store/use-interaction-scope'
44
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
45
+ import {
46
+ createArrowHitAreaGeometry,
47
+ createEndpointHitAreaGeometry,
48
+ InvisibleHandleHitArea,
49
+ NO_RAYCAST,
50
+ useInvisibleHitAreaMaterial,
51
+ } from './node-arrow-handles'
52
+
53
+ const HANDLE_OFFSET = 0.27
54
+ const HANDLE_MIN_OFFSET = 0.33
55
+ const HANDLE_MIN_HEIGHT = 0.4
56
+ const HANDLE_TOP_INSET = 0.08
57
+ const HEIGHT_HANDLE_OFFSET = 0.26
58
+ const MIN_WALL_HEIGHT = 0.5
59
+ const ARROW_COLOR = '#8381ed'
60
+ const ARROW_HOVER_COLOR = '#a5b4fc'
61
+ // Match the door arrows: scale the rendered chevron down to ~two-thirds
62
+ // so the in-world handles read as a single UI family.
63
+ const ARROW_SCALE = 0.65
64
+ const CORNER_HEX_RADIUS = 0.11
65
+ const CORNER_DASH_SIZE = 0.1
66
+ const CORNER_GAP_SIZE = 0.07
67
+ const CORNER_DASH_THICKNESS = 0.006
68
+ const CORNER_FLOOR_OFFSET = 0.01
69
+
70
+ type WallMoveHandle = {
71
+ key: string
72
+ position: [number, number, number]
73
+ rotationY: number
74
+ }
75
+
76
+ // Pre-empt the synthetic `click` the browser fires immediately after a
77
+ // drag's pointerup. Without this, PointerMissedHandler treats the click
78
+ // as "missed" and deselects the wall when the height arrow drag commits.
79
+ function swallowNextClick() {
80
+ const swallow = (clickEvent: Event) => {
81
+ clickEvent.stopPropagation()
82
+ clickEvent.preventDefault()
83
+ }
84
+ window.addEventListener('click', swallow, { capture: true, once: true })
85
+ setTimeout(() => {
86
+ window.removeEventListener('click', swallow, { capture: true })
87
+ }, 300)
88
+ }
89
+
90
+ function createArrowHandleGeometry() {
91
+ // Classic arrow silhouette — chevron head + rectangular shaft — extruded
92
+ // slightly so the handle reads as a 3D plate but stays visually light.
93
+ const shape = new Shape()
94
+ shape.moveTo(0.22, 0)
95
+ shape.lineTo(-0.04, 0.12)
96
+ shape.lineTo(-0.04, 0.035)
97
+ shape.lineTo(-0.2, 0.035)
98
+ shape.lineTo(-0.2, -0.035)
99
+ shape.lineTo(-0.04, -0.035)
100
+ shape.lineTo(-0.04, -0.12)
101
+ shape.lineTo(0.22, 0)
102
+
103
+ const geometry = new ExtrudeGeometry(shape, {
104
+ depth: 0.045,
105
+ bevelEnabled: true,
106
+ bevelThickness: 0.018,
107
+ bevelSize: 0.02,
108
+ bevelOffset: 0,
109
+ bevelSegments: 8,
110
+ curveSegments: 16,
111
+ steps: 1,
112
+ })
113
+
114
+ // Centre the extruded plate around y=0 and re-orient it so the depth
115
+ // axis points up: the chevron lies flat in the XZ plane, tip along +X,
116
+ // wings spread across ±Z.
117
+ geometry.translate(0, 0, -0.0225)
118
+ geometry.rotateX(-Math.PI / 2)
119
+ geometry.computeVertexNormals()
120
+ geometry.computeBoundingSphere()
121
+ return geometry
122
+ }
123
+
124
+ export function WallMoveSideHandles() {
125
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
126
+ const mode = useEditor((state) => state.mode)
127
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
128
+ const movingNode = useMovingNode()
129
+ const endpointReshape = useEndpointReshape()
130
+ const isCurveReshape = useIsCurveReshape()
131
+
132
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
133
+ // Fence side-move / height / corner-pickers now flow through the
134
+ // registry handle path (see packages/nodes/src/fence/definition.ts).
135
+ // Only walls still need the legacy renderer here — the registry path
136
+ // didn't render correctly for walls specifically and was reverted in
137
+ // commit 0e207a7f; revisit once that's diagnosed.
138
+ const selectedNode = useScene((state) => {
139
+ const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
140
+ return node?.type === 'wall' ? node : null
141
+ })
142
+
143
+ const shouldRender =
144
+ Boolean(selectedNode) &&
145
+ !isFloorplanHovered &&
146
+ mode !== 'delete' &&
147
+ !movingNode &&
148
+ !endpointReshape &&
149
+ !isCurveReshape
150
+
151
+ if (!shouldRender || !selectedNode) return null
152
+
153
+ return <WallMoveSideHandlesForWall wall={selectedNode} />
154
+ }
155
+
156
+ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
157
+ // Merge the in-flight drag override so every handle (side-move arrows,
158
+ // height arrow, corner leaders) tracks the live height in real time
159
+ // during a height drag — the scene store stays at the pre-drag value
160
+ // until commit, so reading `wall` alone would freeze them. Same pattern
161
+ // as node-arrow-handles.
162
+ const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id))
163
+ const effectiveWall = useMemo(
164
+ () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall),
165
+ [wall, liveOverride],
166
+ )
167
+
168
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
169
+ wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
170
+ )
171
+
172
+ useEffect(() => {
173
+ let frameId = 0
174
+
175
+ const resolveLevelObject = () => {
176
+ const nextLevelObject = wall.parentId
177
+ ? (sceneRegistry.nodes.get(wall.parentId) ?? null)
178
+ : null
179
+ setLevelObject((currentLevelObject) => {
180
+ if (currentLevelObject === nextLevelObject) {
181
+ return currentLevelObject
182
+ }
183
+ return nextLevelObject
184
+ })
185
+
186
+ if (!nextLevelObject) {
187
+ frameId = window.requestAnimationFrame(resolveLevelObject)
188
+ }
189
+ }
190
+
191
+ resolveLevelObject()
192
+
193
+ return () => {
194
+ if (frameId) {
195
+ window.cancelAnimationFrame(frameId)
196
+ }
197
+ }
198
+ }, [wall.parentId])
199
+
200
+ const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall])
201
+
202
+ if (!levelObject || handles.length === 0) return null
203
+
204
+ return createPortal(
205
+ <group>
206
+ {handles.map((handle) => (
207
+ <WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
208
+ ))}
209
+ <WallHeightArrowHandle wall={effectiveWall} />
210
+ <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
211
+ <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
212
+ </group>,
213
+ levelObject,
214
+ )
215
+ }
216
+
217
+ function buildDashedVerticalGeometry(height: number) {
218
+ // Build each dash as a thin cylinder section so thickness is
219
+ // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU.
220
+ const dashes: BufferGeometry[] = []
221
+ let y = 0
222
+ while (y < height) {
223
+ const end = Math.min(y + CORNER_DASH_SIZE, height)
224
+ const length = end - y
225
+ const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
226
+ cylinder.translate(0, y + length / 2, 0)
227
+ dashes.push(cylinder)
228
+ y = end + CORNER_GAP_SIZE
229
+ }
230
+ const merged = mergeGeometries(dashes, false) ?? new BufferGeometry()
231
+ for (const dash of dashes) dash.dispose()
232
+ return merged
233
+ }
234
+
235
+ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) {
236
+ const [isHovered, setIsHovered] = useState(false)
237
+ const { camera } = useThree()
238
+ const billboardRef = useRef<Group>(null)
239
+ const parentWorldQuaternionRef = useRef(new Quaternion())
240
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
241
+ const baseScale = zoom
242
+ const visualScale = isHovered ? 1.25 : 1
243
+
244
+ const corner = endpoint === 'start' ? wall.start : wall.end
245
+ const x = corner[0]
246
+ const z = corner[1]
247
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
248
+
249
+ const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
250
+ const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
251
+ const hitMaterial = useInvisibleHitAreaMaterial()
252
+ useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
253
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
254
+
255
+ // Node materials matched to the rest of the file — mixing plain
256
+ // `meshBasicMaterial` with WebGPU node materials trips
257
+ // "Color target has no corresponding fragment stage output".
258
+ const dashMaterial = useMemo(
259
+ () =>
260
+ new MeshBasicNodeMaterial({
261
+ color: new Color(ARROW_COLOR),
262
+ transparent: true,
263
+ opacity: 0.85,
264
+ depthTest: false,
265
+ depthWrite: false,
266
+ }),
267
+ [],
268
+ )
269
+ const hexMaterial = useMemo(
270
+ () =>
271
+ new MeshBasicNodeMaterial({
272
+ color: new Color(ARROW_COLOR),
273
+ side: DoubleSide,
274
+ transparent: true,
275
+ opacity: 0.95,
276
+ depthTest: false,
277
+ depthWrite: false,
278
+ }),
279
+ [],
280
+ )
281
+ const ringMaterial = useMemo(
282
+ () =>
283
+ new MeshBasicNodeMaterial({
284
+ color: new Color(ARROW_COLOR),
285
+ side: DoubleSide,
286
+ transparent: true,
287
+ opacity: 1,
288
+ depthTest: false,
289
+ depthWrite: false,
290
+ }),
291
+ [],
292
+ )
293
+
294
+ useEffect(() => {
295
+ const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
296
+ dashMaterial.color.set(next)
297
+ hexMaterial.color.set(next)
298
+ ringMaterial.color.set(next)
299
+ }, [dashMaterial, hexMaterial, ringMaterial, isHovered])
300
+
301
+ useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
302
+ useEffect(() => () => hexMaterial.dispose(), [hexMaterial])
303
+ useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
304
+
305
+ // Billboard the hex disc to the camera so the picker is always
306
+ // recognisable regardless of viewing angle.
307
+ //
308
+ // Why parent-aware: the disc lives under a `createPortal` into the
309
+ // level object, which itself sits under a building. Both can have
310
+ // non-identity world rotations. `quaternion.copy(camera.quaternion)`
311
+ // alone sets the LOCAL quaternion, so any ancestor rotation rotates
312
+ // the disc away from the camera. We instead solve for a local
313
+ // quaternion whose composition with the parent world quaternion
314
+ // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`.
315
+ useFrame(() => {
316
+ const billboard = billboardRef.current
317
+ if (!billboard) return
318
+ billboard.quaternion.copy(camera.quaternion)
319
+ const parent = billboard.parent
320
+ if (parent) {
321
+ parent.getWorldQuaternion(parentWorldQuaternionRef.current)
322
+ billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert())
323
+ }
324
+ })
325
+
326
+ useEffect(() => {
327
+ return () => {
328
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
329
+ document.body.style.cursor = ''
330
+ }
331
+ }
332
+ }, [])
333
+
334
+ const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
335
+ if (event.button !== 0) return
336
+ event.stopPropagation()
337
+ suppressBoxSelectForPointer(event)
338
+ sfxEmitter.emit('sfx:item-pick')
339
+ document.body.style.cursor = 'grabbing'
340
+ useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
341
+ }
342
+
343
+ return (
344
+ <>
345
+ <mesh
346
+ frustumCulled={false}
347
+ geometry={dashedGeometry}
348
+ material={dashMaterial}
349
+ position={[x, 0, z]}
350
+ renderOrder={1001}
351
+ />
352
+ <group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
353
+ <InvisibleHandleHitArea
354
+ geometry={hitGeometry}
355
+ material={hitMaterial}
356
+ onPointerDown={activateEndpointMove}
357
+ onPointerEnter={(event) => {
358
+ event.stopPropagation()
359
+ setIsHovered(true)
360
+ document.body.style.cursor = 'grab'
361
+ }}
362
+ onPointerLeave={(event) => {
363
+ event.stopPropagation()
364
+ setIsHovered(false)
365
+ if (document.body.style.cursor === 'grab') {
366
+ document.body.style.cursor = ''
367
+ }
368
+ }}
369
+ scale={1}
370
+ />
371
+ <group scale={visualScale}>
372
+ <mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
373
+ <circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
374
+ </mesh>
375
+ <mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
376
+ <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
377
+ </mesh>
378
+ </group>
379
+ </group>
380
+ </>
381
+ )
382
+ }
383
+
384
+ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
385
+ const [isHovered, setIsHovered] = useState(false)
386
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
387
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
388
+ const hitMaterial = useInvisibleHitAreaMaterial()
389
+ const arrowMaterial = useMemo(
390
+ () =>
391
+ new MeshBasicNodeMaterial({
392
+ color: new Color(ARROW_COLOR),
393
+ side: DoubleSide,
394
+ depthTest: false,
395
+ depthWrite: false,
396
+ transparent: true,
397
+ opacity: 1,
398
+ }),
399
+ [],
400
+ )
401
+ const { camera, raycaster, gl } = useThree()
402
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
403
+ const baseScale = zoom * ARROW_SCALE
404
+ const scale = (isHovered ? 1.12 : 1) * baseScale
405
+ const dragCleanupRef = useRef<(() => void) | null>(null)
406
+
407
+ useEffect(() => {
408
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
409
+ }, [arrowMaterial, isHovered])
410
+
411
+ useEffect(() => {
412
+ return () => {
413
+ if (document.body.style.cursor === 'ns-resize') {
414
+ document.body.style.cursor = ''
415
+ }
416
+ dragCleanupRef.current?.()
417
+ }
418
+ }, [])
419
+
420
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
421
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
422
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
423
+
424
+ // Sit on the visual centre of the wall — for curved walls that's the
425
+ // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for
426
+ // the yaw so the arrow's local frame matches the wall direction at the
427
+ // apex, consistent with `getWallMoveHandles`.
428
+ const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
429
+ const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2
430
+ const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2
431
+ const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0]
432
+ const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1]
433
+ const wallAngle = Math.atan2(-dirZ, dirX)
434
+ // `wall` is the override-merged effective wall (see
435
+ // WallMoveSideHandlesForWall), so this height is already live during a drag.
436
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
437
+ const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
438
+
439
+ const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
440
+ if (event.button !== 0) return
441
+ event.stopPropagation()
442
+ suppressBoxSelectForPointer(event)
443
+ const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
444
+ if (!levelObject) return
445
+
446
+ // Vertical plane through the wall midpoint whose normal points toward
447
+ // the camera (projected to horizontal). Raycasting against it converts
448
+ // pointer movement into a world-space Y value.
449
+ const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
450
+ const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
451
+ if (planeNormal.lengthSq() === 0) return
452
+ planeNormal.normalize()
453
+ const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
454
+
455
+ const ndc = new Vector2()
456
+ const setNDC = (clientX: number, clientY: number) => {
457
+ const rect = gl.domElement.getBoundingClientRect()
458
+ ndc.set(
459
+ ((clientX - rect.left) / rect.width) * 2 - 1,
460
+ -((clientY - rect.top) / rect.height) * 2 + 1,
461
+ )
462
+ }
463
+
464
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
465
+ raycaster.setFromCamera(ndc, camera)
466
+ const hit = new Vector3()
467
+ if (!raycaster.ray.intersectPlane(plane, hit)) return
468
+
469
+ const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT
470
+ const initialY = hit.y
471
+ const wallId = wall.id as AnyNodeId
472
+ let pendingHeight = initialHeight
473
+
474
+ document.body.style.cursor = 'ns-resize'
475
+ sfxEmitter.emit('sfx:item-pick')
476
+ useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' })
477
+ // Suppress R3F node pointer events until pointerup completes so the
478
+ // synthesized click doesn't reroute selection to whatever mesh sits
479
+ // under the cursor at release.
480
+ useViewer.getState().setInputDragging(true)
481
+ useScene.temporal.getState().pause()
482
+
483
+ // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks
484
+ // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against
485
+ // the override-merged value (via `getEffectiveWall`). Zustand stays
486
+ // at the pre-drag height until pointerup commits one tracked write.
487
+ const onMove = (e: PointerEvent) => {
488
+ setNDC(e.clientX, e.clientY)
489
+ raycaster.setFromCamera(ndc, camera)
490
+ const intersection = new Vector3()
491
+ if (!raycaster.ray.intersectPlane(plane, intersection)) return
492
+ const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
493
+ pendingHeight = newHeight
494
+ useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
495
+ useScene.getState().markDirty(wallId)
496
+ }
497
+
498
+ const cleanup = () => {
499
+ window.removeEventListener('pointermove', onMove)
500
+ window.removeEventListener('pointerup', onUp)
501
+ window.removeEventListener('pointercancel', onCancel)
502
+ window.removeEventListener('keydown', onKeyDown, true)
503
+ if (document.body.style.cursor === 'ns-resize') {
504
+ document.body.style.cursor = ''
505
+ }
506
+ useScene.temporal.getState().resume()
507
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
508
+ useViewer.getState().setInputDragging(false)
509
+ dragCleanupRef.current = null
510
+ }
511
+ const onUp = () => {
512
+ swallowNextClick()
513
+ sfxEmitter.emit('sfx:item-place')
514
+ // Commit: write the final override-merged value to zustand once
515
+ // (tracked, undoable), then drop the override so the renderer
516
+ // falls back to the scene store.
517
+ if (pendingHeight !== initialHeight) {
518
+ useScene.getState().updateNode(wallId, { height: pendingHeight })
519
+ }
520
+ useLiveNodeOverrides.getState().clear(wallId)
521
+ useScene.getState().markDirty(wallId)
522
+ cleanup()
523
+ }
524
+ const onCancel = () => {
525
+ // Revert: drop the override, mark dirty so the geometry rebuilds
526
+ // against the original scene height.
527
+ useLiveNodeOverrides.getState().clear(wallId)
528
+ useScene.getState().markDirty(wallId)
529
+ cleanup()
530
+ }
531
+
532
+ // Escape / ⌘Z abort the drag — capture phase so they win over the global
533
+ // use-keyboard arms (⌘Z must never history-jump under a live pointer).
534
+ const onKeyDown = (e: KeyboardEvent) => {
535
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
536
+ e.preventDefault()
537
+ e.stopPropagation()
538
+ swallowNextClick()
539
+ onCancel()
540
+ }
541
+
542
+ dragCleanupRef.current = cleanup
543
+ window.addEventListener('pointermove', onMove)
544
+ window.addEventListener('pointerup', onUp)
545
+ window.addEventListener('pointercancel', onCancel)
546
+ window.addEventListener('keydown', onKeyDown, true)
547
+ }
548
+
549
+ return (
550
+ <group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
551
+ <group rotation={[0, Math.PI / 2, Math.PI / 2]}>
552
+ <InvisibleHandleHitArea
553
+ geometry={hitGeometry}
554
+ material={hitMaterial}
555
+ onPointerDown={activateHeightResize}
556
+ onPointerEnter={(event) => {
557
+ event.stopPropagation()
558
+ setIsHovered(true)
559
+ document.body.style.cursor = 'ns-resize'
560
+ }}
561
+ onPointerLeave={(event) => {
562
+ event.stopPropagation()
563
+ setIsHovered(false)
564
+ if (document.body.style.cursor === 'ns-resize') {
565
+ document.body.style.cursor = ''
566
+ }
567
+ }}
568
+ scale={baseScale}
569
+ />
570
+ <mesh
571
+ // Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
572
+ frustumCulled={false}
573
+ geometry={arrowGeometry}
574
+ material={arrowMaterial}
575
+ raycast={NO_RAYCAST}
576
+ renderOrder={1002}
577
+ scale={scale}
578
+ />
579
+ </group>
580
+ </group>
581
+ )
582
+ }
583
+
584
+ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
585
+ const [isHovered, setIsHovered] = useState(false)
586
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
587
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
588
+ const hitMaterial = useInvisibleHitAreaMaterial()
589
+ const arrowMaterial = useMemo(
590
+ () =>
591
+ new MeshBasicNodeMaterial({
592
+ color: new Color(ARROW_COLOR),
593
+ side: DoubleSide,
594
+ depthTest: false,
595
+ depthWrite: false,
596
+ transparent: true,
597
+ opacity: 1,
598
+ }),
599
+ [],
600
+ )
601
+ const { camera } = useThree()
602
+
603
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
604
+
605
+ const baseScale = zoom * ARROW_SCALE
606
+ const scale = (isHovered ? 1.12 : 1) * baseScale
607
+
608
+ useEffect(() => {
609
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
610
+ }, [arrowMaterial, isHovered])
611
+
612
+ useEffect(() => {
613
+ return () => {
614
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
615
+ document.body.style.cursor = ''
616
+ }
617
+ }
618
+ }, [])
619
+
620
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
621
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
622
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
623
+
624
+ const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
625
+ if (event.button !== 0) return
626
+ event.stopPropagation()
627
+ suppressBoxSelectForPointer(event)
628
+ document.body.style.cursor = 'grabbing'
629
+
630
+ sfxEmitter.emit('sfx:item-pick')
631
+ useEditor.getState().setMovingNode(wall)
632
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
633
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
634
+ // Keep the wall selected so it stays the active item once the move
635
+ // commits; the `!movingNode` guard on the handles hides them mid-drag.
636
+ }
637
+
638
+ return (
639
+ <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
640
+ <InvisibleHandleHitArea
641
+ geometry={hitGeometry}
642
+ material={hitMaterial}
643
+ onPointerDown={activateWallMove}
644
+ onPointerEnter={(event) => {
645
+ event.stopPropagation()
646
+ setIsHovered(true)
647
+ document.body.style.cursor = 'grab'
648
+ }}
649
+ onPointerLeave={(event) => {
650
+ event.stopPropagation()
651
+ setIsHovered(false)
652
+ if (document.body.style.cursor === 'grab') {
653
+ document.body.style.cursor = ''
654
+ }
655
+ }}
656
+ scale={baseScale}
657
+ />
658
+ <mesh
659
+ // Pass geometry as a prop (not `<primitive attach="geometry">`)
660
+ // so the mesh is never rendered with R3F's default empty
661
+ // `BufferGeometry`. Combined with `frustumCulled={false}`, the
662
+ // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
663
+ // frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
664
+ frustumCulled={false}
665
+ geometry={arrowGeometry}
666
+ material={arrowMaterial}
667
+ raycast={NO_RAYCAST}
668
+ renderOrder={1002}
669
+ scale={scale}
670
+ />
671
+ </group>
672
+ )
673
+ }
674
+
675
+ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
676
+ const [isHovered, setIsHovered] = useState(false)
677
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
678
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
679
+ const hitMaterial = useInvisibleHitAreaMaterial()
680
+ const arrowMaterial = useMemo(
681
+ () =>
682
+ new MeshBasicNodeMaterial({
683
+ color: new Color(ARROW_COLOR),
684
+ side: DoubleSide,
685
+ depthTest: false,
686
+ depthWrite: false,
687
+ transparent: true,
688
+ opacity: 1,
689
+ }),
690
+ [],
691
+ )
692
+ const { camera } = useThree()
693
+
694
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
695
+ const baseScale = zoom * ARROW_SCALE
696
+ const scale = (isHovered ? 1.12 : 1) * baseScale
697
+
698
+ useEffect(() => {
699
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
700
+ }, [arrowMaterial, isHovered])
701
+
702
+ useEffect(() => {
703
+ return () => {
704
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
705
+ document.body.style.cursor = ''
706
+ }
707
+ }
708
+ }, [])
709
+
710
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
711
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
712
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
713
+
714
+ const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
715
+ if (event.button !== 0) return
716
+ event.stopPropagation()
717
+ suppressBoxSelectForPointer(event)
718
+ document.body.style.cursor = 'grabbing'
719
+
720
+ sfxEmitter.emit('sfx:item-pick')
721
+ useEditor.getState().setMovingNode(fence)
722
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
723
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
724
+ // Keep the fence selected so it stays active once the move commits.
725
+ }
726
+
727
+ return (
728
+ <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
729
+ <InvisibleHandleHitArea
730
+ geometry={hitGeometry}
731
+ material={hitMaterial}
732
+ onPointerDown={activateFenceMove}
733
+ onPointerEnter={(event) => {
734
+ event.stopPropagation()
735
+ setIsHovered(true)
736
+ document.body.style.cursor = 'grab'
737
+ }}
738
+ onPointerLeave={(event) => {
739
+ event.stopPropagation()
740
+ setIsHovered(false)
741
+ if (document.body.style.cursor === 'grab') {
742
+ document.body.style.cursor = ''
743
+ }
744
+ }}
745
+ scale={baseScale}
746
+ />
747
+ <mesh
748
+ // Pass geometry as a prop — see WallMoveArrowHandle for the
749
+ // WebGPU "Vertex buffer slot 0 ... was not set" rationale.
750
+ frustumCulled={false}
751
+ geometry={arrowGeometry}
752
+ material={arrowMaterial}
753
+ raycast={NO_RAYCAST}
754
+ renderOrder={1002}
755
+ scale={scale}
756
+ />
757
+ </group>
758
+ )
759
+ }
760
+
761
+ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
762
+ const dx = wall.end[0] - wall.start[0]
763
+ const dz = wall.end[1] - wall.start[1]
764
+ const length = Math.hypot(dx, dz)
765
+
766
+ if (length < 1e-6) {
767
+ return []
768
+ }
769
+
770
+ const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
771
+ const normal: [number, number] = frame
772
+ ? [frame.normal.x, frame.normal.y]
773
+ : [-dz / length, dx / length]
774
+ const midpoint: [number, number] = frame
775
+ ? [frame.point.x, frame.point.y]
776
+ : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
777
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
778
+ const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
779
+ const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
780
+
781
+ return [
782
+ buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
783
+ buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
784
+ ]
785
+ }
786
+
787
+ function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) {
788
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
789
+ fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null,
790
+ )
791
+
792
+ useEffect(() => {
793
+ let frameId = 0
794
+
795
+ const resolveLevelObject = () => {
796
+ const nextLevelObject = fence.parentId
797
+ ? (sceneRegistry.nodes.get(fence.parentId) ?? null)
798
+ : null
799
+ setLevelObject((currentLevelObject) => {
800
+ if (currentLevelObject === nextLevelObject) {
801
+ return currentLevelObject
802
+ }
803
+ return nextLevelObject
804
+ })
805
+
806
+ if (!nextLevelObject) {
807
+ frameId = window.requestAnimationFrame(resolveLevelObject)
808
+ }
809
+ }
810
+
811
+ resolveLevelObject()
812
+
813
+ return () => {
814
+ if (frameId) {
815
+ window.cancelAnimationFrame(frameId)
816
+ }
817
+ }
818
+ }, [fence.parentId])
819
+
820
+ const handles = useMemo(() => getFenceMoveHandles(fence), [fence])
821
+
822
+ if (!levelObject || handles.length === 0) return null
823
+
824
+ return createPortal(
825
+ <group>
826
+ {handles.map((handle) => (
827
+ <FenceMoveArrowHandle fence={fence} handle={handle} key={handle.key} />
828
+ ))}
829
+ </group>,
830
+ levelObject,
831
+ )
832
+ }
833
+
834
+ function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] {
835
+ const dx = fence.end[0] - fence.start[0]
836
+ const dz = fence.end[1] - fence.start[1]
837
+ const length = Math.hypot(dx, dz)
838
+
839
+ if (length < 1e-6) {
840
+ return []
841
+ }
842
+
843
+ const midpoint: [number, number] = [
844
+ (fence.start[0] + fence.end[0]) / 2,
845
+ (fence.start[1] + fence.end[1]) / 2,
846
+ ]
847
+ const normal: [number, number] = [-dz / length, dx / length]
848
+ const fenceHeight = fence.height ?? 1.8
849
+ const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
850
+ const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
851
+
852
+ return [
853
+ buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
854
+ buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
855
+ ]
856
+ }
857
+
858
+ function buildWallMoveHandle(
859
+ key: string,
860
+ midpoint: [number, number],
861
+ direction: [number, number],
862
+ offset: number,
863
+ height: number,
864
+ ): WallMoveHandle {
865
+ return {
866
+ key,
867
+ position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
868
+ rotationY: Math.atan2(-direction[1], direction[0]),
869
+ }
870
+ }