@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
@@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 =>
25
25
  Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
26
26
  const isVec2 = (v: unknown): v is Vec2 =>
27
27
  Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
28
+ const isVec2Array = (v: unknown): v is Vec2[] => Array.isArray(v) && v.length > 0 && v.every(isVec2)
28
29
 
29
30
  // How a participant's placement transforms rigidly around / with the group:
30
31
  // - 'vec3' position + [x,y,z] rotation (items, …)
31
32
  // - 'scalar' position + numeric rotation (columns)
32
33
  // - 'endpoint' start/end tuples (walls, fences)
33
- export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
34
+ // - 'polygon' [x,z] vertex arrays (slabs, ceilings, zones) the placement
35
+ // lives in the vertices themselves (plus optional hole rings)
36
+ export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' | 'polygon'
34
37
 
35
38
  // A selected node qualifies when it belongs to the active level's horizontal
36
39
  // frame: either parented to that level, or declared building-scoped and parented
@@ -75,27 +78,39 @@ function getParticipantScalarRotation(node: AnyNode): number | null {
75
78
  return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
76
79
  }
77
80
 
81
+ // Shape-only classification of a positioned placement (no level-scope check).
82
+ // Used for polygon hosts' attached children, whose parent is the host rather
83
+ // than the level.
84
+ function classifyPlacementShape(node: AnyNode): 'vec3' | 'scalar' | null {
85
+ const p = getParticipantPosition(node)
86
+ const r = (node as { rotation?: unknown }).rotation
87
+ if (isVec3(p) && isVec3(r)) return 'vec3'
88
+ if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
89
+ return null
90
+ }
91
+
78
92
  export function classifyParticipant(
79
93
  node: AnyNode | undefined,
80
94
  levelId: string | null,
81
95
  sceneNodes: Record<string, AnyNode | undefined>,
82
96
  ): ParticipantKind | null {
83
97
  if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
84
- const p = getParticipantPosition(node)
85
- const r = (node as { rotation?: unknown }).rotation
98
+ const shape = classifyPlacementShape(node)
99
+ if (shape) return shape
86
100
  const start = (node as { start?: unknown }).start
87
101
  const end = (node as { end?: unknown }).end
88
- if (isVec3(p) && isVec3(r)) return 'vec3'
89
- if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
90
102
  if (isVec2(start) && isVec2(end)) return 'endpoint'
103
+ if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon'
91
104
  return null
92
105
  }
93
106
 
94
- // Pre-drag placement snapshot + how to transform it.
107
+ // Pre-drag placement snapshot + how to transform it. `holes` is null when the
108
+ // kind carries no holes field (zone), so patches never write one onto it.
95
109
  export type ParticipantStart =
96
110
  | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
97
111
  | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
98
112
  | { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
113
+ | { id: AnyNodeId; kind: 'polygon'; polygon: Vec2[]; holes: Vec2[][] | null }
99
114
 
100
115
  // An unselected wall/fence sharing a junction with a transforming endpoint. Only
101
116
  // the touching endpoint(s) follow, so the neighbour stays attached while its far
@@ -143,7 +158,7 @@ export function collectParticipants(
143
158
  position: [position[0], position[1], position[2]],
144
159
  rotation,
145
160
  })
