@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -7,28 +7,27 @@ import { useFrame } from '@react-three/fiber'
7
7
  import { useCallback, useMemo, useRef } from 'react'
8
8
  import * as THREE from 'three'
9
9
  import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
10
+ import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups'
10
11
  import useEditor from '../../store/use-editor'
11
12
  import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
13
+ import useSessionGroups, {
14
+ groupCurrentSelection,
15
+ ungroupCurrentSelection,
16
+ } from '../../store/use-session-groups'
12
17
  import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
13
18
  import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
14
19
  import { NodeActionMenu } from './node-action-menu'
15
20
  import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
16
21
 
17
- // Matches the single-node FloatingActionMenu's zoom-compensation constants.
18
22
  const REF_ORTHO_ZOOM = 50
19
23
  const REF_CAMERA_DISTANCE = 12
20
24
  const MIN_MENU_SCALE = 0.6
21
25
  const MAX_MENU_SCALE = 1.4
22
- // Clearance above the group's bbox top so the pill doesn't sit on the meshes.
23
26
  const MENU_Y_OFFSET = 0.42
24
27
 
25
28
  /**
26
- * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D
27
- * view the group sibling of the single-node `FloatingActionMenu` (which is
28
- * sole-selection only). Anchored above the selection's bounding-box center;
29
- * every action targets the whole selection: Move picks the group up (it rides
30
- * the cursor until a click places it), Duplicate clones the selection and
31
- * picks the clones up, Delete removes everything selected.
29
+ * Floating pill for MULTI-selection in 3D: Move, Group, Ungroup, Duplicate, Delete.
30
+ * Group/Ungroup are session-only (Ctrl/Cmd+G / Ctrl/Cmd+Shift+G).
32
31
  */
