@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -33,6 +33,8 @@ import {
33
33
  type RoofNode,
34
34
  type RoofSegmentNode,
35
35
  resolveSlabPlacementElevation,
36
+ resolveTerrainWallConstructionOptions,
37
+ runAsSingleSceneHistoryStep,
36
38
  type SiteNode,
37
39
  type SlabNode,
38
40
  SlabNode as SlabNodeSchema,
@@ -88,6 +90,8 @@ import {
88
90
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
89
91
  worldToFloorplanLocalPoint,
90
92
  } from '../../lib/floorplan'
93
+ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
94
+ import type { EditorGridEvent } from '../../lib/grid-event-presentation'
91
95
  import { groundHeightAt } from '../../lib/ground-surface'
92
96
  import { guideEmitter } from '../../lib/guide-events'
93
97
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
@@ -95,6 +99,7 @@ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measureme
95
99
  import { sfxEmitter } from '../../lib/sfx-bus'
96
100
  import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
97
101
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
102
+ import { cancelPendingZonePaint, focusedUnitNode, paintZoneMembership } from '../../lib/units'
98
103
  import { cn } from '../../lib/utils'
99
104
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
100
105
  import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
@@ -148,6 +153,7 @@ import {
148
153
  isBoxSelectPointerSuppressed,
149
154
  markBoxSelectHandled,
150
155
  } from '../tools/select/box-select-state'
156
+ import { marqueePolygon } from '../tools/select/marquee-footprint'
151
157
  import {
152
158
  type Point2 as MarqueePoint2,
153
159
  polygonsIntersect as marqueePolygonsIntersect,
@@ -184,7 +190,6 @@ import {
184
190
  chainEndJoinsExistingWall,
185
191
  createWallOnCurrentLevel,
186
192
  isSegmentLongEnough,
187
- resolveTerrainWallConstructionOptions,
188
193
  snapWallDraftPoint,
189
194
  snapWallDraftPointDetailed,
190
195
  snapPointToGrid as snapWallPointToGrid,
@@ -864,7 +869,8 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
864
869
  | { start?: unknown; end?: unknown; polygon?: unknown }
865
870
  | undefined
866
871
  if (!node) continue
867
- const { start, end, polygon } = node
872
+ const { start, end } = node
873
+ const polygon = marqueePolygon(node)
868
874
  if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
869
875
  dataTested.add(id)
870
876
  if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
@@ -4673,6 +4679,7 @@ function FloorplanLinearDraftLayer({
4673
4679
  const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
4674
4680
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4675
4681
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4682
+ const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
4676
4683
 
4677
4684
  const draftPolygon = useMemo(() => {
4678
4685
  if (
@@ -4709,6 +4716,21 @@ function FloorplanLinearDraftLayer({
4709
4716
  return draftPolygon ? formatPolygonPoints(draftPolygon) : null
4710
4717
  }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
4711
4718
 
4719
+ const roofDraftDirectionLine = useMemo(() => {
4720
+ if (!(isRoofBuildActive && roofDraftStart && roofDraftEnd)) return null
4721
+ const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
4722
+ const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
4723
+ const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
4724
+ const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
4725
+ if (maxX - minX < 1e-6 || maxY - minY < 1e-6) return null
4726
+
4727
+ const centerX = (minX + maxX) / 2
4728
+ const centerY = (minY + maxY) / 2
4729
+ return roofDraftQuarterTurn
4730
+ ? { x1: centerX, y1: minY, x2: centerX, y2: maxY }
4731
+ : { x1: minX, y1: centerY, x2: maxX, y2: centerY }
4732
+ }, [isRoofBuildActive, roofDraftEnd, roofDraftQuarterTurn, roofDraftStart])
4733
+
4712
4734
  const fenceDraftSegment = useMemo(() => {
4713
4735
  if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
4714
4736
  return null
@@ -4887,6 +4909,19 @@ function FloorplanLinearDraftLayer({
4887
4909
  unitsPerPixel={unitsPerPixel}
4888
4910
  />
4889
4911
 
4912
+ {roofDraftDirectionLine && (
4913
+ <line
4914
+ pointerEvents="none"
4915
+ stroke={draftStroke}
4916
+ strokeLinecap="round"
4917
+ strokeWidth={unitsPerPixel * 1.5}
4918
+ x1={toSvgX(roofDraftDirectionLine.x1)}
4919
+ x2={toSvgX(roofDraftDirectionLine.x2)}
4920
+ y1={toSvgY(roofDraftDirectionLine.y1)}
4921
+ y2={toSvgY(roofDraftDirectionLine.y2)}
4922
+ />
4923
+ )}
4924
+
4890
4925
  {draftWallMeasurement && (
4891
4926
  <FloorplanDraftWallMeasurement
4892
4927
  labelBackground={isDark ? '#0f172a' : '#ffffff'}
@@ -5063,6 +5098,9 @@ export function FloorplanPanel({
5063
5098
  if (building?.type !== 'building') return false
5064
5099
  return building.children.some((cid) => state.nodes[cid]?.type === 'level')
5065
5100
  })
5101
+ // The studio workspace (renders / materials / item builder) is a clean
5102
+ // stage — editor viewport chrome, compass included, stays out of it.
5103
+ const isStudioWorkspace = useEditor((s) => s.workspaceMode === 'studio')
5066
5104
  const elevators = useScene(
5067
5105
  useShallow((state) => {
5068
5106
  const building = currentBuildingId ? state.nodes[currentBuildingId] : null
@@ -7916,7 +7954,7 @@ export function FloorplanPanel({
7916
7954
  return null
7917
7955
  }
7918
7956
 
7919
- const { createNode, nodes } = useScene.getState()
7957
+ const { createNode, updateNode, nodes } = useScene.getState()
7920
7958
  const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length
7921
7959
  const zone = ZoneNodeSchema.parse({
7922
7960
  color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],
@@ -7924,9 +7962,15 @@ export function FloorplanPanel({
7924
7962
  polygon: points.map(([x, z]) => [x, z] as [number, number]),
7925
7963
  })
7926
7964
 
7927
- createNode(zone, levelId)
7965
+ // Joining the focused unit rides in the zone's own undo step; selecting
7966
+ // the zone would end focus, so a painted unit keeps it instead.
7967
+ const focusedUnit = focusedUnitNode()
7968
+ runAsSingleSceneHistoryStep(useScene, () => {
7969
+ createNode(zone, levelId)
7970
+ if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
7971
+ })
7928
7972
  sfxEmitter.emit('sfx:structure-build')
7929
- setSelection({ zoneId: zone.id })
7973
+ if (!focusedUnit) setSelection({ zoneId: zone.id })
7930
7974
  return zone.id
7931
7975
  },
7932
7976
  [levelId, setSelection],
@@ -8931,12 +8975,29 @@ export function FloorplanPanel({
8931
8975
  const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY)
8932
8976
  const worldY = groundY ?? floorplanGridWorldY
8933
8977
  const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1]
8978
+ const planScene =
8979
+ nativeEvent.currentTarget.querySelector<SVGGraphicsElement>('[data-floorplan-scene]')
8980
+ const screenMatrix = planScene?.getScreenCTM()
8934
8981
 
8935
- emitter.emit(`grid:${eventType}` as any, {
8982
+ const gridEvent: EditorGridEvent = {
8936
8983
  nativeEvent: nativeEvent.nativeEvent as any,
8937
8984
  position: [worldX, worldY, worldZ],
8938
8985
  localPosition: [planPoint[0], localY, planPoint[1]],
8939
- })
8986
+ screenProjection: screenMatrix
8987
+ ? {
8988
+ pointer: [nativeEvent.clientX, nativeEvent.clientY],
8989
+ localToScreen: [
8990
+ screenMatrix.a,
8991
+ screenMatrix.b,
8992
+ screenMatrix.c,
8993
+ screenMatrix.d,
8994
+ screenMatrix.e,
8995
+ screenMatrix.f,
8996
+ ],
8997
+ }
8998
+ : undefined,
8999
+ }
9000
+ emitter.emit(`grid:${eventType}` as any, gridEvent)
8940
9001
  },
8941
9002
  [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
8942
9003
  )
@@ -9400,10 +9461,14 @@ export function FloorplanPanel({
9400
9461
  // this exclusion the catch-all would emit `grid:move` and re-drive the
9401
9462
  // 3D MoveDoorTool's free-follow, fighting the overlay again.
9402
9463
  if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
9403
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
9404
- emitFloorplanGridEvent('move', snappedPoint, event)
9464
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
9465
+ point: planPoint,
9466
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9467
+ snap: getSnappedFloorplanPoint,
9468
+ })
9469
+ emitFloorplanGridEvent('move', eventPoint, event)
9405
9470
  setCursorPoint((previousPoint) =>
9406
- previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
9471
+ previousPoint && pointsEqual(previousPoint, eventPoint) ? previousPoint : eventPoint,
9407
9472
  )
9408
9473
  return
9409
9474
  }
@@ -9503,6 +9568,7 @@ export function FloorplanPanel({
9503
9568
  // stale closure and float a door symbol while the window tool is armed.
9504
9569
  showOpeningGhost,
9505
9570
  isPolygonBuildActive,
9571
+ isRegistryToolBuildActive,
9506
9572
  isRoofBuildActive,
9507
9573
  isWallBuildActive,
9508
9574
  levelId,
@@ -9849,6 +9915,7 @@ export function FloorplanPanel({
9849
9915
  isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
9850
9916
  isPolygonBuildActive,
9851
9917
  isRoofBuildActive,
9918
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9852
9919
  isWallBuildActive,
9853
9920
  isZoneBuildActive,
9854
9921
  levelId,
@@ -9949,6 +10016,18 @@ export function FloorplanPanel({
9949
10016
  setSelectedReferenceId(null)
9950
10017
 
9951
10018
  if (backgroundSelection.kind === 'select-zone') {
10019
+ // Unit focus: a click paints membership, a double-click selects the
10020
+ // zone and keeps focus (the SVG gets the second click as detail 2).
10021
+ const focusedUnitId = useViewer.getState().focusedUnitId
10022
+ if (focusedUnitId) {
10023
+ if (event.detail >= 2) {
10024
+ cancelPendingZonePaint(backgroundSelection.zoneId)
10025
+ setSelection({ zoneId: backgroundSelection.zoneId })
10026
+ } else {
10027
+ paintZoneMembership(focusedUnitId, backgroundSelection.zoneId)
10028
+ }
10029
+ return
10030
+ }
9952
10031
  setSelection({ zoneId: backgroundSelection.zoneId })
9953
10032
  return
9954
10033
  }
@@ -10512,9 +10591,11 @@ export function FloorplanPanel({
10512
10591
  }
10513
10592
 
10514
10593
  if (useEditor.getState().phase !== 'site') {
10515
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10594
+ useEditor.getState().setPhase('site')
10595
+ useEditor.getState().armToolMode({ mode: 'select' })
10596
+ } else {
10597
+ selectSiteFloorplanContext()
10516
10598
  }
10517
- selectSiteFloorplanContext()
10518
10599
 
10519
10600
  const nextDraft = {
10520
10601
  siteId,
@@ -10596,9 +10677,11 @@ export function FloorplanPanel({
10596
10677
  ]
10597
10678
 
10598
10679
  if (useEditor.getState().phase !== 'site') {
10599
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10680
+ useEditor.getState().setPhase('site')
10681
+ useEditor.getState().armToolMode({ mode: 'select' })
10682
+ } else {
10683
+ selectSiteFloorplanContext()
10600
10684
  }
10601
- selectSiteFloorplanContext()
10602
10685
 
10603
10686
  const nextDraft = {
10604
10687
  siteId,
@@ -11107,7 +11190,8 @@ export function FloorplanPanel({
11107
11190
  <FloorplanRegistryActionMenu />
11108
11191
  <FloorplanGroupActionMenu />
11109
11192
 
11110
- {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11193
+ {!isStudioWorkspace &&
11194
+ (levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11111
11195
  (compassHost ? (
11112
11196
  createPortal(
11113
11197
  <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 {
@@ -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: {