146
- } else {
161
+ } else if (kind === 'endpoint') {
147
162
  const n = node as AnyNode & { start: Vec2; end: Vec2 }
148
163
  starts.push({
149
164
  id: id as AnyNodeId,
@@ -151,6 +166,56 @@ export function collectParticipants(
151
166
  start: [n.start[0], n.start[1]],
152
167
  end: [n.end[0], n.end[1]],
153
168
  })
169
+ } else {
170
+ const n = node as AnyNode & { polygon: Vec2[]; holes?: Vec2[][] }
171
+ starts.push({
172
+ id: id as AnyNodeId,
173
+ kind,
174
+ polygon: n.polygon.map(([x, z]) => [x, z] as Vec2),
175
+ holes: Array.isArray(n.holes)
176
+ ? n.holes.map((hole) => hole.map(([x, z]) => [x, z] as Vec2))
177
+ : null,
178
+ })
179
+ }
180
+ }
181
+
182
+ // Polygon hosts (slab/ceiling) rebuild their geometry from vertices rather
183
+ // than transforming a group, so — unlike wall children, which ride the wall
184
+ // mesh — their attached children (ceiling-mounted items) must transform
185
+ // explicitly. Their positions are stored in the level frame (the host group
186
+ // sits at the origin), so the same rigid patches apply.
187
+ const includedIds = new Set<string>(starts.map((s) => s.id))
188
+ for (const s of [...starts]) {
189
+ if (s.kind !== 'polygon') continue
190
+ const host = sceneNodes[s.id]
191
+ const childIds = (host as { children?: string[] } | undefined)?.children
192
+ if (!Array.isArray(childIds)) continue
193
+ for (const childId of childIds) {
194
+ if (includedIds.has(childId)) continue
195
+ const child = sceneNodes[childId]
196
+ if (!child) continue
197
+ const shape = classifyPlacementShape(child)
198
+ const position = child ? getParticipantPosition(child) : null
199
+ if (!(shape && position)) continue
200
+ if (shape === 'vec3') {
201
+ const c = child as AnyNode & { rotation: Vec3 }
202
+ starts.push({
203
+ id: childId as AnyNodeId,
204
+ kind: 'vec3',
205
+ position: [position[0], position[1], position[2]],
206
+ rotation: [c.rotation[0], c.rotation[1], c.rotation[2]],
207
+ })
208
+ } else {
209
+ const rotation = getParticipantScalarRotation(child)
210
+ if (rotation === null) continue
211
+ starts.push({
212
+ id: childId as AnyNodeId,
213
+ kind: 'scalar',
214
+ position: [position[0], position[1], position[2]],
215
+ rotation,
216
+ })
217
+ }
218
+ includedIds.add(childId)
154
219
  }
155
220
  }
156
221
 
@@ -219,6 +284,174 @@ export function expandToComponent(
219
284
  return Array.from(included)
220
285
  }
221
286
 