33
32
  export function GroupFloatingActionMenu() {
34
33
  const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -37,8 +36,7 @@ export function GroupFloatingActionMenu() {
37
36
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
38
37
  const movingNode = useMovingNode()
39
38
  const nodes = useScene((s) => s.nodes)
40
- // Hard-hidden during any active interaction (drag, pick-up, reshape) so the
41
- // pill never competes with the live action — same policy as the 1-node menu.
39
+ const sessionGroups = useSessionGroups((s) => s.groups)
42
40
  const scope = useInteractionScope((s) => s.scope)
43
41
  const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
44
42
 
@@ -55,9 +53,18 @@ export function GroupFloatingActionMenu() {
55
53
  [selectedIds, levelId, nodes],
56
54
  )
57
55
 
58
- // World anchor above the group bbox. Depends on the scene (post-commit
59
- // positions), not the camera, and the menu hides during drags so a
60
- // memo keyed on selection + nodes is enough, no per-frame box traversal.
56
+ const liveIds = useMemo(() => new Set(Object.keys(nodes)), [nodes])
57
+ // Always offer Group when multi-select is not already exactly a session group.
58
+ const showGroup = useMemo(
59
+ () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
60
+ [sessionGroups, selectedIds, liveIds],
61
+ )
62
+ // Ungroup when any selected member is in a session group.
63
+ const showUngroup = useMemo(
64
+ () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
65
+ [sessionGroups, selectedIds, liveIds],
66
+ )
67
+
61
68
  const meshEpoch = useMeshSettleEpoch(nodes)
62
69
  const anchor = useMemo(() => {
63
70
  void meshEpoch
@@ -73,8 +80,6 @@ export function GroupFloatingActionMenu() {
73
80
  }, [participantIds, nodes, levelId, meshEpoch])
74
81
 
75
82
  useFrame((state) => {
76
- // Scale the HTML pill with camera zoom / distance so it feels anchored to
77
- // the world — mirrors the single-node menu.
78
83
  if (!(menuScaleRef.current && groupRef.current)) return
79
84
  const raw =
80
85
  state.camera instanceof THREE.OrthographicCamera
@@ -92,6 +97,14 @@ export function GroupFloatingActionMenu() {
92
97
  event.stopPropagation()
93
98
  startGroupPickUp()
94
99
  }, [])
100
+ const handleGroup = useCallback((event: React.MouseEvent) => {
101
+ event.stopPropagation()
102
+ groupCurrentSelection()
103
+ }, [])
104
+ const handleUngroup = useCallback((event: React.MouseEvent) => {
105
+ event.stopPropagation()
106
+ ungroupCurrentSelection()
107
+ }, [])
95
108
  const handleDuplicate = useCallback((event: React.MouseEvent) => {
96
109
  event.stopPropagation()
97
110
  duplicateSelectionAndPickUp()
@@ -119,9 +132,11 @@ export function GroupFloatingActionMenu() {
119
132
  <NodeActionMenu
120
133
  onDelete={handleDelete}
121
134
  onDuplicate={handleDuplicate}
135
+ onGroup={showGroup ? handleGroup : undefined}
122
136
  onMove={handleMove}
123
137
  onPointerDown={stopPointer}
124
138
  onPointerUp={stopPointer}
139
+ onUngroup={showUngroup ? handleUngroup : undefined}
125
140
  />
126
141
  </div>
127
142
  </Html>
@@ -32,9 +32,12 @@ import {
32
32
  collectParticipants,
33
33
  computeGroupBox,
34
34
  expandToComponent,
35
+ type GroupPlanBounds,
36
+ groupPlanBounds,
35
37
  levelFrame,
36
- participantExtents,
38
+ planBoundsCenter,
37
39
  rotateGroupSnapshots,
40
+ rotatePlanBounds,
38
41
  translateGroupPatches,
39
42
  type Vec2,
40
43
  } from './group-transform-shared'
@@ -88,6 +91,9 @@ export function armGroupMove3d(args: {
88
91
  affectedIds: AnyNodeId[]
89
92
  candidates: ReturnType<typeof collectAlignmentAnchors>
90
93
  restAnchors: ReturnType<typeof bboxCornerAnchors>
94
+ startBounds: GroupPlanBounds
95
+ restBounds: GroupPlanBounds
96
+ rotation: number
91
97
  restCenter: Vec2
92
98
  plane: Plane
93
99
  startLocal: Vector3
@@ -130,14 +136,14 @@ export function armGroupMove3d(args: {
130
136
  if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
131
137
  }
132
138
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
133
- const boxMin = restBox.min.clone().applyMatrix4(frameInv)
134
- const boxMax = restBox.max.clone().applyMatrix4(frameInv)
139
+ const restBounds = groupPlanBounds(restBox, starts, frameInv)
140
+ if (!restBounds) return null
135
141
  const restAnchors = bboxCornerAnchors(
136
142
  'group-move',
137
- Math.min(boxMin.x, boxMax.x),
138
- Math.min(boxMin.z, boxMax.z),
139
- Math.max(boxMin.x, boxMax.x),
140
- Math.max(boxMin.z, boxMax.z),
143
+ restBounds.minX,
144
+ restBounds.minZ,
145
+ restBounds.maxX,
146
+ restBounds.maxZ,
141
147
  )
142
148
 
143
149
  for (const id of affectedIds) {
@@ -154,9 +160,11 @@ export function armGroupMove3d(args: {
154
160
  nodeId,
155
161
  handle: GROUP_MOVE_DRAG_LABEL,
156
162
  })
157
- // Rotation pivot for mid-drag R/T — the participant DATA extents' center.
158
- const ext = participantExtents(starts)
159
- const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
163
+ // Rotation pivot for mid-drag R/T — the START footprint's center, the same
164
+ // point the idle keyboard rotate and the rotate gizmos orbit. Fixed for the
165
+ // whole session: the snapshots are start placements, and `applyDelta` adds
166
+ // the live drag delta on top of them.
167
+ const restCenter = planBoundsCenter(restBounds)
160
168
 
161
169
  return {
162
170
  starts,
@@ -164,6 +172,9 @@ export function armGroupMove3d(args: {
164
172
  affectedIds,
165
173
  candidates,
166
174
  restAnchors,
175
+ startBounds: restBounds,
176
+ restBounds,
177
+ rotation: 0,
167
178
  restCenter,
168
179
  plane,
169
180
  startLocal,
@@ -245,18 +256,22 @@ export function armGroupMove3d(args: {
245
256
  // current delta — the carried group turns exactly like the idle keyboard
246
257
  // rotate, and the commit stays a single updateNodes.
247
258
  const rotateSession = (s: Session, direction: 1 | -1) => {
248
- const rotated = rotateGroupSnapshots(
249
- s.starts,
250
- s.links,
251
- { x: s.restCenter[0], z: s.restCenter[1] },
252
- -direction * (Math.PI / 4),
253
- )
259
+ const pivot = { x: s.restCenter[0], z: s.restCenter[1] }
260
+ const delta = -direction * (Math.PI / 4)
261
+ const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta)
254
262
  s.starts = rotated.starts
255
263
  s.links = rotated.links
256
- const ext = participantExtents(rotated.starts)
257
- if (ext) {
258
- s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
259
- }
264
+ // Re-fit from the start footprint at the accumulated angle: rotating the
265
+ // previous axis-aligned fit would inflate the box every step.
266
+ s.rotation += delta
267
+ s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation)
268
+ s.restAnchors = bboxCornerAnchors(
269
+ 'group-move',
270
+ s.restBounds.minX,
271
+ s.restBounds.minZ,
272
+ s.restBounds.maxX,
273
+ s.restBounds.maxZ,
274
+ )
260
275
  sfxEmitter.emit('sfx:item-rotate')
261
276
  applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
262
277
  }
@@ -369,7 +384,22 @@ export function armGroupMove3d(args: {
369
384
  const onKeyDown = (e: KeyboardEvent) => {
370
385
  const key = e.key.toLowerCase()
371
386
  if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
372
- if (!session) return
387
+ // Armed but still under the drag threshold: engage first (exactly what
388
+ // the next pointer-move would do) so the rotation lands inside this
389
+ // session. Falling through to the global idle arm instead would write
390
+ // the scene behind snapshots already captured here, and the first
391
+ // `applyDelta` would republish them — undoing the rotation.
392
+ if (!session) {
393
+ session = engage()
394
+ if (!session) {
395
+ // No plane hit yet: swallow the chord and keep the gesture armed so
396
+ // the next pointer-move can still engage; the idle arm must not run
397
+ // behind the snapshots captured here.
398
+ e.preventDefault()
399
+ e.stopPropagation()
400
+ return
401
+ }
402
+ }
373
403
  e.preventDefault()
374
404
  e.stopPropagation()
375
405
  rotateSession(session, key === 'r' ? 1 : -1)
@@ -4,7 +4,10 @@ import { z } from 'zod'
4
4
  import {
5
5
  classifyParticipant,
6
6
  collectParticipants,
7
+ planBoundsCenter,
7
8
  rotateGroupPatches,
9
+ rotateGroupSnapshots,
10
+ rotatePlanBounds,
8
11
  translateGroupPatches,
9
12
  } from './group-transform-shared'
10
13
 
@@ -40,6 +43,34 @@ function registerElevatorTestKind() {
40
43
  } as AnyNodeDefinition)
41
44
  }
42
45
 
46
+ // A level holding one of each rigid placement shape: an item ([x,y,z] rotation)
47
+ // and a column (numeric rotation).
48
+ function placedNodes() {
49
+ return {
50
+ building_test: { id: 'building_test', type: 'building', children: ['level_test'] },
51
+ level_test: {
52
+ id: 'level_test',
53
+ type: 'level',
54
+ parentId: 'building_test',
55
+ children: ['item_chair', 'column_post'],
56
+ },
57
+ item_chair: {
58
+ id: 'item_chair',
59
+ type: 'item',
60
+ parentId: 'level_test',
61
+ position: [1, 0, 3],
62
+ rotation: [0, 0.5, 0],
63
+ },
64
+ column_post: {
65
+ id: 'column_post',
66
+ type: 'column',
67
+ parentId: 'level_test',
68
+ position: [4, 0, -1],
69
+ rotation: 1.25,
70
+ },
71
+ } as unknown as Record<string, AnyNode>
72
+ }
73
+
43
74
  describe('group transform participants', () => {
44
75
  beforeAll(() => {
45
76
  registerBuildingScopedTestKind()
@@ -362,6 +393,53 @@ describe('group transform participants', () => {
362
393
  expect(lampPatch.position).toEqual([3, 2.4, 2])
363
394
  })
364
395
 
396
+ test('translate patches carry the snapshot rotation for vec3 and scalar kinds', () => {
397
+ const { starts } = collectParticipants(
398
+ ['item_chair', 'column_post'],
399
+ placedNodes(),
400
+ 'level_test',
401
+ )
402
+ const patches = Object.fromEntries(translateGroupPatches(starts, [], 1, 2))
403
+
404
+ expect(patches.item_chair).toEqual({ position: [2, 0, 5], rotation: [0, 0.5, 0] })
405
+ expect(patches.column_post).toEqual({ position: [5, 0, 1], rotation: 1.25 })
406
+ })
407
+
408
+ test('a mid-drag rotation survives the next translate re-publish', () => {
409
+ const { starts } = collectParticipants(
410
+ ['item_chair', 'column_post'],
411
+ placedNodes(),
412
+ 'level_test',
413
+ )
414
+ // What a mid-drag R does: turn the snapshots, then re-apply the live delta.
415
+ const rotated = rotateGroupSnapshots(starts, [], { x: 0, z: 0 }, Math.PI / 2)
416
+ const patches = Object.fromEntries(
417
+ translateGroupPatches(rotated.starts, rotated.links, 1, 2),
418
+ ) as Record<string, { position: number[]; rotation: number[] | number }>
419
+
420
+ // Orbited 90° in the atan2 x→z sense ((x, z) → (-z, x)), then slid.
421
+ expect(patches.item_chair!.position[0]).toBeCloseTo(-2)
422
+ expect(patches.item_chair!.position[2]).toBeCloseTo(3)
423
+ expect(patches.column_post!.position[0]).toBeCloseTo(2)
424
+ expect(patches.column_post!.position[2]).toBeCloseTo(6)
425
+ // …and the facings turned with it instead of reverting to the pre-drag yaw.
426
+ expect((patches.item_chair!.rotation as number[])[1]).toBeCloseTo(0.5 - Math.PI / 2)
427
+ expect(patches.column_post!.rotation as number).toBeCloseTo(1.25 - Math.PI / 2)
428
+ })
429
+
430
+ test('rotating the plan bounds keeps the footprint centred on the pivot', () => {
431
+ const bounds = { minX: 0, minZ: 0, maxX: 4, maxZ: 2 }
432
+ const [pivotX, pivotZ] = planBoundsCenter(bounds)
433
+ const rotated = rotatePlanBounds(bounds, { x: pivotX, z: pivotZ }, Math.PI / 2)
434
+
435
+ expect(rotated.minX).toBeCloseTo(1)
436
+ expect(rotated.maxX).toBeCloseTo(3)
437
+ expect(rotated.minZ).toBeCloseTo(-1)
438
+ expect(rotated.maxZ).toBeCloseTo(3)
439
+ expect(planBoundsCenter(rotated)[0]).toBeCloseTo(pivotX)
440
+ expect(planBoundsCenter(rotated)[1]).toBeCloseTo(pivotZ)
441
+ })
442
+
365
443
  test('supports legacy level-parented elevators already loaded in the editor', () => {
366
444
  const nodes = {
367
445
  building_test: {
@@ -387,12 +387,11 @@ export function rotateGroupSnapshots(
387
387
  return { starts: rotatedStarts, links: rotatedLinks }
388
388
  }
389
389
 
390
+ export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
391
+
390
392
  // 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 {
393
+ // `computeGroupBox`, used when meshes aren't mounted yet.
394
+ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null {
396
395
  let minX = Number.POSITIVE_INFINITY
397
396
  let minZ = Number.POSITIVE_INFINITY
398
397
  let maxX = Number.NEGATIVE_INFINITY
@@ -419,8 +418,73 @@ export function participantExtents(
419
418
  return { minX, minZ, maxX, maxZ }
420
419
  }
421
420
 
421
+ // The one footprint every group transform measures itself against: the
422
+ // selection's mounted meshes (world box, converted into the level frame) with
423
+ // the participant DATA extents as the fallback when the meshes aren't up yet
424
+ // (Duplicate picks up its clones a frame before their renderers mount). Anchor
425
+ // points alone sit metres inside a wide selection's real footprint, so a
426
+ // gesture that pivots on the data extents orbits a different point than the
427
+ // idle keyboard rotate and the rotate gizmos, which both use the mesh box.
428
+ export function groupPlanBounds(
429
+ box: Box3 | null,
430
+ starts: ParticipantStart[],
431
+ frameInv: Matrix4,
432
+ ): GroupPlanBounds | null {
433
+ if (!box) return participantExtents(starts)
434
+ const min = box.min.clone().applyMatrix4(frameInv)
435
+ const max = box.max.clone().applyMatrix4(frameInv)
436
+ return {
437
+ minX: Math.min(min.x, max.x),
438
+ minZ: Math.min(min.z, max.z),
439
+ maxX: Math.max(min.x, max.x),
440
+ maxZ: Math.max(min.z, max.z),
441
+ }
442
+ }
443
+
444
+ export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [
445
+ (b.minX + b.maxX) / 2,
446
+ (b.minZ + b.maxZ) / 2,
447
+ ]
448
+
449
+ // Re-seed the footprint after a mid-gesture rotation by orbiting the box
450
+ // corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA
451
+ // extents instead would slide the centre off the pivot the snapshots turned
452
+ // around, dragging the alignment anchors away from the group under the cursor.
453
+ export function rotatePlanBounds(
454
+ b: GroupPlanBounds,
455
+ center: { x: number; z: number },
456
+ delta: number,
457
+ ): GroupPlanBounds {
458
+ const cos = Math.cos(delta)
459
+ const sin = Math.sin(delta)
460
+ let minX = Number.POSITIVE_INFINITY
461
+ let minZ = Number.POSITIVE_INFINITY
462
+ let maxX = Number.NEGATIVE_INFINITY
463
+ let maxZ = Number.NEGATIVE_INFINITY
464
+ const corners: Vec2[] = [
465
+ [b.minX, b.minZ],
466
+ [b.maxX, b.minZ],
467
+ [b.maxX, b.maxZ],
468
+ [b.minX, b.maxZ],
469
+ ]
470
+ for (const [x, z] of corners) {
471
+ const dx = x - center.x
472
+ const dz = z - center.z
473
+ const rx = center.x + dx * cos - dz * sin
474
+ const rz = center.z + dx * sin + dz * cos
475
+ minX = Math.min(minX, rx)
476
+ minZ = Math.min(minZ, rz)
477
+ maxX = Math.max(maxX, rx)
478
+ maxZ = Math.max(maxZ, rz)
479
+ }
480
+ return { minX, minZ, maxX, maxZ }
481
+ }
482
+
422
483
  // 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.
484
+ // shared endpoint) by the same level-frame XZ delta. Y is untouched; the
485
+ // snapshot's rotation rides along because a mid-gesture R/T turns the
486
+ // SNAPSHOTS — dropping it here would republish (and commit) the pre-rotation
487
+ // facing, orbiting the layout while every member keeps its old bearing.
424
488
  export function translateGroupPatches(
425
489
  starts: ParticipantStart[],
426
490
  links: LinkedNeighbor[],
@@ -437,7 +501,10 @@ export function translateGroupPatches(
437
501
  if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
438
502
  patches.push([s.id, patch])
439
503
  } else {
440
- patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
504
+ const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz]
505
+ const rotation =
506
+ s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation
507
+ patches.push([s.id, { position, rotation }])
441
508
  }
442
509
  }
443
510
  for (const l of links) {
@@ -0,0 +1,86 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { _roots, act, createRoot, extend } from '@react-three/fiber'
3
+ import { createElement } from 'react'
4
+ import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3, type WebGLRenderer } from 'three'
5
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
6
+ import useEditor from '../../../store/use-editor'
7
+ import { hitAreaRaycast, InvisibleHandleHitArea } from './handle-arrow'
8
+
9
+ extend({ Mesh })
10
+
11
+ test('an occluded handle does not sort ahead of a nearer scene body', () => {
12
+ const geometry = new BoxGeometry(0.5, 0.5, 0.5)
13
+ const material = new MeshBasicMaterial()
14
+ const body = new Mesh(geometry, material)
15
+ body.position.z = 1
16
+ body.updateMatrixWorld()
17
+ const handle = new Mesh(geometry, material)
18
+ handle.position.z = 2
19
+ handle.raycast = hitAreaRaycast
20
+ handle.updateMatrixWorld()
21
+
22
+ const raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 1))
23
+ const hits = raycaster.intersectObjects([body, handle], false)
24
+
25
+ expect(hits[0]?.object).toBe(body)
26
+ expect(hits.find((hit) => hit.object === handle)?.distance).toBeGreaterThan(
27
+ hits.find((hit) => hit.object === body)?.distance ?? Number.POSITIVE_INFINITY,
28
+ )
29
+
30
+ geometry.dispose()
31
+ material.dispose()
32
+ })
33
+
34
+ test('mounted handles disable synchronously during placement drags and restore after release', async () => {
35
+ const previousDragMode = useEditor.getState().placementDragMode
36
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
37
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
38
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
39
+ const canvas = new EventTarget() as HTMLCanvasElement
40
+ const root = createRoot(canvas)
41
+ const geometry = new BoxGeometry(1, 1, 1)
42
+ const material = new MeshBasicNodeMaterial()
43
+ try {
44
+ useEditor.setState({ placementDragMode: true })
45
+ await root.configure({
46
+ gl: { render() {}, setSize() {}, setPixelRatio() {} } as unknown as WebGLRenderer,
47
+ frameloop: 'never',
48
+ dpr: 1,
49
+ size: { width: 100, height: 100, top: 0, left: 0 },
50
+ })
51
+ const render = (scale: number) =>
52
+ createElement(InvisibleHandleHitArea, {
53
+ geometry,
54
+ material,
55
+ scale,
56
+ onPointerDown() {},
57
+ onPointerEnter() {},
58
+ onPointerLeave() {},
59
+ })
60
+ await act(async () => root.render(render(1)))
61
+ const scene = _roots.get(canvas)!.store.getState().scene
62
+ const handle = scene.children[0] as Mesh
63
+ scene.updateMatrixWorld(true)
64
+ const raycaster = new Raycaster(new Vector3(0, 0, 2), new Vector3(0, 0, -1))
65
+ raycaster.layers.enableAll()
66
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
67
+ useEditor.setState({ placementDragMode: false })
68
+ expect(handle.raycast).toBe(hitAreaRaycast)
69
+ expect(raycaster.intersectObject(handle).length).toBeGreaterThan(0)
70
+ useEditor.setState({ placementDragMode: true })
71
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
72
+ await act(async () => root.render(render(2)))
73
+ expect(raycaster.intersectObject(handle)).toHaveLength(0)
74
+ await act(async () => root.render(null))
75
+ const detachedRaycast = handle.raycast
76
+ useEditor.setState({ placementDragMode: false })
77
+ expect(handle.raycast).toBe(detachedRaycast)
78
+ } finally {
79
+ await act(async () => root.render(null))
80
+ _roots.delete(canvas)
81
+ geometry.dispose()
82
+ material.dispose()
83
+ useEditor.setState({ placementDragMode: previousDragMode })
84
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
85
+ }
86
+ })