@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
6
+ import {
7
+ createQuickMeasurementPointerScheduler,
8
+ resolveQuickMeasurementReport,
9
+ } from '../../lib/quick-measurement'
10
+ import {
11
+ activateQuickMeasurementHudSource,
12
+ clearQuickMeasurementHudSource,
13
+ publishQuickMeasurementHudSource,
14
+ } from '../../store/use-quick-measurement-hud'
15
+ import { useFloorplanRender } from './floorplan-render-context'
16
+
17
+ type FloorplanQuickMeasureHit = {
18
+ nodeId: string
19
+ point: { x: number; y: number }
20
+ }
21
+
22
+ function nodeIdFromElement(element: Element | null): string | null {
23
+ const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
24
+ return entry?.dataset.nodeId ?? null
25
+ }
26
+
27
+ function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
28
+ const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
29
+ if (direct) return direct
30
+ for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
31
+ if (!svg.contains(element)) continue
32
+ const nodeId = nodeIdFromElement(element)
33
+ if (nodeId) return nodeId
34
+ }
35
+ return null
36
+ }
37
+
38
+ function pointAtPointer(
39
+ group: SVGGElement,
40
+ event: PointerEvent,
41
+ ): FloorplanQuickMeasureHit['point'] | null {
42
+ const matrix = group.getScreenCTM()
43
+ if (!matrix) return null
44
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
45
+ return { x: point.x, y: point.y }
46
+ }
47
+
48
+ function FloorplanSmartMarker({
49
+ hit,
50
+ pinned,
51
+ unitsPerPixel,
52
+ markerRef,
53
+ }: {
54
+ hit?: FloorplanQuickMeasureHit
55
+ pinned: boolean
56
+ unitsPerPixel: number
57
+ markerRef?: RefObject<SVGGElement | null>
58
+ }) {
59
+ const localRef = useRef<SVGGElement>(null)
60
+ const ref = markerRef ?? localRef
61
+
62
+ useLayoutEffect(() => {
63
+ if (!hit) ref.current?.setAttribute('display', 'none')
64
+ }, [hit, ref])
65
+
66
+ return (
67
+ <g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
68
+ <circle
69
+ cx={0}
70
+ cy={0}
71
+ fill="none"
72
+ r={(pinned ? 11 : 8) * unitsPerPixel}
73
+ stroke={pinned ? '#0e7490' : '#0891b2'}
74
+ strokeWidth={pinned ? 2.5 : 2}
75
+ vectorEffect="non-scaling-stroke"
76
+ />
77
+ {pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
78
+ </g>
79
+ )
80
+ }
81
+
82
+ function showFloorplanSmartMarker(
83
+ marker: SVGGElement | null,
84
+ point: FloorplanQuickMeasureHit['point'],
85
+ ) {
86
+ if (!marker) return
87
+ marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
88
+ marker.removeAttribute('display')
89
+ }
90
+
91
+ function hideFloorplanSmartMarker(marker: SVGGElement | null) {
92
+ marker?.setAttribute('display', 'none')
93
+ }
94
+
95
+ export function FloorplanQuickMeasureLayer() {
96
+ const groupRef = useRef<SVGGElement>(null)
97
+ const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
98
+ const hoverNodeIdRef = useRef<string | null>(null)
99
+ const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
100
+ const candidateHasReportRef = useRef(false)
101
+ const hoverMarkerRef = useRef<SVGGElement>(null)
102
+ const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
103
+ const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
104
+ const nodes = useScene((state) => state.nodes)
105
+ const candidateNodesRef = useRef(nodes)
106
+ const levelId = useViewer((state) => state.selection.levelId)
107
+ const levelRef = useRef(levelId)
108
+ const renderContext = useFloorplanRender()
109
+ const hoverReport = useMemo(
110
+ () => resolveQuickMeasurementReport(hoverNodeId, nodes),
111
+ [hoverNodeId, nodes],
112
+ )
113
+ const pinnedReport = useMemo(
114
+ () => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
115
+ [pinned?.nodeId, nodes],
116
+ )
117
+
118
+ useEffect(() => {
119
+ if (levelRef.current === levelId) return
120
+ levelRef.current = levelId
121
+ hoverRef.current = null
122
+ hoverNodeIdRef.current = null
123
+ candidateNodeIdRef.current = undefined
124
+ candidateHasReportRef.current = false
125
+ hideFloorplanSmartMarker(hoverMarkerRef.current)
126
+ setHoverNodeId(null)
127
+ setPinned(null)
128
+ }, [levelId])
129
+
130
+ useEffect(() => {
131
+ const group = groupRef.current
132
+ const svg = groupRef.current?.ownerSVGElement
133
+ if (!(group && svg)) return
134
+ const updateHover = (next: FloorplanQuickMeasureHit | null) => {
135
+ hoverRef.current = next
136
+ if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
137
+ else hideFloorplanSmartMarker(hoverMarkerRef.current)
138
+ const nextNodeId = next?.nodeId ?? null
139
+ if (nextNodeId === hoverNodeIdRef.current) return
140
+ hoverNodeIdRef.current = nextNodeId
141
+ setHoverNodeId(nextNodeId)
142
+ }
143
+ const processPointerMove = (event: PointerEvent) => {
144
+ activateQuickMeasurementHudSource('2d')
145
+ const candidateNodeId = nodeIdAtPointer(svg, event)
146
+ const sceneNodes = useScene.getState().nodes
147
+ if (candidateNodesRef.current !== sceneNodes) {
148
+ candidateNodesRef.current = sceneNodes
149
+ candidateNodeIdRef.current = undefined
150
+ }
151
+ if (candidateNodeId !== candidateNodeIdRef.current) {
152
+ candidateNodeIdRef.current = candidateNodeId
153
+ candidateHasReportRef.current = Boolean(
154
+ resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
155
+ )
156
+ }
157
+ const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
158
+ updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
159
+ }
160
+ const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
161
+ const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
162
+ const clear = () => {
163
+ pointerScheduler.clear()
164
+ updateHover(null)
165
+ }
166
+ const onClick = (event: MouseEvent) => {
167
+ const next = hoverRef.current
168
+ if (!(next && event.button === 0)) return
169
+ event.preventDefault()
170
+ event.stopImmediatePropagation()
171
+ activateQuickMeasurementHudSource('2d')
172
+ setPinned(next)
173
+ }
174
+
175
+ svg.addEventListener('pointermove', onPointerMove, true)
176
+ svg.addEventListener('pointerleave', clear)
177
+ svg.addEventListener('click', onClick, true)
178
+ return () => {
179
+ svg.removeEventListener('pointermove', onPointerMove, true)
180
+ svg.removeEventListener('pointerleave', clear)
181
+ svg.removeEventListener('click', onClick, true)
182
+ pointerScheduler.clear()
183
+ }
184
+ }, [])
185
+
186
+ const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
187
+ const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
188
+ const report = hoverReport ?? pinnedReport
189
+ const lensState =
190
+ pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
191
+
192
+ useEffect(() => {
193
+ publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
194
+ }, [lensState, report])
195
+
196
+ useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
197
+
198
+ return (
199
+ <g pointerEvents="none" ref={groupRef}>
200
+ {pinned && pinnedReport ? (
201
+ <FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
202
+ ) : null}
203
+ <FloorplanSmartMarker
204
+ markerRef={hoverMarkerRef}
205
+ pinned={false}
206
+ unitsPerPixel={unitsPerPixel}
207
+ />
208
+ </g>
209
+ )
210
+ }
211
+
212
+ export default FloorplanQuickMeasureLayer
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
6
+ import {
7
+ type FloorplanToolContext,
8
+ getFloorplanNodeExtension,
9
+ } from '../../lib/floorplan/floorplan-extension'
10
+ import {
11
+ type FloorplanMode,
12
+ isFloorplanToolAvailableInMode,
13
+ } from '../../lib/floorplan/floorplan-mode'
14
+ import useEditor from '../../store/use-editor'
15
+ import useFloorplanMode from '../../store/use-floorplan-mode'
16
+
17
+ const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
18
+
19
+ function registeredFloorplanTool(
20
+ tool: string | null,
21
+ mode: FloorplanMode,
22
+ ): ComponentType<FloorplanToolContext> | null {
23
+ if (!tool) return null
24
+ const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
25
+ if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
26
+ const loader = extension?.tool
27
+ if (!loader) return null
28
+ const cached = lazyToolCache.get(loader)
29
+ if (cached) return cached
30
+ const component = lazy(loader)
31
+ lazyToolCache.set(loader, component)
32
+ return component
33
+ }
34
+
35
+ export function FloorplanRegisteredToolLayer() {
36
+ const mode = useEditor((state) => state.mode)
37
+ const tool = useEditor((state) => state.tool)
38
+ const floorplanMode = useFloorplanMode((state) => state.mode)
39
+ const gridSnapStep = useEditor((state) => state.gridSnapStep)
40
+ const toolDefaults = useEditor((state) =>
41
+ state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
42
+ )
43
+ const activeLevelId = useViewer((state) => state.selection.levelId)
44
+ const unit = useViewer((state) => state.unit)
45
+ const metricNotation = useViewer((state) => state.metricNotation)
46
+ const sceneApi = useMemo(() => createSceneApi(useScene), [])
47
+ const selectNode = useCallback(
48
+ (id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
49
+ useViewer.getState().setSelection({ selectedIds: [id] }),
50
+ [],
51
+ )
52
+ const finishTool = useCallback(() => {
53
+ useEditor.getState().setTool(null)
54
+ useEditor.getState().setMode('select')
55
+ }, [])
56
+ if (mode !== 'build') return null
57
+ const Tool = registeredFloorplanTool(tool, floorplanMode)
58
+ return Tool ? (
59
+ <Suspense fallback={null}>
60
+ <Tool
61
+ activeLevelId={activeLevelId}
62
+ finishTool={finishTool}
63
+ gridSnapStep={gridSnapStep}
64
+ metricNotation={metricNotation}
65
+ sceneApi={sceneApi}
66
+ selectNode={selectNode}
67
+ toolDefaults={toolDefaults}
68
+ unit={unit}
69
+ />
70
+ </Suspense>
71
+ ) : null
72
+ }
@@ -4,19 +4,97 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
+ createSceneApi,
7
8
  getWallMidpointHandlePoint,
9
+ type NodeQuickAction,
8
10
  nodeRegistry,
11
+ runAsSingleSceneHistoryStep,
9
12
  type SlabNode,
10
13
  useLiveNodeOverrides,
11
14
  useScene,
12
15
  type WallNode,
13
16
  } from '@pascal-app/core'
14
17
  import { useViewer } from '@pascal-app/viewer'
15
- import { useEffect, useState } from 'react'
18
+ import { type MouseEvent, useEffect, useState } from 'react'
16
19
  import { createPortal } from 'react-dom'
20
+ import { useShallow } from 'zustand/react/shallow'
21
+ import { useReducedMotion } from '../../hooks/use-reduced-motion'
22
+ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
23
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
24
+ import {
25
+ createFreshPlacementSubtree,
26
+ duplicatesAsFreshSubtree,
27
+ prepareFreshPlacementRootDuplicate,
28
+ } from '../../lib/fresh-planar-placement'
29
+ import { curveReshapeScope } from '../../lib/interaction/scope'
30
+ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
31
+ import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
17
32
  import { sfxEmitter } from '../../lib/sfx-bus'
33
+ import { cn } from '../../lib/utils'
18
34
  import useEditor from '../../store/use-editor'
35
+ import useInteractionScope, {
36
+ useIsCurveReshape,
37
+ useMovingNode,
38
+ } from '../../store/use-interaction-scope'
19
39
  import { NodeActionMenu } from '../editor/node-action-menu'
40
+ import { IconRefGlyph } from '../ui/icon-ref'
41
+
42
+ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
43
+ return (
44
+ <svg
45
+ aria-hidden="true"
46
+ className="h-3.5 w-3.5"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ strokeWidth="1.7"
52
+ viewBox="0 0 24 24"
53
+ >
54
+ <rect x="8" y="7" width="9" height="10" rx="1.75" />
55
+ <path d="M12.5 7v10" />
56
+ {direction === 'left' ? (
57
+ <>
58
+ <path d="M6.5 12H2.75" />
59
+ <path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
60
+ </>
61
+ ) : (
62
+ <>
63
+ <path d="M17.5 12h3.75" />
64
+ <path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
65
+ </>
66
+ )}
67
+ </svg>
68
+ )
69
+ }
70
+
71
+ // Builtin string tokens map to the generic glyphs above; kind-owned marks
72
+ // arrive as IconRef objects and render through the shared IconRefGlyph.
73
+ // Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
74
+ function QuickActionIcon({ action }: { action: NodeQuickAction }) {
75
+ const icon = action.icon
76
+ if (!icon) return null
77
+ if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
78
+ switch (icon) {
79
+ case 'add-left':
80
+ return <SideAddGlyph direction="left" />
81
+ case 'add-right':
82
+ return <SideAddGlyph direction="right" />
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ function collectQuickActionNodes(
89
+ nodes: Record<AnyNodeId, AnyNode>,
90
+ selectedId: string | null,
91
+ ): Record<AnyNodeId, AnyNode> | null {
92
+ if (!selectedId) return null
93
+ const selected = nodes[selectedId as AnyNodeId]
94
+ const def = selected ? nodeRegistry.get(selected.type) : undefined
95
+ if (!def?.quickActions) return null
96
+ return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
97
+ }
20
98
 
21
99
  /**
22
100
  * Floating Move / Duplicate / Delete buttons that appear above the
@@ -34,30 +112,68 @@ import { NodeActionMenu } from '../editor/node-action-menu'
34
112
  * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
35
113
  * Walls are excluded — their move is reached via the side-arrow
36
114
  * handles emitted from `def.floorplan`, not via a menu button.
115
+ * - Curve (wall only): enters curve reshape mode. The selected wall's
116
+ * midpoint curve handle remains visible so it can be dragged in plan.
37
117
  * - Add hole (slab + ceiling only): inserts a small default-square
38
118
  * hole at the polygon centroid via `updateNode`. Mirrors the legacy
39
119
  * `handleAddHole` in `floating-action-menu.tsx`.
40
- * - Duplicate: deep-clones the node, marks it new, sets it as the
41
- * movingNode (placement cursor) same UX pattern as 3D duplicate.
120
+ * - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
121
+ * root-only copy, then hands that real draft to the placement cursor.
42
122
  * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
43
123
  * `relations.cascadeDelete` if declared on the def.
44
124
  *
45
- * Hidden while in a move state (so we don't show buttons over a ghost).
125
+ * Hidden while moving or curving so the menu does not compete with the active affordance.
46
126
  */
47
127
  export function FloorplanRegistryActionMenu() {
48
- const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
49
- const movingNode = useEditor((s) => s.movingNode)
128
+ const reducedMotion = useReducedMotion()
129
+ // Sole selection only a multi-selection gets the group menu
130
+ // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
131
+ const selectedId = useViewer((s) =>
132
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
133
+ ) as AnyNodeId | undefined
134
+ const movingNode = useMovingNode()
135
+ const isCurveReshape = useIsCurveReshape()
50
136
  const setMovingNode = useEditor((s) => s.setMovingNode)
137
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
138
+ // Gate on floorplan hover so this 2D menu never coexists with the 3D
139
+ // FloatingActionMenu in split view — that menu hides while the floorplan
140
+ // is hovered, so this one must only show then. Mirrors the legacy
141
+ // FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
142
+ // duct) shows two Duplicate buttons whenever the pointer is outside the
143
+ // 2D panel.
144
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
51
145
 
52
146
  const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
53
147
 
54
148
  // Only show for registered kinds (skip legacy kinds — they have their
55
149
  // own FloorplanActionMenuLayer entries).
56
150
  const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
151
+ const canCurve = useScene((s) => {
152
+ if (!selectedId) return false
153
+ const selectedNode = s.nodes[selectedId]
154
+ if (!selectedNode) return false
155
+ const definition = nodeRegistry.get(selectedNode.type)
156
+ const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
157
+ return (
158
+ !!definition?.floorplanAffordances?.curve &&
159
+ !!canCurveNode?.({
160
+ node: selectedNode as never,
161
+ nodes: s.nodes,
162
+ })
163
+ )
164
+ })
57
165
  const def = selectedKind ? nodeRegistry.get(selectedKind) : null
58
166
  const isRegistryKind = !!def
59
- const isVisible = isRegistryKind && !movingNode
167
+ const isVisible =
168
+ isRegistryKind &&
169
+ def?.presentation?.actionMenu !== false &&
170
+ !movingNode &&
171
+ !isCurveReshape &&
172
+ isFloorplanHovered
60
173
  const isWall = selectedKind === 'wall'
174
+ const quickActionNodes = useScene(
175
+ useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
176
+ )
61
177
 
62
178
  useEffect(() => {
63
179
  if (!(isVisible && selectedId)) {
@@ -117,6 +233,14 @@ export function FloorplanRegistryActionMenu() {
117
233
  const node = useScene.getState().nodes[selectedId]
118
234
  if (!node) return null
119
235
 
236
+ const quickActions =
237
+ quickActionNodes && nodeRegistry.get(node.type)?.quickActions
238
+ ? (nodeRegistry.get(node.type)?.quickActions?.({
239
+ node: node as never,
240
+ nodes: quickActionNodes,
241
+ }) ?? [])
242
+ : []
243
+
120
244
  // Move button is enabled when any of:
121
245
  // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
122
246
  // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
@@ -133,7 +257,13 @@ export function FloorplanRegistryActionMenu() {
133
257
 
134
258
  const handleMove = () => {
135
259
  sfxEmitter.emit('sfx:item-pick')
136
- setMovingNode(node as never)
260
+ const sceneNodes = useScene.getState().nodes
261
+ setMovingNode(resolveMoveActionNode(node, sceneNodes) as never)
262
+ // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
263
+ // Mark the origin (after `setMovingNode`, which resets it to null) so
264
+ // `ToolManager` keeps the 3D affordance mover from also adopting the node
265
+ // and reverting it on unmount. Mirrors the orange move-dot path.
266
+ setMovingNodeOrigin('2d')
137
267
  // Match the legacy 3D `floating-action-menu`: clear selection so
138
268
  // selection-gated affordances unmount during the drag. Specifically
139
269
  // the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -181,21 +311,40 @@ export function FloorplanRegistryActionMenu() {
181
311
  )
182
312
  }
183
313
 
314
+ const handleCurve = () => {
315
+ if (!canCurve) return
316
+ sfxEmitter.emit('sfx:item-pick')
317
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
318
+ }
319
+
184
320
  const handleDuplicate = () => {
185
321
  if (!node.parentId) return
186
322
  sfxEmitter.emit('sfx:item-pick')
187
323
  useScene.temporal.getState().pause()
188
- const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
189
- delete (cloned as { id?: AnyNodeId }).id
190
- const prevMeta =
191
- cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
192
- ? (cloned.metadata as Record<string, unknown>)
193
- : {}
194
- cloned.metadata = { ...prevMeta, isNew: true }
195
- const parsed = def.schema.parse(cloned) as AnyNode
196
- useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
197
- setMovingNode(parsed as never)
198
- useScene.temporal.getState().resume()
324
+ let draftId: AnyNodeId | null = null
325
+ try {
326
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
327
+ draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
328
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
329
+ if (!draft) return
330
+ setMovingNode(draft as never)
331
+ } else {
332
+ const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
333
+ const parsed = def.schema.parse(cloned) as AnyNode
334
+ draftId = parsed.id as AnyNodeId
335
+ useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
336
+ setMovingNode(parsed as never)
337
+ }
338
+ setMovingNodeOrigin('2d')
339
+ useViewer.getState().setSelection({ selectedIds: [] })
340
+ } catch (error) {
341
+ if (draftId && useScene.getState().nodes[draftId]) {
342
+ useScene.getState().deleteNode(draftId)
343
+ }
344
+ console.error('Failed to duplicate node', error)
345
+ } finally {
346
+ useScene.temporal.getState().resume()
347
+ }
199
348
  }
200
349
 
201
350
  const handleDelete = () => {
@@ -204,9 +353,25 @@ export function FloorplanRegistryActionMenu() {
204
353
  useViewer.getState().setSelection({ selectedIds: [] })
205
354
  }
206
355
 
356
+ const handleQuickAction = (action: NodeQuickAction, event: MouseEvent<HTMLButtonElement>) => {
357
+ if (action.disabled) {
358
+ if (action.blockedFeedback) {
359
+ playBlockedQuickActionFeedback(event.currentTarget, reducedMotion)
360
+ }
361
+ return
362
+ }
363
+ const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
364
+ const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
365
+ if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
366
+ if (result?.selectedIds) {
367
+ const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
368
+ sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
369
+ }
370
+ }
371
+
207
372
  return createPortal(
208
373
  <div
209
- className="pointer-events-none fixed z-30"
374
+ className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
210
375
  style={{
211
376
  left: position.left,
212
377
  top: position.top,
@@ -215,12 +380,46 @@ export function FloorplanRegistryActionMenu() {
215
380
  >
216
381
  <NodeActionMenu
217
382
  onAddHole={canAddHole ? handleAddHole : undefined}
383
+ onCurve={canCurve ? handleCurve : undefined}
218
384
  onDelete={canDelete ? handleDelete : undefined}
219
385
  onDuplicate={canDuplicate ? handleDuplicate : undefined}
220
386
  onMove={canMove ? handleMove : undefined}
221
387
  onPointerDown={(event) => event.stopPropagation()}
222
388
  onPointerUp={(event) => event.stopPropagation()}
223
389
  />
390
+ {quickActions.length > 0 ? (
391
+ <div
392
+ className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
393
+ onPointerDown={(event) => event.stopPropagation()}
394
+ onPointerUp={(event) => event.stopPropagation()}
395
+ >
396
+ {quickActions.map((action) => (
397
+ <button
398
+ aria-disabled={action.disabled || undefined}
399
+ aria-label={action.title ?? action.label}
400
+ className={cn(
401
+ 'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
402
+ action.disabled &&
403
+ 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
404
+ )}
405
+ disabled={action.disabled && !action.blockedFeedback}
406
+ key={action.id}
407
+ onClick={(event) => handleQuickAction(action, event)}
408
+ title={action.title ?? action.label}
409
+ type="button"
410
+ >
411
+ <span className="flex items-center gap-1.5" data-quick-action-feedback>
412
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
413
+ <QuickActionIcon action={action} />
414
+ </span>
415
+ <span className="whitespace-nowrap leading-none" data-quick-action-label>
416
+ {action.label}
417
+ </span>
418
+ </span>
419
+ </button>
420
+ ))}
421
+ </div>
422
+ ) : null}
224
423
  </div>,
225
424
  document.body,
226
425
  )