@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
@@ -3,6 +3,9 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ DEFAULT_ANGLE_STEP,
7
+ pauseSpaceDetection,
8
+ resumeSpaceDetection,
6
9
  useLiveNodeOverrides,
7
10
  useLiveTransforms,
8
11
  useScene,
@@ -11,8 +14,14 @@ import { useViewer } from '@pascal-app/viewer'
11
14
  import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
12
15
  import { useEffect, useMemo, useRef, useState } from 'react'
13
16
  import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
18
+ import { isHistoryShortcut } from '../../lib/history'
14
19
  import { sfxEmitter } from '../../lib/sfx-bus'
15
20
  import useEditor from '../../store/use-editor'
21
+ import useInteractionScope, {
22
+ useActiveHandleDrag,
23
+ useMovingNode,
24
+ } from '../../store/use-interaction-scope'
16
25
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
17
26
  import {
18
27
  CORNER_OFFSET,
@@ -21,7 +30,7 @@ import {
21
30
  computeGroupBox,
22
31
  expandToComponent,
23
32
  levelFrame,
24
- type Vec2,
33
+ rotateGroupPatches,
25
34
  type Vec3,
26
35
  } from './group-transform-shared'
27
36
  import {
@@ -31,16 +40,13 @@ import {
31
40
  createRotateArrowHandleGeometry,
32
41
  createRotateArrowHitAreaGeometry,
33
42
  GuideRing,
34
- InvisibleHandleHitArea,
35
- NO_RAYCAST,
36
43
  RotationGuide,
37
44
  type RotationGuideData,
38
45
  swallowNextClick,
39
46
  useArrowMaterial,
40
47
  useInvisibleHitAreaMaterial,
41
48
  } from './node-arrow-handles'
42
-
43
- const ROTATE_SNAP = Math.PI / 12 // 15°
49
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
44
50
 
45
51
  /**
46
52
  * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
@@ -56,11 +62,14 @@ export function GroupRotateHandle() {
56
62
  const selectedIds = useViewer((s) => s.selection.selectedIds)
57
63
  const levelId = useViewer((s) => s.selection.levelId)
58
64
  const mode = useEditor((s) => s.mode)
59
- const movingNode = useEditor((s) => s.movingNode)
65
+ const movingNode = useMovingNode()
66
+ const activeHandleDrag = useActiveHandleDrag()
60
67
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
61
68
  // Re-derive participants whenever the scene mutates (e.g. after a commit).
62
69
  // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
63
70
  const nodes = useScene((s) => s.nodes)
71
+ // Re-measure the pivot/corner once the meshes settle after a scene change.
72
+ const meshEpoch = useMeshSettleEpoch(nodes)
64
73
 
65
74
  const participantIds = useMemo(
66
75
  () =>
@@ -79,14 +88,20 @@ export function GroupRotateHandle() {
79
88
  )
80
89
 
81
90
  const shouldRender =
82
- participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
91
+ participantIds.length >= 2 &&
92
+ mode !== 'delete' &&
93
+ !movingNode &&
94
+ !isFloorplanHovered &&
95
+ // Hide while the sibling move gizmo drags the group — the frozen corner
96
+ // this handle would sit at goes stale as the group slides under it.
97
+ activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL
83
98
 
84
99
  if (!shouldRender) return null
85
100
  // Remount when the moving set changes so the rest pivot re-seeds cleanly.
86
- return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} />
101
+ return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
87
102
  }
88
103
 
89
- function GroupRotateHandleInner({ ids }: { ids: string[] }) {
104
+ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
90
105
  const { camera, raycaster, gl, scene } = useThree()
91
106
  const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
92
107
  const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
@@ -117,6 +132,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
117
132
  // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
118
133
  // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
119
134
  const rest = useMemo(() => {
135
+ void meshEpoch
120
136
  const box = computeGroupBox(ids)
121
137
  if (!box) return null
122
138
  const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
@@ -126,13 +142,25 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
126
142
  box.max.z + CORNER_OFFSET,
127
143
  )
128
144
  return { pivot, corner }
129
- }, [ids])
145
+ }, [ids, meshEpoch])
130
146
 
131
147
  if (!rest) return null
132
148
  const active = isDragging && frozenRest.current ? frozenRest.current : rest
133
149
  const corner = active.corner
134
150
 
151
+ const onHoverEnter = (event: ThreeEvent<PointerEvent>) => {
152
+ event.stopPropagation()
153
+ setIsHovered(true)
154
+ if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
155
+ }
156
+ const onHoverLeave = (event: ThreeEvent<PointerEvent>) => {
157
+ event.stopPropagation()
158
+ setIsHovered(false)
159
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
160
+ }
161
+
135
162
  const activate = (event: ThreeEvent<PointerEvent>) => {
163
+ if (event.button !== 0) return
136
164
  event.stopPropagation()
137
165
  suppressBoxSelectForPointer(event)
138
166
 
@@ -166,6 +194,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
166
194
  if (s.kind === 'endpoint') {
167
195
  reach(s.start[0], s.start[1])
168
196
  reach(s.end[0], s.end[1])
197
+ } else if (s.kind === 'polygon') {
198
+ for (const [x, z] of s.polygon) {
199
+ reach(x, z)
200
+ }
169
201
  } else {
170
202
  reach(s.position[0], s.position[2])
171
203
  }
@@ -191,6 +223,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
191
223
  sfxEmitter.emit('sfx:item-pick')
192
224
  useViewer.getState().setInputDragging(true)
193
225
  useScene.temporal.getState().pause()
226
+ useInteractionScope.getState().begin({
227
+ kind: 'handle-drag',
228
+ nodeId: ids[0] ?? '',
229
+ handle: GROUP_ROTATE_DRAG_LABEL,
230
+ })
194
231
  setIsDragging(true)
195
232
 
196
233
  const onMove = (e: PointerEvent) => {
@@ -201,55 +238,31 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
201
238
  let delta = angleOf(moveHit) - initialAngle
202
239
  while (delta > Math.PI) delta -= 2 * Math.PI
203
240
  while (delta < -Math.PI) delta += 2 * Math.PI
204
- if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
205
-
206
- // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
207
- // turn its yaw by `-delta` to match three.js Y-rotation handedness (same
208
- // convention as the single-item rotate handle in item/definition.ts).
209
- // Endpoint nodes (walls/fences) have no yaw — swinging both endpoints
210
- // around the pivot rotates them rigidly; their curveOffset sagitta is
211
- // rotation-invariant, so arcs are preserved.
212
- const cos = Math.cos(delta)
213
- const sin = Math.sin(delta)
214
- const rot = (x: number, z: number): Vec2 => {
215
- const dx = x - localCenter.x
216
- const dz = z - localCenter.z
217
- return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
218
- }
219
- const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
241
+ if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
242
+
243
+ // Shared rigid-rotation math (also used by the keyboard group R/T);
244
+ // see `rotateGroupPatches` for the orbit/yaw handedness contract.
245
+ const overrideEntries = rotateGroupPatches(
246
+ starts,
247
+ links,
248
+ { x: localCenter.x, z: localCenter.z },
249
+ delta,
250
+ )
251
+ const patchById = new Map(overrideEntries)
220
252
  const liveTransforms = useLiveTransforms.getState()
221
253
  for (const s of starts) {
222
- if (s.kind === 'endpoint') {
223
- overrideEntries.push([
224
- s.id,
225
- { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) },
226
- ])
227
- } else {
228
- const [px, pz] = rot(s.position[0], s.position[2])
229
- const position: Vec3 = [px, s.position[1], pz]
230
- const rotation =
231
- s.kind === 'vec3'
232
- ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
233
- : s.rotation - delta
234
- overrideEntries.push([s.id, { position, rotation }])
235
- if (s.kind === 'scalar') {
236
- liveTransforms.set(s.id, { position, rotation: s.rotation - delta })
254
+ if (s.kind === 'scalar') {
255
+ const patch = patchById.get(s.id)
256
+ if (patch) {
257
+ liveTransforms.set(s.id, {
258
+ position: patch.position as Vec3,
259
+ rotation: patch.rotation as number,
260
+ })
237
261
  }
238
262
  }
239
263
  useScene.getState().markDirty(s.id)
240
264
  }
241
-
242
- // Drag each linked neighbour's shared endpoint to the same rotated spot
243
- // (rot is deterministic, so it lands exactly on the selected wall's
244
- // rotated endpoint), keeping the junction welded; the far end stays put.
245
265
  for (const l of links) {
246
- overrideEntries.push([
247
- l.id,
248
- {
249
- start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
250
- end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
251
- },
252
- ])
253
266
  useScene.getState().markDirty(l.id)
254
267
  }
255
268
  useLiveNodeOverrides.getState().setMany(overrideEntries)
@@ -289,9 +302,13 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
289
302
  window.removeEventListener('pointermove', onMove)
290
303
  window.removeEventListener('pointerup', onUp)
291
304
  window.removeEventListener('pointercancel', onCancel)
305
+ window.removeEventListener('keydown', onKeyDown, true)
292
306
  if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
293
307
  useScene.temporal.getState().resume()
294
308
  useViewer.getState().setInputDragging(false)
309
+ useInteractionScope
310
+ .getState()
311
+ .endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
295
312
  setIsDragging(false)
296
313
  setGuide(null)
297
314
  frozenRest.current = null
@@ -316,8 +333,12 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
316
333
  const updates = commitFromOverrides()
317
334
  // Resume before the commit so the single batched `updateNodes` is the
318
335
  // one tracked set — collapsing the whole group rotation into one undo.
336
+ // Space detection stays out: a rigid rotation of existing walls must
337
+ // not re-create the room's auto floors/ceilings at the new bearing.
338
+ pauseSpaceDetection()
319
339
  useScene.temporal.getState().resume()
320
340
  if (updates.length > 0) useScene.getState().updateNodes(updates)
341
+ resumeSpaceDetection()
321
342
  clearLivePreviews()
322
343
  cleanup()
323
344
  }
@@ -328,6 +349,16 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
328
349
  cleanup()
329
350
  }
330
351
 
352
+ // Escape / ⌘Z abort the rotate — capture phase so they win over the global
353
+ // use-keyboard arms (⌘Z must never history-jump under a live pointer).
354
+ const onKeyDown = (e: KeyboardEvent) => {
355
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
356
+ e.preventDefault()
357
+ e.stopPropagation()
358
+ swallowNextClick()
359
+ onCancel()
360
+ }
361
+
331
362
  dragCleanupRef.current = () => {
332
363
  clearLivePreviews()
333
364
  cleanup()
@@ -338,6 +369,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
338
369
  window.addEventListener('pointermove', onMove)
339
370
  window.addEventListener('pointerup', onUp)
340
371
  window.addEventListener('pointercancel', onCancel)
372
+ window.addEventListener('keydown', onKeyDown, true)
341
373
  }
342
374
 
343
375
  return createPortal(
@@ -348,27 +380,33 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
348
380
  </group>
349
381
  )}
350
382
  <group position={[corner.x, corner.y, corner.z]}>
351
- <InvisibleHandleHitArea
383
+ {/* Fat invisible grab target (torus wrapping the arrow). A plain
384
+ default-layer mesh with the standard raycast — the shared
385
+ `InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
386
+ received pointer events in this portalled context: R3F's
387
+ `createPortal` gives the portal root its own fresh `Raycaster`
388
+ (default mask = layer 0 only), so the EDITOR_LAYER enable applied
389
+ to the root raycaster in `custom-camera-controls` never reaches
390
+ it. Harmless render-wise — the material is colorWrite:false /
391
+ depthWrite:false, so nothing leaks into thumbnails or the ink
392
+ pass. A proper fix would enable EDITOR_LAYER on the portal
393
+ raycaster from inside the portal. */}
394
+ <mesh
395
+ frustumCulled={false}
352
396
  geometry={hitGeometry}
353
397
  material={hitMaterial}
354
398
  onPointerDown={activate}
355
- onPointerEnter={(event) => {
356
- event.stopPropagation()
357
- setIsHovered(true)
358
- if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
359
- }}
360
- onPointerLeave={(event) => {
361
- event.stopPropagation()
362
- setIsHovered(false)
363
- if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
364
- }}
399
+ onPointerEnter={onHoverEnter}
400
+ onPointerLeave={onHoverLeave}
365
401
  scale={baseScale}
366
402
  />
367
403
  <mesh
368
404
  frustumCulled={false}
369
405
  geometry={arrowGeometry}
370
406
  material={arrowMaterial}
371
- raycast={NO_RAYCAST}
407
+ onPointerDown={activate}
408
+ onPointerEnter={onHoverEnter}
409
+ onPointerLeave={onHoverLeave}
372
410
  renderOrder={1010}
373
411
  scale={scale}
374
412
  />
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ThreeEvent, useThree } from '@react-three/fiber'
6
+ import { useEffect, useMemo } from 'react'
7
+ import * as THREE from 'three'
8
+ import useEditor from '../../store/use-editor'
9
+ import { useMovingNode } from '../../store/use-interaction-scope'
10
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
11
+ import { armGroupMove3d } from './group-move-3d'
12
+ import {
13
+ classifyParticipant,
14
+ computeGroupBox,
15
+ expandToComponent,
16
+ levelFrame,
17
+ } from './group-transform-shared'
18
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
19
+
20
+ // Matches the 2D dashed selection box's stroke.
21
+ const BOX_COLOR = '#3b82f6'
22
+ // Small clearance so the dashes don't z-fight the selection's outer faces.
23
+ const BOX_PAD = 0.06
24
+
25
+ /**
26
+ * 3D sibling of the 2D dashed group selection box: a dashed wireframe around
27
+ * the multi-selection's transformable participants (expanded to the welded
28
+ * wall/fence component) that doubles as the group's whole-volume drag
29
+ * handle — move cursor across it, press-drag anywhere on it slides the group,
30
+ * a plain click picks it up. Holding a selection modifier passes the press
31
+ * through so members inside can still be toggled. Rides the live drag delta
32
+ * so it tracks the group mid-gesture.
33
+ */
34
+ export function GroupSelectionBox3D() {
35
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const levelId = useViewer((s) => s.selection.levelId)
37
+ const nodes = useScene((s) => s.nodes)
38
+ const mode = useEditor((s) => s.mode)
39
+ const movingNode = useMovingNode()
40
+ const delta = useFloorplanGroupDrag((s) => s.delta)
41
+ const { camera, raycaster, gl } = useThree()
42
+
43
+ const participantIds = useMemo(
44
+ () =>
45
+ selectedIds.length > 1
46
+ ? selectedIds.filter(
47
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
48
+ )
49
+ : [],
50
+ [selectedIds, levelId, nodes],
51
+ )
52
+
53
+ // Re-measure once the meshes settle after a scene change (undo included).
54
+ const meshEpoch = useMeshSettleEpoch(nodes)
55
+ const box = useMemo(() => {
56
+ void meshEpoch
57
+ if (participantIds.length === 0) return null
58
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
59
+ const world = computeGroupBox(fullIds)
60
+ if (!world) return null
61
+ const size = new THREE.Vector3()
62
+ world.getSize(size)
63
+ const center = new THREE.Vector3()
64
+ world.getCenter(center)
65
+ return {
66
+ size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
67
+ number,
68
+ number,
69
+ number,
70
+ ],
71
+ center,
72
+ }
73
+ }, [participantIds, nodes, levelId, meshEpoch])
74
+
75
+ // Dashed wireframe. Built per box size (rare — selection / commit changes)
76
+ // because LineDashedMaterial measures dashes along the line, so scaling a
77
+ // unit box would stretch them per axis.
78
+ const edges = useMemo(() => {
79
+ if (!box) return null
80
+ const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.size))
81
+ const line = new THREE.LineSegments(
82
+ geometry,
83
+ new THREE.LineDashedMaterial({
84
+ color: BOX_COLOR,
85
+ dashSize: 0.18,
86
+ gapSize: 0.12,
87
+ opacity: 0.9,
88
+ transparent: true,
89
+ }),
90
+ )
91
+ line.computeLineDistances()
92
+ return line
93
+ }, [box])
94
+ useEffect(
95
+ () => () => {
96
+ if (edges) {
97
+ edges.geometry.dispose()
98
+ ;(edges.material as THREE.Material).dispose()
99
+ }
100
+ },
101
+ [edges],
102
+ )
103
+
104
+ // Mid-drag the sessions publish a level-frame delta; map it to world so the
105
+ // box rides the group (shared with the 2D box's store).
106
+ const worldOffset = useMemo(() => {
107
+ if (!delta) return null
108
+ const { matrix } = levelFrame(levelId)
109
+ const origin = new THREE.Vector3().applyMatrix4(matrix)
110
+ return new THREE.Vector3(delta[0], 0, delta[1]).applyMatrix4(matrix).sub(origin)
111
+ }, [delta, levelId])
112
+
113
+ if (!box || !edges || movingNode || mode !== 'select') return null
114
+
115
+ const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
116
+ const native = event.nativeEvent
117
+ if (native.button !== 0) return
118
+ // A selection modifier passes through so members inside the box can
119
+ // still be toggled in and out of the selection.
120
+ if (native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return
121
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
122
+ const sceneNodes = useScene.getState().nodes
123
+ const anchor = currentIds.find(
124
+ (id) => classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
125
+ )
126
+ if (!anchor) return
127
+ const armed = armGroupMove3d({
128
+ nodeId: anchor as AnyNodeId,
129
+ clientX: native.clientX,
130
+ clientY: native.clientY,
131
+ pointerId: native.pointerId,
132
+ nativeEvent: native,
133
+ camera,
134
+ raycaster,
135
+ domElement: gl.domElement,
136
+ })
137
+ // Own the press: deeper hits (members, ground) must not also select.
138
+ if (armed) event.stopPropagation()
139
+ }
140
+
141
+ // Set on move (not just enter): the canvas may carry an app default
142
+ // cursor, and member hovers inside the box write theirs — keep 'move'
143
+ // asserted across the whole volume, except while a drag shows 'grabbing'.
144
+ const applyMoveCursor = () => {
145
+ const cursor = gl.domElement.style.cursor
146
+ if (cursor !== 'move' && cursor !== 'grabbing') gl.domElement.style.cursor = 'move'
147
+ }
148
+ const handlePointerLeave = () => {
149
+ if (gl.domElement.style.cursor === 'move') gl.domElement.style.cursor = ''
150
+ }
151
+
152
+ const position: [number, number, number] = [
153
+ box.center.x + (worldOffset?.x ?? 0),
154
+ box.center.y + (worldOffset?.y ?? 0),
155
+ box.center.z + (worldOffset?.z ?? 0),
156
+ ]
157
+
158
+ return (
159
+ <group position={position}>
160
+ <primitive object={edges} />
161
+ {/* Invisible whole-volume hit target — the box IS the drag handle. */}
162
+ <mesh
163
+ onPointerDown={handlePointerDown}
164
+ onPointerEnter={applyMoveCursor}
165
+ onPointerLeave={handlePointerLeave}
166
+ onPointerMove={applyMoveCursor}
167
+ >
168
+ <boxGeometry args={box.size} />
169
+ <meshBasicMaterial depthWrite={false} opacity={0} transparent />
170
+ </mesh>
171
+ </group>
172
+ )
173
+ }
@@ -1,7 +1,12 @@
1
1
  import { beforeAll, describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
3
3
  import { z } from 'zod'
4
- import { classifyParticipant, collectParticipants } from './group-transform-shared'
4
+ import {
5
+ classifyParticipant,
6
+ collectParticipants,
7
+ rotateGroupPatches,
8
+ translateGroupPatches,
9
+ } from './group-transform-shared'
5
10
 
6
11
  const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
7
12
 
@@ -171,6 +176,192 @@ describe('group transform participants', () => {
171
176
  ])
172
177
  })
