@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -15,7 +15,12 @@ import useSessionGroups, {
15
15
  ungroupCurrentSelection,
16
16
  } from '../../store/use-session-groups'
17
17
  import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
18
- import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
18
+ import {
19
+ classifyParticipant,
20
+ computeGroupBox,
21
+ computeGroupPlanBox,
22
+ levelFrame,
23
+ } from './group-transform-shared'
19
24
  import { NodeActionMenu } from './node-action-menu'
20
25
  import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
21
26
 
@@ -69,15 +74,20 @@ export function GroupFloatingActionMenu() {
69
74
  const anchor = useMemo(() => {
70
75
  void meshEpoch
71
76
  if (participantIds.length === 0) return null
72
- const fullIds = expandToComponent(participantIds, nodes, levelId)
73
- const box = computeGroupBox(fullIds)
77
+ const box = computeGroupBox(participantIds)
74
78
  if (!box) return null
75
- return new THREE.Vector3(
76
- (box.min.x + box.max.x) / 2,
77
- box.max.y + MENU_Y_OFFSET,
78
- (box.min.z + box.max.z) / 2,
79
- )
80
- }, [participantIds, nodes, levelId, meshEpoch])
79
+ // Over the level-frame box's centre (the dashed boxes' and gizmo's), so the
80
+ // pill stays on the box under a rotated building; the mesh box gives the top.
81
+ const levelId = useViewer.getState().selection.levelId
82
+ const plan = computeGroupPlanBox(participantIds, levelId)
83
+ const anchor = plan
84
+ ? new THREE.Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4(
85
+ levelFrame(levelId).matrix,
86
+ )
87
+ : new THREE.Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2)
88
+ anchor.y = box.max.y + MENU_Y_OFFSET
89
+ return anchor
90
+ }, [participantIds, meshEpoch])
81
91
 