287
+ // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
288
+ // preview and for the single batched `updateNodes` on commit.
289
+ export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
290
+
291
+ // Rigid group rotation: orbit each participant's anchor point(s) CCW by
292
+ // `delta` (atan2 x→z sense) around `center` (level-frame XZ) and turn yaws by
293
+ // `-delta` to match three.js Y-rotation handedness (same convention as the
294
+ // single-item rotate handle in item/definition.ts). Endpoint nodes
295
+ // (walls/fences) have no yaw — swinging both endpoints around the pivot
296
+ // rotates them rigidly; their curveOffset sagitta is rotation-invariant, so
297
+ // arcs are preserved. Linked neighbours' shared endpoints land exactly on the
298
+ // selected wall's rotated endpoint (rot is deterministic), keeping junctions
299
+ // welded while the far end stays put.
300
+ export function rotateGroupPatches(
301
+ starts: ParticipantStart[],
302
+ links: LinkedNeighbor[],
303
+ center: { x: number; z: number },
304
+ delta: number,
305
+ ): GroupPatch[] {
306
+ const cos = Math.cos(delta)
307
+ const sin = Math.sin(delta)
308
+ const rot = (x: number, z: number): Vec2 => {
309
+ const dx = x - center.x
310
+ const dz = z - center.z
311
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
312
+ }
313
+ const patches: GroupPatch[] = []
314
+ for (const s of starts) {
315
+ if (s.kind === 'endpoint') {
316
+ patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }])
317
+ } else if (s.kind === 'polygon') {
318
+ const patch: Record<string, unknown> = { polygon: s.polygon.map(([x, z]) => rot(x, z)) }
319
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(([x, z]) => rot(x, z)))
320
+ patches.push([s.id, patch])
321
+ } else {
322
+ const [px, pz] = rot(s.position[0], s.position[2])
323
+ const position: Vec3 = [px, s.position[1], pz]
324
+ const rotation =
325
+ s.kind === 'vec3'
326
+ ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
327
+ : s.rotation - delta
328
+ patches.push([s.id, { position, rotation }])
329
+ }
330
+ }
331
+ for (const l of links) {
332
+ patches.push([
333
+ l.id,
334
+ {
335
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
336
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
337
+ },
338
+ ])
339
+ }
340
+ return patches
341
+ }
342
+
343
+ // Rotate the SNAPSHOTS themselves (same math as `rotateGroupPatches`, but
344
+ // producing new snapshot shapes instead of node patches). Lets an in-flight
345
+ // group move re-seed its rest state after a mid-drag R/T: subsequent
346
+ // translate patches then place the rotated layout at the live delta.
347
+ export function rotateGroupSnapshots(
348
+ starts: ParticipantStart[],
349
+ links: LinkedNeighbor[],
350
+ center: { x: number; z: number },
351
+ delta: number,
352
+ ): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
353
+ const cos = Math.cos(delta)
354
+ const sin = Math.sin(delta)
355
+ const rot = (x: number, z: number): Vec2 => {
356
+ const dx = x - center.x
357
+ const dz = z - center.z
358
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
359
+ }
360
+ const rotatedStarts = starts.map((s): ParticipantStart => {
361
+ if (s.kind === 'endpoint') {
362
+ return { ...s, start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }
363
+ }
364
+ if (s.kind === 'polygon') {
365
+ return {
366
+ ...s,
367
+ polygon: s.polygon.map(([x, z]) => rot(x, z)),
368
+ holes: s.holes ? s.holes.map((hole) => hole.map(([x, z]) => rot(x, z))) : null,
369
+ }
370
+ }
371
+ const [px, pz] = rot(s.position[0], s.position[2])
372
+ const position: Vec3 = [px, s.position[1], pz]
373
+ if (s.kind === 'vec3') {
374
+ return { ...s, position, rotation: [s.rotation[0], s.rotation[1] - delta, s.rotation[2]] }
375
+ }
376
+ return { ...s, position, rotation: s.rotation - delta }
377
+ })
378
+ // Only the welded (linked) endpoints follow the rotation; the far ends
379
+ // stay put, exactly like the per-tick patch path.
380
+ const rotatedLinks = links.map(
381
+ (l): LinkedNeighbor => ({
382
+ ...l,
383
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
384
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
385
+ }),
386
+ )
387
+ return { starts: rotatedStarts, links: rotatedLinks }
388
+ }
389
+
390
+ // Level-frame XZ extents of the participant DATA — the mesh-free sibling of
391
+ // `computeGroupBox`, used when meshes aren't mounted yet and to re-seed
392
+ // alignment anchors after a mid-drag rotation.
393
+ export function participantExtents(
394
+ starts: ParticipantStart[],
395
+ ): { minX: number; minZ: number; maxX: number; maxZ: number } | null {
396
+ let minX = Number.POSITIVE_INFINITY
397
+ let minZ = Number.POSITIVE_INFINITY
398
+ let maxX = Number.NEGATIVE_INFINITY
399
+ let maxZ = Number.NEGATIVE_INFINITY
400
+ const reach = (x: number, z: number) => {
401
+ minX = Math.min(minX, x)
402
+ minZ = Math.min(minZ, z)
403
+ maxX = Math.max(maxX, x)
404
+ maxZ = Math.max(maxZ, z)
405
+ }
406
+ for (const s of starts) {
407
+ if (s.kind === 'endpoint') {
408
+ reach(s.start[0], s.start[1])
409
+ reach(s.end[0], s.end[1])
410
+ } else if (s.kind === 'polygon') {
411
+ for (const [x, z] of s.polygon) {
412
+ reach(x, z)
413
+ }
414
+ } else {
415
+ reach(s.position[0], s.position[2])
416
+ }
417
+ }
418
+ if (!Number.isFinite(minX)) return null
419
+ return { minX, minZ, maxX, maxZ }
420
+ }
421
+
422
+ // Rigid group slide: shift every participant (and each linked neighbour's
423
+ // shared endpoint) by the same level-frame XZ delta. Y and rotations untouched.
424
+ export function translateGroupPatches(
425
+ starts: ParticipantStart[],
426
+ links: LinkedNeighbor[],
427
+ dx: number,
428
+ dz: number,
429
+ ): GroupPatch[] {
430
+ const patches: GroupPatch[] = []
431
+ const shift = ([x, z]: Vec2): Vec2 => [x + dx, z + dz]
432
+ for (const s of starts) {
433
+ if (s.kind === 'endpoint') {
434
+ patches.push([s.id, { start: shift(s.start), end: shift(s.end) }])
435
+ } else if (s.kind === 'polygon') {
436
+ const patch: Record<string, unknown> = { polygon: s.polygon.map(shift) }
437
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
438
+ patches.push([s.id, patch])
439
+ } else {
440
+ patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
441
+ }
442
+ }
443
+ for (const l of links) {
444
+ patches.push([
445
+ l.id,
446
+ {
447
+ start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
448
+ end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
449
+ },
450
+ ])
451
+ }
452
+ return patches
453
+ }
454
+
222
455
  // Frozen world matrix of the level group + its inverse. A node's placement
