@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,7 +7,7 @@ const baseArgs = {
7
7
  currentSelectedIds: ['wall_1'],
8
8
  getFloorplanHitIdAtPoint: () => 'door_1',
9
9
  isWallBuildActive: false,
10
- modifierKeys: { meta: false, ctrl: false, shift: false },
10
+ modifierKeys: { meta: false, ctrl: false, shift: false, alt: false },
11
11
  planPoint: [0, 0] as [number, number],
12
12
  structureLayer: 'elements',
13
13
  }
@@ -16,7 +16,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
16
16
  test('shift-click on a floorplan node toggles into the current selection', () => {
17
17
  const result = resolveFloorplanBackgroundSelection({
18
18
  ...baseArgs,
19
- modifierKeys: { meta: false, ctrl: false, shift: true },
19
+ modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
20
20
  })
21
21
 
22
22
  expect(result).toEqual({
@@ -30,7 +30,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
30
30
  const result = resolveFloorplanBackgroundSelection({
31
31
  ...baseArgs,
32
32
  currentSelectedIds: ['wall_1', 'door_1'],
33
- modifierKeys: { meta: false, ctrl: false, shift: true },
33
+ modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
34
34
  })
35
35
 
36
36
  expect(result).toEqual({
@@ -44,7 +44,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
44
44
  const result = resolveFloorplanBackgroundSelection({
45
45
  ...baseArgs,
46
46
  getFloorplanHitIdAtPoint: () => null,
47
- modifierKeys: { meta: false, ctrl: false, shift: true },
47
+ modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
48
48
  })
49
49
 
50
50
  expect(result).toEqual({
@@ -54,6 +54,33 @@ describe('resolveFloorplanBackgroundSelection', () => {
54
54
  })
55
55
  })
56
56
 
57
+ test('plain click expands a session group', () => {
58
+ const result = resolveFloorplanBackgroundSelection({
59
+ ...baseArgs,
60
+ expandIdsForNode: (nodeId) => (nodeId === 'door_1' ? ['door_1', 'wall_2'] : null),
61
+ })
62
+
63
+ expect(result).toEqual({
64
+ handled: true,
65
+ kind: 'select-elements',
66
+ selectedIds: ['door_1', 'wall_2'],
67
+ })
68
+ })
69
+
70
+ test('alt-click selects one member without expanding', () => {
71
+ const result = resolveFloorplanBackgroundSelection({
72
+ ...baseArgs,
73
+ expandIdsForNode: () => ['door_1', 'wall_2'],
74
+ modifierKeys: { meta: false, ctrl: false, shift: false, alt: true },
75
+ })
76
+
77
+ expect(result).toEqual({
78
+ handled: true,
79
+ kind: 'select-elements',
80
+ selectedIds: ['door_1'],
81
+ })
82
+ })
83
+
57
84
  test('uses the registry hit result for zone selection', () => {
58
85
  const result = resolveFloorplanBackgroundSelection({
59
86
  ...baseArgs,
@@ -7,12 +7,16 @@ type ModifierKeys = {
7
7
  meta: boolean
8
8
  ctrl: boolean
9
9
  shift: boolean
10
+ /** Alt alone: select one session-group member without expanding. */
11
+ alt: boolean
10
12
  }
11
13
 
12
14
  type ResolveFloorplanBackgroundSelectionArgs = {
13
15
  canSelectElementFloorplanGeometry: boolean
14
16
  canSelectFloorplanZones: boolean
15
17
  currentSelectedIds: string[]
18
+ /** Session-group expand on plain click (not on modifier/Alt). */
19
+ expandIdsForNode?: (nodeId: string) => string[] | null
16
20
  getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
17
21
  isWallBuildActive: boolean
18
22
  modifierKeys: ModifierKeys
@@ -20,6 +24,26 @@ type ResolveFloorplanBackgroundSelectionArgs = {
20
24
  structureLayer: string
21
25
  }
22
26
 
27
+ function hasToggleModifier(modifierKeys: ModifierKeys): boolean {
28
+ return modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
29
+ }
30
+
31
+ function resolveHitSelection(
32
+ hitId: string,
33
+ currentSelectedIds: string[],
34
+ modifierKeys: ModifierKeys,
35
+ expandIdsForNode?: (nodeId: string) => string[] | null,
36
+ ): string[] {
37
+ if (hasToggleModifier(modifierKeys)) {
38
+ return currentSelectedIds.includes(hitId)
39
+ ? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
40
+ : [...currentSelectedIds, hitId]
41
+ }
42
+ if (modifierKeys.alt) return [hitId]
43
+ const expanded = expandIdsForNode?.(hitId)
44
+ return expanded && expanded.length > 1 ? expanded : [hitId]
45
+ }
46
+
23
47
  export type FloorplanBackgroundSelectionResult =
24
48
  | {
25
49
  handled: true
@@ -48,6 +72,7 @@ export function resolveFloorplanBackgroundSelection({
48
72
  canSelectElementFloorplanGeometry,
49
73
  canSelectFloorplanZones,
50
74
  currentSelectedIds,
75
+ expandIdsForNode,
51
76
  getFloorplanHitIdAtPoint,
52
77
  isWallBuildActive,
53
78
  modifierKeys,
@@ -71,12 +96,7 @@ export function resolveFloorplanBackgroundSelection({
71
96
  return {
72
97
  handled: true,
73
98
  kind: 'select-elements',
74
- selectedIds:
75
- modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
76
- ? currentSelectedIds.includes(hitId)
77
- ? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
78
- : [...currentSelectedIds, hitId]
79
- : [hitId],
99
+ selectedIds: resolveHitSelection(hitId, currentSelectedIds, modifierKeys, expandIdsForNode),
80
100
  }
81
101
  }
82
102
  }
@@ -92,7 +112,7 @@ export function resolveFloorplanBackgroundSelection({
92
112
  return {
93
113
  handled: true,
94
114
  kind: 'clear-elements',
95
- preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
115
+ preserveSelection: hasToggleModifier(modifierKeys),
96
116
  }
97
117
  }
98
118
 
@@ -33,6 +33,7 @@ import {
33
33
  type RoofNode,
34
34
  type RoofSegmentNode,
35
35
  resolveSlabPlacementElevation,
36
+ resolveTerrainWallConstructionOptions,
36
37
  type SiteNode,
37
38
  type SlabNode,
38
39
  SlabNode as SlabNodeSchema,
@@ -88,6 +89,8 @@ import {
88
89
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
89
90
  worldToFloorplanLocalPoint,
90
91
  } from '../../lib/floorplan'
92
+ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
93
+ import type { EditorGridEvent } from '../../lib/grid-event-presentation'
91
94
  import { groundHeightAt } from '../../lib/ground-surface'
92
95
  import { guideEmitter } from '../../lib/guide-events'
93
96
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
@@ -115,6 +118,7 @@ import useInteractionScope, {
115
118
  useReshapingNode,
116
119
  } from '../../store/use-interaction-scope'
117
120
  import usePlacementPreview from '../../store/use-placement-preview'
121
+ import { expandSessionSelectionForNode } from '../../store/use-session-groups'
118
122
  import { useStairBuildPreview } from '../../store/use-stair-build-preview'
119
123
  import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
120
124
  import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
@@ -147,6 +151,7 @@ import {
147
151
  isBoxSelectPointerSuppressed,
148
152
  markBoxSelectHandled,
149
153
  } from '../tools/select/box-select-state'
154
+ import { marqueePolygon } from '../tools/select/marquee-footprint'
150
155
  import {
151
156
  type Point2 as MarqueePoint2,
152
157
  polygonsIntersect as marqueePolygonsIntersect,
@@ -183,7 +188,6 @@ import {
183
188
  chainEndJoinsExistingWall,
184
189
  createWallOnCurrentLevel,
185
190
  isSegmentLongEnough,
186
- resolveTerrainWallConstructionOptions,
187
191
  snapWallDraftPoint,
188
192
  snapWallDraftPointDetailed,
189
193
  snapPointToGrid as snapWallPointToGrid,
@@ -193,7 +197,7 @@ import {
193
197
  } from '../tools/wall/wall-drafting'
194
198
 
195
199
  import { PALETTE_COLORS } from '../ui/primitives/color-dot'
196
- import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
200
+ import { FloorplanCompassButton } from '../viewer/floorplan-compass-button'
197
201
  import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
198
202
  import {
199
203
  subscribeFloorplanCameraNavigation,
@@ -505,50 +509,6 @@ type GuideHandleHintAnchor = {
505
509
  directionY: number
506
510
  }
507
511
 
508
- function FloorplanCompassButton({
509
- northRotationDeg,
510
- onAlignNorth,
511
- needleRef,
512
- }: {
513
- northRotationDeg: number
514
- onAlignNorth: () => void
515
- needleRef?: React.RefObject<SVGSVGElement | null>
516
- }) {
517
- return (
518
- <Tooltip>
519
- <TooltipTrigger asChild>
520
- <button
521
- aria-label="Align view to north"
522
- className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
523
- onClick={(event) => {
524
- event.preventDefault()
525
- event.stopPropagation()
526
- onAlignNorth()
527
- }}
528
- onPointerDown={(event) => {
529
- event.stopPropagation()
530
- }}
531
- type="button"
532
- >
533
- <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
534
- <svg
535
- aria-hidden="true"
536
- className="h-6 w-6"
537
- ref={needleRef}
538
- style={{ transform: `rotate(${northRotationDeg}deg)` }}
539
- viewBox="0 0 48 48"
540
- >
541
- <path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
542
- <path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
543
- </svg>
544
- </span>
545
- </button>
546
- </TooltipTrigger>
547
- <TooltipContent side="right">Align view to north</TooltipContent>
548
- </Tooltip>
549
- )
550
- }
551
-
552
512
  type GuideInteractionState = {
553
513
  pointerId: number
554
514
  guideId: GuideNode['id']
@@ -843,11 +803,13 @@ function getSelectionModifierKeys(event?: {
843
803
  metaKey?: boolean
844
804
  ctrlKey?: boolean
845
805
  shiftKey?: boolean
806
+ altKey?: boolean
846
807
  }) {
847
808
  return {
848
809
  meta: Boolean(event?.metaKey),
849
810
  ctrl: Boolean(event?.ctrlKey),
850
811
  shift: Boolean(event?.shiftKey),
812
+ alt: Boolean(event?.altKey),
851
813
  }
852
814
  }
853
815
 
@@ -905,7 +867,8 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
905
867
  | { start?: unknown; end?: unknown; polygon?: unknown }
906
868
  | undefined
907
869
  if (!node) continue
908
- const { start, end, polygon } = node
870
+ const { start, end } = node
871
+ const polygon = marqueePolygon(node)
909
872
  if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
910
873
  dataTested.add(id)
911
874
  if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
@@ -4714,6 +4677,7 @@ function FloorplanLinearDraftLayer({
4714
4677
  const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
4715
4678
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4716
4679
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4680
+ const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
4717
4681
 
4718
4682
  const draftPolygon = useMemo(() => {
4719
4683
  if (
@@ -4750,6 +4714,21 @@ function FloorplanLinearDraftLayer({
4750
4714
  return draftPolygon ? formatPolygonPoints(draftPolygon) : null
4751
4715
  }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
4752
4716
 
4717
+ const roofDraftDirectionLine = useMemo(() => {
4718
+ if (!(isRoofBuildActive && roofDraftStart && roofDraftEnd)) return null
4719
+ const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
4720
+ const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
4721
+ const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
4722
+ const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
4723
+ if (maxX - minX < 1e-6 || maxY - minY < 1e-6) return null
4724
+
4725
+ const centerX = (minX + maxX) / 2
4726
+ const centerY = (minY + maxY) / 2
4727
+ return roofDraftQuarterTurn
4728
+ ? { x1: centerX, y1: minY, x2: centerX, y2: maxY }
4729
+ : { x1: minX, y1: centerY, x2: maxX, y2: centerY }
4730
+ }, [isRoofBuildActive, roofDraftEnd, roofDraftQuarterTurn, roofDraftStart])
4731
+
4753
4732
  const fenceDraftSegment = useMemo(() => {
4754
4733
  if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
4755
4734
  return null
@@ -4928,6 +4907,19 @@ function FloorplanLinearDraftLayer({
4928
4907
  unitsPerPixel={unitsPerPixel}
4929
4908
  />
4930
4909
 
4910
+ {roofDraftDirectionLine && (
4911
+ <line
4912
+ pointerEvents="none"
4913
+ stroke={draftStroke}
4914
+ strokeLinecap="round"
4915
+ strokeWidth={unitsPerPixel * 1.5}
4916
+ x1={toSvgX(roofDraftDirectionLine.x1)}
4917
+ x2={toSvgX(roofDraftDirectionLine.x2)}
4918
+ y1={toSvgY(roofDraftDirectionLine.y1)}
4919
+ y2={toSvgY(roofDraftDirectionLine.y2)}
4920
+ />
4921
+ )}
4922
+
4931
4923
  {draftWallMeasurement && (
4932
4924
  <FloorplanDraftWallMeasurement
4933
4925
  labelBackground={isDark ? '#0f172a' : '#ffffff'}
@@ -5104,6 +5096,9 @@ export function FloorplanPanel({
5104
5096
  if (building?.type !== 'building') return false
5105
5097
  return building.children.some((cid) => state.nodes[cid]?.type === 'level')
5106
5098
  })
5099
+ // The studio workspace (renders / materials / item builder) is a clean
5100
+ // stage — editor viewport chrome, compass included, stays out of it.
5101
+ const isStudioWorkspace = useEditor((s) => s.workspaceMode === 'studio')
5107
5102
  const elevators = useScene(
5108
5103
  useShallow((state) => {
5109
5104
  const building = currentBuildingId ? state.nodes[currentBuildingId] : null
@@ -8972,12 +8967,29 @@ export function FloorplanPanel({
8972
8967
  const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY)
8973
8968
  const worldY = groundY ?? floorplanGridWorldY
8974
8969
  const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1]
8970
+ const planScene =
8971
+ nativeEvent.currentTarget.querySelector<SVGGraphicsElement>('[data-floorplan-scene]')
8972
+ const screenMatrix = planScene?.getScreenCTM()
8975
8973
 
8976
- emitter.emit(`grid:${eventType}` as any, {
8974
+ const gridEvent: EditorGridEvent = {
8977
8975
  nativeEvent: nativeEvent.nativeEvent as any,
8978
8976
  position: [worldX, worldY, worldZ],
8979
8977
  localPosition: [planPoint[0], localY, planPoint[1]],
8980
- })
8978
+ screenProjection: screenMatrix
8979
+ ? {
8980
+ pointer: [nativeEvent.clientX, nativeEvent.clientY],
8981
+ localToScreen: [
8982
+ screenMatrix.a,
8983
+ screenMatrix.b,
8984
+ screenMatrix.c,
8985
+ screenMatrix.d,
8986
+ screenMatrix.e,
8987
+ screenMatrix.f,
8988
+ ],
8989
+ }
8990
+ : undefined,
8991
+ }
8992
+ emitter.emit(`grid:${eventType}` as any, gridEvent)
8981
8993
  },
8982
8994
  [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
8983
8995
  )
@@ -9441,10 +9453,14 @@ export function FloorplanPanel({
9441
9453
  // this exclusion the catch-all would emit `grid:move` and re-drive the
9442
9454
  // 3D MoveDoorTool's free-follow, fighting the overlay again.
9443
9455
  if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
9444
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
9445
- emitFloorplanGridEvent('move', snappedPoint, event)
9456
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
9457
+ point: planPoint,
9458
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9459
+ snap: getSnappedFloorplanPoint,
9460
+ })
9461
+ emitFloorplanGridEvent('move', eventPoint, event)
9446
9462
  setCursorPoint((previousPoint) =>
9447
- previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
9463
+ previousPoint && pointsEqual(previousPoint, eventPoint) ? previousPoint : eventPoint,
9448
9464
  )
9449
9465
  return
9450
9466
  }
@@ -9544,6 +9560,7 @@ export function FloorplanPanel({
9544
9560
  // stale closure and float a door symbol while the window tool is armed.
9545
9561
  showOpeningGhost,
9546
9562
  isPolygonBuildActive,
9563
+ isRegistryToolBuildActive,
9547
9564
  isRoofBuildActive,
9548
9565
  isWallBuildActive,
9549
9566
  levelId,
@@ -9890,6 +9907,7 @@ export function FloorplanPanel({
9890
9907
  isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
9891
9908
  isPolygonBuildActive,
9892
9909
  isRoofBuildActive,
9910
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9893
9911
  isWallBuildActive,
9894
9912
  isZoneBuildActive,
9895
9913
  levelId,
@@ -9978,6 +9996,7 @@ export function FloorplanPanel({
9978
9996
  canSelectElementFloorplanGeometry,
9979
9997
  canSelectFloorplanZones,
9980
9998
  currentSelectedIds: useViewer.getState().selection.selectedIds,
9999
+ expandIdsForNode: expandSessionSelectionForNode,
9981
10000
  getFloorplanHitIdAtPoint,
9982
10001
  isWallBuildActive,
9983
10002
  modifierKeys,
@@ -10552,9 +10571,11 @@ export function FloorplanPanel({
10552
10571
  }
10553
10572
 
10554
10573
  if (useEditor.getState().phase !== 'site') {
10555
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10574
+ useEditor.getState().setPhase('site')
10575
+ useEditor.getState().armToolMode({ mode: 'select' })
10576
+ } else {
10577
+ selectSiteFloorplanContext()
10556
10578
  }
10557
- selectSiteFloorplanContext()
10558
10579
 
10559
10580
  const nextDraft = {
10560
10581
  siteId,
@@ -10636,9 +10657,11 @@ export function FloorplanPanel({
10636
10657
  ]
10637
10658
 
10638
10659
  if (useEditor.getState().phase !== 'site') {
10639
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10660
+ useEditor.getState().setPhase('site')
10661
+ useEditor.getState().armToolMode({ mode: 'select' })
10662
+ } else {
10663
+ selectSiteFloorplanContext()
10640
10664
  }
10641
- selectSiteFloorplanContext()
10642
10665
 
10643
10666
  const nextDraft = {
10644
10667
  siteId,
@@ -11147,7 +11170,8 @@ export function FloorplanPanel({
11147
11170
  <FloorplanRegistryActionMenu />
11148
11171
  <FloorplanGroupActionMenu />
11149
11172
 
11150
- {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11173
+ {!isStudioWorkspace &&
11174
+ (levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11151
11175
  (compassHost ? (
11152
11176
  createPortal(
11153
11177
  <FloorplanCompassButton
@@ -9,7 +9,7 @@ import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/
9
9
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
10
  import { useCeilingEvents } from '../../hooks/use-ceiling-events'
11
11
  import { useGridEvents } from '../../hooks/use-grid-events'
12
- import { getPlacementSurface } from '../../lib/active-placement-surface'
12
+ import { getPlacementSurface, usesOrientedPlacementPlane } from '../../lib/active-placement-surface'
13
13
  import useEditor, { isGridSnapActive } from '../../store/use-editor'
14
14
  import { getMovingNode } from '../../store/use-interaction-scope'
15
15
 
@@ -229,16 +229,21 @@ export const Grid = ({
229
229
  }
230
230
 
231
231
  const gridMesh = gridRef.current
232
- const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
233
- if (onWall && surfacePoint) {
234
- // Wall-anchored lattice: orient the plane into the wall and pin the mesh to
232
+ const onOrientedPlane = surfacePoint != null && usesOrientedPlacementPlane(surfaceNormal)
233
+ if (onOrientedPlane && surfacePoint) {
234
+ // Surface-anchored lattice: orient the plane into the host and pin the mesh to
235
235
  // the plane's FOOT (the point on the wall plane closest to the world origin)
236
236
  // — never the moving ghost. Sliding the opening along the wall then only
237
237
  // moves the reveal patch (the cursor uniform); the snap lattice stays put.
238
238
  // (Copying `surfacePoint` here made the grid follow the item — useless.)
239
239
  gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
240
240
  const planeOffset = surfacePoint.dot(surfaceNormal)
241
- gridMesh.position.copy(surfaceNormal).multiplyScalar(planeOffset)
241
+ const latticeAnchor = published?.anchor ?? surfacePoint
242
+ gridMesh.position.copy(latticeAnchor)
243
+ gridMesh.position.addScaledVector(
244
+ surfaceNormal,
245
+ -latticeAnchor.dot(surfaceNormal) + planeOffset,
246
+ )
242
247
  // Cursor → plane-local XY: rotate (ghost − anchor) by the inverse plane
243
248
  // orientation. Both lie in the plane, so the resulting local Z is ~0.
244
249
  invQuatRef.current.copy(gridMesh.quaternion).invert()
@@ -275,8 +280,8 @@ export const Grid = ({
275
280
  // Floor grid depth-tests against the scene (ground occludes a sub-floor
276
281
  // lattice); the wall grid ignores depth so it stays visible through the wall
277
282
  // when the opening is being handled from the opposite side.
278
- if (material.depthTest === onWall) {
279
- material.depthTest = !onWall
283
+ if (material.depthTest === onOrientedPlane) {
284
+ material.depthTest = !onOrientedPlane
280
285
  material.needsUpdate = true
281
286
  }
282
287
 
@@ -14,7 +14,7 @@ import {
14
14
  useLiveTransforms,
15
15
  useScene,
16
16
  } from '@pascal-app/core'
17
- import { useViewer } from '@pascal-app/viewer'
17
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
18
18
  import { Plane, Vector2, Vector3 } from 'three'
19
19
  import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
20
20
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
@@ -40,9 +40,11 @@ import {
40
40
  collectParticipants,
41
41
  computeGroupBox,
42
42
  expandToComponent,
43
+ groupPlanBounds,
43
44
  levelFrame,
44
- participantExtents,
45
+ planBoundsCenter,
45
46
  rotateGroupSnapshots,
47
+ rotatePlanBounds,
46
48
  translateGroupPatches,
47
49
  type Vec2,
48
50
  } from './group-transform-shared'
@@ -104,46 +106,15 @@ export function startGroupPickUp(
104
106
  if (starts.length === 0) return false
105
107
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
106
108
 
107
- // Rest bounds in the level frame. Prefer the mounted meshes' world box
108
- // (footprint-accurate), but fall back to the participant DATA when the
109
- // meshes aren't up yet — Duplicate starts the pick-up synchronously after
110
- // `createNodes`, one frame before the clones' renderers mount.
111
109
  const { inverse: frameInv } = levelFrame(levelId)
112
110
  const restBox = computeGroupBox(fullIds)
113
- let minX = Number.POSITIVE_INFINITY
114
- let minZ = Number.POSITIVE_INFINITY
115
- let maxX = Number.NEGATIVE_INFINITY
116
- let maxZ = Number.NEGATIVE_INFINITY
117
- if (restBox) {
118
- const boxMin = restBox.min.clone().applyMatrix4(frameInv)
119
- const boxMax = restBox.max.clone().applyMatrix4(frameInv)
120
- minX = Math.min(boxMin.x, boxMax.x)
121
- minZ = Math.min(boxMin.z, boxMax.z)
122
- maxX = Math.max(boxMin.x, boxMax.x)
123
- maxZ = Math.max(boxMin.z, boxMax.z)
124
- } else {
125
- const reach = (x: number, z: number) => {
126
- minX = Math.min(minX, x)
127
- minZ = Math.min(minZ, z)
128
- maxX = Math.max(maxX, x)
129
- maxZ = Math.max(maxZ, z)
130
- }
131
- for (const s of starts) {
132
- if (s.kind === 'endpoint') {
133
- reach(s.start[0], s.start[1])
134
- reach(s.end[0], s.end[1])
135
- } else if (s.kind === 'polygon') {
136
- for (const [x, z] of s.polygon) {
137
- reach(x, z)
138
- }
139
- } else {
140
- reach(s.position[0], s.position[2])
141
- }
142
- }
143
- }
144
- if (!Number.isFinite(minX)) return false
111
+ const startBounds = groupPlanBounds(restBox, starts, frameInv)
112
+ if (!startBounds) return false
113
+ // Mutable: mid-carry R/T re-seeds the footprint around the same pivot.
114
+ let restBounds = startBounds
115
+ let carriedRotation = 0
145
116
  // Rotation pivot for mid-carry R/T; stable across the whole pick-up.
146
- const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
117
+ const restCenter = planBoundsCenter(restBounds)
147
118
  // Ground plane for the 3D surface: the meshes' base when available, floor
148
119
  // level otherwise. Placements live in the level frame, so both surfaces
149
120
  // resolve into it before measuring.
@@ -157,7 +128,13 @@ export function startGroupPickUp(
157
128
  if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
158
129
  }
159
130
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
160
- let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
131
+ let restAnchors = bboxCornerAnchors(
132
+ 'group-move',
133
+ restBounds.minX,
134
+ restBounds.minZ,
135
+ restBounds.maxX,
136
+ restBounds.maxZ,
137
+ )
161
138
 
162
139
  // Cursor → level-frame plan point, whichever surface the pointer is over.
163
140
  const ndc = new Vector2()
@@ -274,18 +251,20 @@ export function startGroupPickUp(
274
251
  // the current delta — the carried group turns exactly like the idle
275
252
  // keyboard rotate, and the placement stays a single updateNodes.
276
253
  const rotateCarried = (direction: 1 | -1) => {
277
- const rotated = rotateGroupSnapshots(
278
- starts,
279
- links,
280
- { x: restCenter[0], z: restCenter[1] },
281
- -direction * (Math.PI / 4),
282
- )
254
+ const pivot = { x: restCenter[0], z: restCenter[1] }
255
+ const delta = -direction * (Math.PI / 4)
256
+ const rotated = rotateGroupSnapshots(starts, links, pivot, delta)
283
257
  starts = rotated.starts
284
258
  links = rotated.links
285
- const ext = participantExtents(rotated.starts)
286
- if (ext) {
287
- restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
288
- }
259
+ carriedRotation += delta
260
+ restBounds = rotatePlanBounds(startBounds, pivot, carriedRotation)
261
+ restAnchors = bboxCornerAnchors(
262
+ 'group-move',
263
+ restBounds.minX,
264
+ restBounds.minZ,
265
+ restBounds.maxX,
266
+ restBounds.maxZ,
267
+ )
289
268
  sfxEmitter.emit('sfx:item-rotate')
290
269
  applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
291
270
  }
@@ -575,6 +554,11 @@ export function deleteSelection(): boolean {
575
554
  if (selectedIds.length === 0) return false
576
555
 
577
556
  const commitDelete = () => {
557
+ const detail =
558
+ selectedIds.length === 1
559
+ ? (useScene.getState().nodes[selectedIds[0]!]?.type ?? selectedIds[0]!)
560
+ : String(selectedIds.length)
561
+ markPerfAction('delete', detail)
578
562
  if (selectedIds.length === 1) {
579
563
  emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
580
564
  } else {