82
92
  useFrame((state) => {
83
93
  if (!(menuScaleRef.current && groupRef.current)) return
@@ -31,7 +31,6 @@ import {
31
31
  classifyParticipant,
32
32
  collectParticipants,
33
33
  computeGroupBox,
34
- expandToComponent,
35
34
  type GroupPlanBounds,
36
35
  groupPlanBounds,
37
36
  levelFrame,
@@ -107,16 +106,15 @@ export function armGroupMove3d(args: {
107
106
  const participantIds = selectedIds.filter(
108
107
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
109
108
  )
110
- // Move the full connected wall/fence component, mirroring the 2D session.
111
- const fullIds = expandToComponent(participantIds, nodes, levelId)
112
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
109
+ // Only the selection moves; connected walls stretch through `links`, as in 2D.
110
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
113
111
  if (starts.length === 0) return null
114
112
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
115
113
 
116
114
  // Horizontal drag plane at the group's base; placements live in the level
117
115
  // frame, so world-space plane hits convert through it (a rotated building
118
116
  // would otherwise drift off-axis from the cursor).
119
- const restBox = computeGroupBox(fullIds)
117
+ const restBox = computeGroupBox(participantIds)
120
118
  if (!restBox) return null
121
119
  const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y)
122
120
  const { inverse: frameInv } = levelFrame(levelId)
@@ -136,7 +134,7 @@ export function armGroupMove3d(args: {
136
134
  if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
137
135
  }
138
136
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
139
- const restBounds = groupPlanBounds(restBox, starts, frameInv)
137
+ const restBounds = groupPlanBounds(starts, frameInv)
140
138
  if (!restBounds) return null
141
139
  const restAnchors = bboxCornerAnchors(
142
140
  'group-move',
@@ -13,11 +13,13 @@ import {
13
13
  import { useViewer } from '@pascal-app/viewer'
14
14
  import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
15
15
  import { useEffect, useMemo, useRef, useState } from 'react'
16
- import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
16
+ import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three'
17
17
  import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
18
18
  import { isHistoryShortcut } from '../../lib/history'
19
19
  import { sfxEmitter } from '../../lib/sfx-bus'
20
- import useEditor from '../../store/use-editor'
20
+ import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane'
21
+ import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input'
22
+ import useEditor, { isAngleSnapActive } from '../../store/use-editor'
21
23
  import useInteractionScope, {
22
24
  useActiveHandleDrag,
23
25
  useMovingNode,
@@ -28,7 +30,7 @@ import {
28
30
  classifyParticipant,
29
31
  collectParticipants,
30
32
  computeGroupBox,
31
- expandToComponent,
33
+ computeGroupPlanBox,
32
34
  levelFrame,
33
35
  rotateGroupPatches,
34
36
  type Vec3,
@@ -79,14 +81,6 @@ export function GroupRotateHandle() {
79
81
  [selectedIds, levelId, nodes],
80
82
  )
81
83
 
82
- // Gate on the explicit selection (so a single connected wall still gets the
83
- // per-node handles), but transform the full connected wall/fence component so
84
- // attached structure rotates rigidly as one piece.
85
- const fullIds = useMemo(
86
- () => expandToComponent(participantIds, nodes, levelId),
87
- [participantIds, levelId, nodes],
88
- )
89
-
90
84
  const shouldRender =
91
85
  participantIds.length >= 2 &&
92
86
  mode !== 'delete' &&
@@ -98,7 +92,13 @@ export function GroupRotateHandle() {
98
92
 
99
93
  if (!shouldRender) return null
100
94
  // Remount when the moving set changes so the rest pivot re-seeds cleanly.
101
- return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
95
+ return (
96
+ <GroupRotateHandleInner
97
+ ids={participantIds}
98
+ key={participantIds.join(',')}
99
+ meshEpoch={meshEpoch}
100
+ />
101
+ )
102
102
  }
103
103
 
104
104
  function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
@@ -125,22 +125,28 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
125
125
  const baseScale = zoom * ARROW_SCALE * 1.05
126
126
  const scale = (isHovered ? 1.12 : 1) * baseScale
127
127
 
128
- // World-space bounding box of the selected meshes. Levels are axis-aligned in
129
- // XZ, so world XZ coincides with each node's level-local placement — letting
130
- // us rotate `position` / `start` / `end` directly against the pivot without
131
- // per-node frame conversion.
132
- // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
133
- // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
128
+ // Both rest points come from the level-frame box (the dashed boxes' box, also
129
+ // keyboard R/T's pivot), carried to world so they stay on it under a rotated
130
+ // building; the world mesh box only supplies heights.
131
+ // - `pivot` = box center, Y at the group's base → the rotation origin
132
+ // - `corner` = far box corner at mid-height → where the gizmo sits
134
133
  const rest = useMemo(() => {
135
134
  void meshEpoch
136
135
  const box = computeGroupBox(ids)
137
136
  if (!box) return null
138
- const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
139
- const corner = new Vector3(
140
- box.max.x + CORNER_OFFSET,
141
- (box.min.y + box.max.y) / 2,
142
- box.max.z + CORNER_OFFSET,
143
- )
137
+ const levelId = useViewer.getState().selection.levelId
138
+ const { matrix } = levelFrame(levelId)
139
+ const plan = computeGroupPlanBox(ids, levelId)
140
+ const pivot = plan
141
+ ? new Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4(
142
+ matrix,
143
+ )
144
+ : new Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2)
145
+ pivot.y = box.min.y
146
+ const corner = plan
147
+ ? new Vector3(plan.maxX + CORNER_OFFSET, 0, plan.maxZ + CORNER_OFFSET).applyMatrix4(matrix)
148
+ : new Vector3(box.max.x + CORNER_OFFSET, 0, box.max.z + CORNER_OFFSET)
149
+ corner.y = (box.min.y + box.max.y) / 2
144
150
  return { pivot, corner }
145
151
  }, [ids, meshEpoch])
146
152
 
@@ -163,6 +169,12 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
163
169
  if (event.button !== 0) return
164
170
  event.stopPropagation()
165
171
  suppressBoxSelectForPointer(event)
172
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
173
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
174
+ const pointerTarget = event.object as typeof event.object & {
175
+ releasePointerCapture?: (pointerId: number) => void
176
+ setPointerCapture?: (pointerId: number) => void
177
+ }
166
178
 
167
179
  frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
168
180
  const center = rest.pivot.clone()
@@ -213,11 +225,15 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
213
225
  )
214
226
  }
215
227
 
216
- setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
217
- raycaster.setFromCamera(ndc, camera)
228
+ if (!spatialRay) {
229
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
230
+ raycaster.setFromCamera(ndc, camera)
231
+ }
218
232
  const hit = new Vector3()
219
- if (!raycaster.ray.intersectPlane(plane, hit)) return
233
+ if (!intersectSpatialDragPlane(spatialRay ?? raycaster.ray, plane, hit)) return
220
234
  const initialAngle = angleOf(hit)
235
+ if (spatialPointerId) pointerTarget.setPointerCapture?.(event.pointerId)
236
+ let altKey = event.nativeEvent.altKey
221
237
 
222
238
  document.body.style.cursor = 'grabbing'
223
239
  sfxEmitter.emit('sfx:item-pick')
@@ -230,15 +246,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
230
246
  })
231
247
  setIsDragging(true)
232
248
 
233
- const onMove = (e: PointerEvent) => {
234
- setNDC(e.clientX, e.clientY)
235
- raycaster.setFromCamera(ndc, camera)
249
+ const applyRay = (ray: Ray, freeRotation = false) => {
236
250
  const moveHit = new Vector3()
237
- if (!raycaster.ray.intersectPlane(plane, moveHit)) return
251
+ if (!intersectSpatialDragPlane(ray, plane, moveHit)) return
238
252
  let delta = angleOf(moveHit) - initialAngle
239
253
  while (delta > Math.PI) delta -= 2 * Math.PI
240
254
  while (delta < -Math.PI) delta += 2 * Math.PI
241
- if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
255
+ if (!freeRotation && isAngleSnapActive())
256
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
242
257
 
243
258
  // Shared rigid-rotation math (also used by the keyboard group R/T);
244
259
  // see `rotateGroupPatches` for the orbit/yaw handedness contract.
@@ -286,8 +301,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
286
301
  })
287
302
  }
288
303
  }
304
+ const onMove = (e: PointerEvent) => {
305
+ setNDC(e.clientX, e.clientY)
306
+ raycaster.setFromCamera(ndc, camera)
307
+ applyRay(raycaster.ray, e.altKey)
308
+ }
289
309
 
290
310
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
311
+ let releaseSpatialCapture: (() => void) | null = null
291
312
  const clearLivePreviews = () => {
292
313
  const overrides = useLiveNodeOverrides.getState()
293
314
  const liveTransforms = useLiveTransforms.getState()
@@ -303,6 +324,10 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
303
324
  window.removeEventListener('pointerup', onUp)
304
325
  window.removeEventListener('pointercancel', onCancel)
305
326
  window.removeEventListener('keydown', onKeyDown, true)
327
+ window.removeEventListener('keyup', onKeyUp, true)
328
+ releaseSpatialCapture?.()
329
+ releaseSpatialCapture = null
330
+ if (spatialPointerId) pointerTarget.releasePointerCapture?.(event.pointerId)
306
331
  if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
307
332
  useScene.temporal.getState().resume()
308
333
  useViewer.getState().setInputDragging(false)
@@ -352,12 +377,19 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
352
377
  // Escape / ⌘Z abort the rotate — capture phase so they win over the global
353
378
  // use-keyboard arms (⌘Z must never history-jump under a live pointer).
354
379
  const onKeyDown = (e: KeyboardEvent) => {
380
+ if (e.key === 'Alt') {
381
+ altKey = true
382
+ return
383
+ }
355
384
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
356
385
  e.preventDefault()
357
386
  e.stopPropagation()
358
387
  swallowNextClick()
359
388
  onCancel()
360
389
  }
390
+ const onKeyUp = (e: KeyboardEvent) => {
391
+ if (e.key === 'Alt') altKey = false
392
+ }
361
393
 
362
394
  dragCleanupRef.current = () => {
363
395
  clearLivePreviews()
@@ -366,10 +398,22 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
366
398
  for (const id of affectedIds) {
367
399
  useLiveTransforms.getState().clear(id)
368
400
  }
369
- window.addEventListener('pointermove', onMove)
370
- window.addEventListener('pointerup', onUp)
371
- window.addEventListener('pointercancel', onCancel)
401
+ if (spatialPointerId && spatialRay) {
402
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
403
+ onMove: (ray) => {
404
+ spatialRay.copy(ray)
405
+ applyRay(spatialRay, altKey)
406
+ },
407
+ onRelease: onUp,
408
+ onCancel,
409
+ })
410
+ } else {
411
+ window.addEventListener('pointermove', onMove)
412
+ window.addEventListener('pointerup', onUp)
413
+ window.addEventListener('pointercancel', onCancel)
414
+ }
372
415
  window.addEventListener('keydown', onKeyDown, true)
416
+ window.addEventListener('keyup', onKeyUp, true)
373
417
  }
374
418
 
375
419
  return createPortal(
@@ -398,6 +442,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
398
442
  onPointerDown={activate}
399
443
  onPointerEnter={onHoverEnter}
400
444
  onPointerLeave={onHoverLeave}
445
+ {...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
401
446
  scale={baseScale}
402
447
  />
403
448
  <mesh
@@ -407,6 +452,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
407
452
  onPointerDown={activate}
408
453
  onPointerEnter={onHoverEnter}
409
454
  onPointerLeave={onHoverLeave}
455
+ {...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
410
456
  renderOrder={1010}
411
457
  scale={scale}
412
458
  />
@@ -12,7 +12,7 @@ import { armGroupMove3d } from './group-move-3d'
12
12
  import {
13
13
  classifyParticipant,
14
14
  computeGroupBox,
15
- expandToComponent,
15
+ computeGroupPlanBox,
16
16
  levelFrame,
17
17
  } from './group-transform-shared'
18
18
  import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
@@ -24,8 +24,8 @@ const BOX_PAD = 0.06
24
24
 
25
25
  /**
26
26
  * 3D sibling of the 2D dashed group selection box: a dashed wireframe around
27
- * the multi-selection's transformable participants (expanded to the welded
28
- * wall/fence component) that doubles as the group's whole-volume drag
27
+ * the multi-selection's transformable participants that doubles as the
28
+ * group's whole-volume drag
29
29
  * handle — move cursor across it, press-drag anywhere on it slides the group,
30
30
  * a plain click picks it up. Holding a selection modifier passes the press
31
31
  * through so members inside can still be toggled. Rides the live drag delta
@@ -55,22 +55,30 @@ export function GroupSelectionBox3D() {
55
55
  const box = useMemo(() => {
56
56
  void meshEpoch
57
57
  if (participantIds.length === 0) return null
58
- const fullIds = expandToComponent(participantIds, nodes, levelId)
59
- const world = computeGroupBox(fullIds)
60
- if (!world) return null
61
- const size = new THREE.Vector3()
62
- world.getSize(size)
63
- const center = new THREE.Vector3()
64
- world.getCenter(center)
58
+ // Aligned with the building like the 2D box: level-frame footprint, world
59
+ // height span, turned by the level's rotation.
60
+ const world = computeGroupBox(participantIds)
61
+ const { matrix } = levelFrame(levelId)
62
+ const plan = computeGroupPlanBox(participantIds, levelId)
63
+ if (!(world && plan)) return null
64
+ const center = new THREE.Vector3(
65
+ (plan.minX + plan.maxX) / 2,
66
+ 0,
67
+ (plan.minZ + plan.maxZ) / 2,
68
+ ).applyMatrix4(matrix)
69
+ center.y = (world.min.y + world.max.y) / 2
70
+ const quaternion = new THREE.Quaternion()
71
+ matrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3())
65
72
  return {
66
- size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
67
- number,
68
- number,
69
- number,
70
- ],
73
+ size: [
74
+ plan.maxX - plan.minX + 2 * BOX_PAD,
75
+ world.max.y - world.min.y + 2 * BOX_PAD,
76
+ plan.maxZ - plan.minZ + 2 * BOX_PAD,
77
+ ] as [number, number, number],
71
78
  center,
79
+ quaternion,
72
80
  }
73
- }, [participantIds, nodes, levelId, meshEpoch])
81
+ }, [participantIds, levelId, meshEpoch])
74
82
 
75
83
  // Dashed wireframe. Built per box size (rare — selection / commit changes)
76
84
  // because LineDashedMaterial measures dashes along the line, so scaling a
@@ -156,7 +164,7 @@ export function GroupSelectionBox3D() {
156
164
  ]
157
165
 
158
166
  return (
159
- <group position={position}>
167
+ <group position={position} quaternion={box.quaternion}>
160
168
  <primitive object={edges} />
161
169
  {/* Invisible whole-volume hit target — the box IS the drag handle. */}
162
170
  <mesh
@@ -1,9 +1,24 @@
1
1
  import { beforeAll, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ CeilingNode,
7
+ LevelNode,
8
+ nodeRegistry,
9
+ registerNode,
10
+ SlabNode,
11
+ sceneRegistry,
12
+ useScene,
13
+ WallNode,
14
+ } from '@pascal-app/core'
15
+ import { BoxGeometry, Group, Mesh } from 'three'
3
16
  import { z } from 'zod'
4
17
  import {
5
18
  classifyParticipant,
6
19
  collectParticipants,
20
+ computeGroupPlanBox,
21
+ groupPlanBounds,
7
22
  planBoundsCenter,
8
23
  rotateGroupPatches,
9
24
  rotateGroupSnapshots,
@@ -472,3 +487,113 @@ describe('group transform participants', () => {
472
487
  ])
473
488
  })
474
489
  })