223
456
  // (`position` / `start` / `end`) is stored in its parent level's frame, but the
224
457
  // gizmos raycast the ground plane in WORLD space. When the building is rotated
@@ -12,12 +12,27 @@ import {
12
12
  DoubleSide,
13
13
  ExtrudeGeometry,
14
14
  type Group,
15
+ type Intersection,
16
+ Mesh,
17
+ type Raycaster,
15
18
  Shape,
16
19
  TorusGeometry,
17
20
  } from 'three'
18
21
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
19
22
  import { MeshBasicNodeMaterial } from 'three/webgpu'
20
23
  import { EDITOR_LAYER } from '../../../lib/constants'
24
+ import useEditor from '../../../store/use-editor'
25
+
26
+ // While a press-drag move is in flight (`placementDragMode`), the move tool
27
+ // owns the pointer and the handle rig rides the moving node — so a handle hit
28
+ // area would sit under the cursor and starve the tool's surface raycast
29
+ // (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
30
+ // Make every handle hit area inert for the duration; the indicator mesh still
31
+ // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
32
+ function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
33
+ if (useEditor.getState().placementDragMode) return
34
+ Mesh.prototype.raycast.call(this, raycaster, intersects)
35
+ }
21
36
 
22
37
  export const ARROW_SCALE = 0.65
23
38
  export const ARROW_COLOR = '#8381ed'
@@ -36,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
36
51
  const CHEVRON_BEVEL_THICKNESS = 0.035
37
52
  const CHEVRON_BEVEL_SIZE = 0.03
38
53
  const CHEVRON_BEVEL_SEGMENTS = 10
54
+ // Slimmer extrude profile matching the legacy wall side handles
55
+ // (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier
56
+ // default is preserved for every other handle that uses the shared chevron.
57
+ const CHEVRON_THIN_DEPTH = 0.045
58
+ const CHEVRON_THIN_BEVEL_THICKNESS = 0.018
59
+ const CHEVRON_THIN_BEVEL_SIZE = 0.02
60
+ const CHEVRON_THIN_BEVEL_SEGMENTS = 8
39
61
  const MOVE_CROSS_HALF_LENGTH = 0.36
40
62
  const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
41
63
  const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
@@ -49,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
49
71
  const ROTATE_RIBBON_HALF_WIDTH = 0.02
50
72
  const ROTATE_HEAD_HALF_WIDTH = 0.045
51
73
  const TRACKER_CUBE_SIZE = 0.16