173
178
 
179
+ test('classifies and transforms polygon kinds (slab / ceiling / zone)', () => {
180
+ const nodes = {
181
+ building_test: {
182
+ id: 'building_test',
183
+ type: 'building',
184
+ children: ['level_test'],
185
+ },
186
+ level_test: {
187
+ id: 'level_test',
188
+ type: 'level',
189
+ parentId: 'building_test',
190
+ children: ['slab_test', 'zone_test'],
191
+ },
192
+ slab_test: {
193
+ id: 'slab_test',
194
+ type: 'slab',
195
+ parentId: 'level_test',
196
+ polygon: [
197
+ [0, 0],
198
+ [2, 0],
199
+ [2, 2],
200
+ [0, 2],
201
+ ],
202
+ holes: [
203
+ [
204
+ [0.5, 0.5],
205
+ [1, 0.5],
206
+ [1, 1],
207
+ ],
208
+ ],
209
+ },
210
+ zone_test: {
211
+ id: 'zone_test',
212
+ type: 'zone',
213
+ parentId: 'level_test',
214
+ polygon: [
215
+ [0, 0],
216
+ [1, 0],
217
+ [1, 1],
218
+ ],
219
+ },
220
+ slab_elsewhere: {
221
+ id: 'slab_elsewhere',
222
+ type: 'slab',
223
+ parentId: 'level_other',
224
+ polygon: [
225
+ [0, 0],
226
+ [1, 0],
227
+ [1, 1],
228
+ ],
229
+ },
230
+ } as unknown as Record<string, AnyNode>
231
+
232
+ expect(classifyParticipant(nodes.slab_test, 'level_test', nodes)).toBe('polygon')
233
+ expect(classifyParticipant(nodes.zone_test, 'level_test', nodes)).toBe('polygon')
234
+ // Not in the active level frame → excluded.
235
+ expect(classifyParticipant(nodes.slab_elsewhere, 'level_test', nodes)).toBeNull()
236
+
237
+ const { starts, links } = collectParticipants(['slab_test', 'zone_test'], nodes, 'level_test')
238
+ expect(links).toEqual([])
239
+ expect(starts).toEqual([
240
+ {
241
+ id: 'slab_test',
242
+ kind: 'polygon',
243
+ polygon: [
244
+ [0, 0],
245
+ [2, 0],
246
+ [2, 2],
247
+ [0, 2],
248
+ ],
249
+ holes: [
250
+ [
251
+ [0.5, 0.5],
252
+ [1, 0.5],
253
+ [1, 1],
254
+ ],
255
+ ],
256
+ },
257
+ {
258
+ id: 'zone_test',
259
+ kind: 'polygon',
260
+ polygon: [
261
+ [0, 0],
262
+ [1, 0],
263
+ [1, 1],
264
+ ],
265
+ holes: null,
266
+ },
267
+ ])
268
+
269
+ // Translate: every vertex (holes included) shifts; the hole-less zone's
270
+ // patch never grows a `holes` field.
271
+ const moved = translateGroupPatches(starts, [], 1, -2)
272
+ expect(moved).toEqual([
273
+ [
274
+ 'slab_test',
275
+ {
276
+ polygon: [
277
+ [1, -2],
278
+ [3, -2],
279
+ [3, 0],
280
+ [1, 0],
281
+ ],
282
+ holes: [
283
+ [
284
+ [1.5, -1.5],
285
+ [2, -1.5],
286
+ [2, -1],
287
+ ],
288
+ ],
289
+ },
290
+ ],
291
+ [
292
+ 'zone_test',
293
+ {
294
+ polygon: [
295
+ [1, -2],
296
+ [2, -2],
297
+ [2, -1],
298
+ ],
299
+ },
300
+ ],
301
+ ])
302
+
303
+ // Rotate 90° in the atan2 x→z sense around the origin: (x, z) → (-z, x).
304
+ const rotated = rotateGroupPatches(
305
+ starts.filter((s) => s.id === 'zone_test'),
306
+ [],
307
+ { x: 0, z: 0 },
308
+ Math.PI / 2,
309
+ )
310
+ expect(rotated).toHaveLength(1)
311
+ const rotatedPolygon = (rotated[0]![1] as { polygon: [number, number][] }).polygon
312
+ const expected = [
313
+ [0, 0],
314
+ [0, 1],
315
+ [-1, 1],
316
+ ]
317
+ rotatedPolygon.forEach((point, i) => {
318
+ expect(point[0]).toBeCloseTo(expected[i]![0]!)
319
+ expect(point[1]).toBeCloseTo(expected[i]![1]!)
320
+ })
321
+ })
322
+
323
+ test('polygon hosts carry their attached positioned children (ceiling items)', () => {
324
+ const nodes = {
325
+ building_test: { id: 'building_test', type: 'building', children: ['level_test'] },
326
+ level_test: {
327
+ id: 'level_test',
328
+ type: 'level',
329
+ parentId: 'building_test',
330
+ children: ['ceiling_test'],
331
+ },
332
+ ceiling_test: {
333
+ id: 'ceiling_test',
334
+ type: 'ceiling',
335
+ parentId: 'level_test',
336
+ children: ['item_lamp'],
337
+ polygon: [
338
+ [0, 0],
339
+ [2, 0],
340
+ [2, 2],
341
+ [0, 2],
342
+ ],
343
+ },
344
+ item_lamp: {
345
+ id: 'item_lamp',
346
+ type: 'item',
347
+ parentId: 'ceiling_test',
348
+ position: [1, 2.4, 1],
349
+ rotation: [0, 0.5, 0],
350
+ },
351
+ } as unknown as Record<string, AnyNode>
352
+
353
+ // The lamp itself is not level-parented, so it is not an independent
354
+ // participant — it rides its host ceiling.
355
+ expect(classifyParticipant(nodes.item_lamp, 'level_test', nodes)).toBeNull()
356
+
357
+ const { starts } = collectParticipants(['ceiling_test'], nodes, 'level_test')
358
+ expect(starts.map((s) => s.id).sort()).toEqual(['ceiling_test', 'item_lamp'])
359
+
360
+ const moved = translateGroupPatches(starts, [], 2, 1)
361
+ const lampPatch = Object.fromEntries(moved).item_lamp as { position: [number, number, number] }
362
+ expect(lampPatch.position).toEqual([3, 2.4, 2])
363
+ })
364
+
174
365
  test('supports legacy level-parented elevators already loaded in the editor', () => {
175
366
  const nodes = {
176
367
  building_test: {