490
+
491
+ describe('group plan bounds', () => {
492
+ const withNodes = (nodes: AnyNode[], run: () => void) => {
493
+ const saved = useScene.getState().nodes
494
+ useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved })
495
+ try {
496
+ run()
497
+ } finally {
498
+ useScene.setState({ nodes: saved })
499
+ }
500
+ }
501
+
502
+ test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => {
503
+ const level = new Group()
504
+ level.position.set(10, 0, -4)
505
+ level.rotation.y = Math.PI / 5
506
+ const item = new Mesh(new BoxGeometry(1, 1, 1))
507
+ item.position.set(2, 0.5, 0)
508
+ level.add(item)
509
+ level.updateWorldMatrix(true, true)
510
+ const registry = sceneRegistry.nodes as unknown as Map<string, unknown>
511
+ registry.set('item_plan_bounds_test', item)
512
+ const start = {
513
+ id: 'item_plan_bounds_test' as AnyNodeId,
514
+ kind: 'vec3' as const,
515
+ position: [2, 0, 0] as [number, number, number],
516
+ rotation: [0, 0, 0] as [number, number, number],
517
+ }
518
+ try {
519
+ const frameInv = level.matrixWorld.clone().invert()
520
+ const built = groupPlanBounds([start], frameInv)
521
+ expect(built?.minX).toBeCloseTo(1.5)
522
+ expect(built?.maxX).toBeCloseTo(2.5)
523
+ expect(built?.minZ).toBeCloseTo(-0.5)
524
+ expect(built?.maxZ).toBeCloseTo(0.5)
525
+ // Unbuilt while the 3D scene is paused: the placeholder must not count.
526
+ item.geometry.userData.placeholder = true
527
+ expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 })
528
+ } finally {
529
+ registry.delete('item_plan_bounds_test')
530
+ }
531
+ })
532
+
533
+ test('hugs the selected wall outlines; a connected wall stays outside', () => {
534
+ const level = LevelNode.parse({ children: [] })
535
+ const bottom = WallNode.parse({
536
+ parentId: level.id,
537
+ start: [0, 0],
538
+ end: [2.4, 0],
539
+ thickness: 0.2,
540
+ })
541
+ const side = WallNode.parse({
542
+ parentId: level.id,
543
+ start: [2.4, 0],
544
+ end: [2.4, 5.3],
545
+ thickness: 0.2,
546
+ })
547
+ const neighbour = WallNode.parse({
548
+ parentId: level.id,
549
+ start: [2.4, 5.3],
550
+ end: [8, 5.3],
551
+ thickness: 0.2,
552
+ })
553
+ withNodes([level, bottom, side, neighbour], () => {
554
+ const plan = computeGroupPlanBox([bottom.id, side.id], level.id)
555
+ expect(plan?.minX).toBeCloseTo(0)
556
+ expect(plan?.maxX).toBeCloseTo(2.5)
557
+ expect(plan?.minZ).toBeCloseTo(-0.1)
558
+ expect(plan?.maxZ).toBeLessThan(5.5)
559
+ expect(plan?.maxX).toBeLessThan(8)
560
+ })
561
+ })
562
+
563
+ test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => {
564
+ // A user's selection whose box came out 13 m wide in 2D-only view.
565
+ const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] })
566
+ const room: [number, number][] = [
567
+ [-3.6221682640435353, -9.037533979512114],
568
+ [-6.21504662384804, -8.29671159099654],
569
+ [-5.712152486161543, -6.5365821090938],
570
+ [-5.712152486161543, -5],
571
+ [-8.223564365687542, -5],
572
+ [-8.223564365687542, -6.5365821090938],
573
+ [-10.712152486161543, -6.5365821090938],
574
+ [-11.712152486161543, -8.5365821090938],
575
+ [-4.119274126357038, -10.777404497609373],
576
+ ]
577
+ const wall15 = WallNode.parse({
578
+ parentId: level.id,
579
+ start: [-10.712152486161543, -6.5365821090938],
580
+ end: [-11.712152486161543, -8.5365821090938],
581
+ })
582
+ const wall12 = WallNode.parse({
583
+ parentId: level.id,
584
+ start: [-11.712152486161543, -8.5365821090938],
585
+ end: [-6.712152486161543, -10.0365821090938],
586
+ })
587
+ const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 })
588
+ const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 })
589
+ withNodes([level, wall15, wall12, slab, ceiling], () => {
590
+ const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)!
591
+ expect(plan.minX).toBeLessThan(-11.71)
592
+ expect(plan.minX).toBeGreaterThan(-11.95)
593
+ expect(plan.maxX).toBeCloseTo(-3.62, 1)
594
+ expect(plan.minZ).toBeCloseTo(-10.78, 1)
595
+ expect(plan.maxZ).toBeCloseTo(-5, 1)
596
+ expect(plan.maxX - plan.minX).toBeLessThan(8.4)
597
+ })
598
+ })
599
+ })