52
- export const CORNER_HEX_RADIUS = 0.16
74
+ export const CORNER_HEX_RADIUS = 0.11
53
75
 
54
76
  export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
55
77
  export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
@@ -75,6 +97,10 @@ export type HandleArrowProps = {
75
97
  indicatorRotation?: readonly [number, number, number]
76
98
  onPointerEnter?: PointerHandler
77
99
  onPointerLeave?: PointerHandler
100
+ // Extrude the slimmer wall-handle chevron profile (chevron shape only).
101
+ thin?: boolean
102
+ // Render the corner-picker disc as a smooth circle instead of a hexagon.
103
+ round?: boolean
78
104
  }
79
105
 
80
106
  function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
@@ -164,8 +190,9 @@ export function createRotateArrowHandleGeometry() {
164
190
 
165
191
  // Reused chevron+shaft silhouette. The chevron points along +X by default;
166
192
  // callers rotate it around Y for Z-axis handles and into a vertical frame for
167
- // Y-axis handles.
168
- export function createArrowHandleGeometry() {
193
+ // Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
194
+ export function createArrowHandleGeometry(thin = false) {
195
+ const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
169
196
  const shape = new Shape()
170
197
  shape.moveTo(CHEVRON_MAX_X, 0)
171
198
  shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
@@ -176,16 +203,16 @@ export function createArrowHandleGeometry() {
176
203
  shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
177
204
  shape.lineTo(CHEVRON_MAX_X, 0)
178
205
  const geometry = new ExtrudeGeometry(shape, {
179
- depth: CHEVRON_DEPTH,
206
+ depth,
180
207
  bevelEnabled: true,
181
- bevelThickness: CHEVRON_BEVEL_THICKNESS,
182
- bevelSize: CHEVRON_BEVEL_SIZE,
208
+ bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
209
+ bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
183
210
  bevelOffset: 0,
184
- bevelSegments: CHEVRON_BEVEL_SEGMENTS,
211
+ bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
185
212
  curveSegments: 16,
186
213
  steps: 1,
187
214
  })
188
- geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
215
+ geometry.translate(0, 0, -depth / 2)
189
216
  geometry.rotateX(-Math.PI / 2)
190
217
  geometry.computeVertexNormals()
191
218
  geometry.computeBoundingSphere()
@@ -254,15 +281,27 @@ export function createArrowHitAreaGeometry() {
254
281
  return geometry
255
282
  }
256
283
 
284
+ // The move cross is a plus, not a disk. A disk-shaped hit area fills the four
285
+ // corner gaps between the arms, so a neighbouring node sitting next to the
286
+ // selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
287
+ // invisible grip and can't be picked. Wrap the visible arms instead: two flat
288
+ // arm boxes (length/width + margin) merged into a plus, leaving the corners
289
+ // empty so co-located neighbours stay selectable while the grip stays grabbable.
257
290
  function createMoveCrossHitAreaGeometry() {
258
- const geometry = new CylinderGeometry(
259
- MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
260
- MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
261
- HIT_AREA_THICKNESS,
262
- 32,
263
- )
264
- geometry.computeBoundingSphere()
265
- return geometry
291
+ const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
292
+ const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
293
+ const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
294
+ const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
295
+ const merged = mergeGeometries([armX, armZ], false)
296
+ if (!merged) {
297
+ armZ.dispose()
298
+ armX.computeBoundingSphere()
299
+ return armX
300
+ }
301
+ armX.dispose()
302
+ armZ.dispose()
303
+ merged.computeBoundingSphere()
304
+ return merged
266
305
  }
267
306
 
268
307
  export function createRotateArrowHitAreaGeometry() {
@@ -299,8 +338,12 @@ export function createEndpointHitAreaGeometry(radius: number) {
299
338
  return geometry
300
339
  }
301
340
 
302
- function createHandleArrowGeometry(shape: HandleArrowShape) {
303
- if (shape === 'chevron') return createArrowHandleGeometry()
341
+ // Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`.
342
+ const CORNER_DISC_SEGMENTS = 6
343
+ const CORNER_DISC_ROUND_SEGMENTS = 32
344
+
345
+ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
346
+ if (shape === 'chevron') return createArrowHandleGeometry(thin)
304
347
  if (shape === 'cross') return createMoveCrossHandleGeometry()
305
348
  if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
306
349
  if (shape === 'tracker') {
@@ -308,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape) {
308
351
  geometry.computeBoundingSphere()
309
352
  return geometry
310
353
  }
311
- const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
354
+ const geometry = new CircleGeometry(
355
+ CORNER_HEX_RADIUS,
356
+ round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
357
+ )
312
358
  geometry.computeBoundingSphere()
313
359
  return geometry
314
360
  }
315
361
 
316
- function createHandleArrowHitGeometry(shape: HandleArrowShape) {
362
+ function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
317
363
  if (shape === 'chevron') return createArrowHitAreaGeometry()
318
364
  if (shape === 'cross') return createMoveCrossHitAreaGeometry()
319
365
  if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
320
366
  if (shape === 'tracker') return createTrackerHitAreaGeometry()
321
- const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
367
+ const geometry = new CircleGeometry(
368
+ CORNER_HEX_RADIUS,
369
+ round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
370
+ )
322
371
  geometry.computeBoundingSphere()
323
372
  return geometry
324
373
  }
@@ -382,6 +431,7 @@ export function InvisibleHandleHitArea({
382
431
  onPointerDown={onPointerDown}
383
432
  onPointerEnter={onPointerEnter}
384
433
  onPointerLeave={onPointerLeave}
434
+ raycast={hitAreaRaycast}
385
435
  renderOrder={HIT_AREA_RENDER_ORDER}
386
436
  scale={scale}
387
437
  />
@@ -443,10 +493,18 @@ export function HandleArrow({
443
493
  onPointerDown,
444
494
  onPointerEnter,
445
495
  onPointerLeave,
496
+ thin = false,
497
+ round = false,
446
498
  }: HandleArrowProps) {
447
499
  const visualShape = normalizeHandleArrowShape(shape, cursor)
448
- const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
449
- const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
500
+ const geometry = useMemo(
501
+ () => createHandleArrowGeometry(visualShape, thin, round),
502
+ [visualShape, thin, round],
503
+ )
504
+ const hitGeometry = useMemo(
505
+ () => createHandleArrowHitGeometry(visualShape, round),
506
+ [visualShape, round],
507
+ )
450
508
  const indicatorMaterial = useHandleArrowMaterial(visualShape)
451
509
  const hitMaterial = useInvisibleHitAreaMaterial()
452
510
  const rootRef = useRef<Group>(null)
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ runAsSingleSceneHistoryStep,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+ import { commitHandleDragPatch } from './handle-drag-history'
9
+
10
+ type RafFn = (callback: (time: number) => void) => number
11
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
12
+ callback(0)
13
+ return 0
14
+ }
15
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
16
+
17
+ const NODE_ID = 'shelf_handle-drag-history' as AnyNodeId
18
+ const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId
19
+
20
+ function shelf(depth: number, id = NODE_ID): AnyNode {
21
+ return {
22
+ id,
23
+ type: 'shelf',
24
+ object: 'node',
25
+ parentId: null,
26
+ visible: true,
27
+ metadata: {},
28
+ children: [],
29
+ position: [0, 0, 0],
30
+ rotation: [0, 0, 0],
31
+ width: 1,
32
+ depth,
33
+ thickness: 0.04,
34
+ height: 0.9,
35
+ style: 'wall-shelf',
36
+ rows: 1,
37
+ columns: 1,
38
+ withBack: false,
39
+ withSides: true,
40
+ withBottom: false,
41
+ bracketStyle: 'minimal',
42
+ } as AnyNode
43
+ }
44
+
45
+ describe('commitHandleDragPatch', () => {
46
+ beforeEach(() => {
47
+ useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
48
+ useScene.temporal.getState().clear()
49
+ useScene.temporal.getState().resume()
50
+ })
51
+
52
+ test('records the final patch as one undo step after preview history is resumed', () => {
53
+ useScene.getState().createNode(shelf(0.3))
54
+ const pastCount = useScene.temporal.getState().pastStates.length
55
+ useScene.temporal.getState().pause()
56
+
57
+ commitHandleDragPatch({
58
+ patch: { depth: 0.7 },
59
+ resumeHistory: () => useScene.temporal.getState().resume(),
60
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
61
+ commit: (patch) => useScene.getState().updateNode(NODE_ID, patch),
62
+ })
63
+
64
+ expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1)
65
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
66
+
67
+ useScene.temporal.getState().undo()
68
+ expect(useScene.getState().nodes[NODE_ID]).toBeDefined()
69
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
70
+ })
71
+
72
+ test('records a composite multi-node commit as one undo step', () => {
73
+ useScene.getState().createNode(shelf(0.3))
74
+ useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID))
75
+ useScene.temporal.getState().clear()
76
+ useScene.temporal.getState().pause()
77
+
78
+ commitHandleDragPatch({
79
+ patch: { selectedDepth: 0.7, companionDepth: 0.2 },
80
+ resumeHistory: () => useScene.temporal.getState().resume(),
81
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
82
+ commit: ({ selectedDepth, companionDepth }) => {
83
+ useScene.getState().updateNode(NODE_ID, { depth: selectedDepth })
84
+ useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth })
85
+ },
86
+ })
87
+
88
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
89
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
90
+ expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2)
91
+
92
+ useScene.temporal.getState().undo()
93
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
94
+ expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4)
95
+ })
96
+ })
@@ -0,0 +1,14 @@
1
+ export function commitHandleDragPatch<T>({
2
+ commit,
3
+ patch,
4
+ resumeHistory,
5
+ runAsSingleHistoryStep,
6
+ }: {
7
+ commit: (patch: T) => void
8
+ patch: T
9
+ resumeHistory: () => void
10
+ runAsSingleHistoryStep: (run: () => void) => void
11
+ }) {
12
+ resumeHistory()
13
+ runAsSingleHistoryStep(() => commit(patch))
14
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, mock, test } from 'bun:test'
2
+ import type { AnyNodeId } from '@pascal-app/core'
3
+ import { replacePreviewOverrideIds } from './preview-overrides'
4
+
5
+ const FIRST_ID = 'cabinet_first' as AnyNodeId
6
+ const SECOND_ID = 'cabinet_second' as AnyNodeId
7
+ const THIRD_ID = 'cabinet_third' as AnyNodeId
8
+
9
+ describe('replacePreviewOverrideIds', () => {
10
+ test('clears companion overrides that leave the active preview', () => {
11
+ const clear = mock(() => {})
12
+
13
+ const nextIds = replacePreviewOverrideIds(
14
+ new Set([FIRST_ID, SECOND_ID]),
15
+ [
16
+ [SECOND_ID, { width: 0.7 }],
17
+ [THIRD_ID, { width: 0.5 }],
18
+ ],
19
+ clear,
20
+ )
21
+
22
+ expect(clear).toHaveBeenCalledTimes(1)
23
+ expect(clear).toHaveBeenCalledWith(FIRST_ID)
24
+ expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID]))
25
+ })
26
+ })
@@ -0,0 +1,13 @@
1
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
2
+
3
+ export function replacePreviewOverrideIds(
4
+ activeIds: ReadonlySet<AnyNodeId>,
5
+ entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
6
+ clear: (id: AnyNodeId) => void,
7
+ ): Set<AnyNodeId> {
8
+ const nextIds = new Set(entries.map(([id]) => id))
9
+ for (const id of activeIds) {
10
+ if (!nextIds.has(id)) clear(id)
11
+ }
12
+ return nextIds
13
+ }