@pascal-app/editor 1.0.1 → 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.
- package/package.json +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-interaction-scope.ts +155 -6
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
GRID_LAYER,
|
|
6
|
+
getSceneTheme,
|
|
7
|
+
useImmersiveXRPresentation,
|
|
8
|
+
useViewer,
|
|
9
|
+
} from '@pascal-app/viewer'
|
|
5
10
|
import { useFrame } from '@react-three/fiber'
|
|
6
11
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
7
12
|
import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
|
|
@@ -10,9 +15,12 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
|
10
15
|
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
|
11
16
|
import { useGridEvents } from '../../hooks/use-grid-events'
|
|
12
17
|
import { getPlacementSurface, usesOrientedPlacementPlane } from '../../lib/active-placement-surface'
|
|
13
|
-
import
|
|
18
|
+
import { gridLocalNormal, gridLocalPoint } from '../../lib/grid-frame'
|
|
19
|
+
import useEditor, { getActiveSnapContext, isGridSnapActive } from '../../store/use-editor'
|
|
14
20
|
import { getMovingNode } from '../../store/use-interaction-scope'
|
|
15
21
|
|
|
22
|
+
export const EDITOR_GRID_INPUT_NAME = 'pascal-editor-grid-input'
|
|
23
|
+
|
|
16
24
|
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
|
|
17
25
|
// grid-snap mode — much tighter than the idle reveal so only the area you're
|
|
18
26
|
// about to snap into lights up.
|
|
@@ -45,6 +53,7 @@ export const Grid = ({
|
|
|
45
53
|
fadeStrength?: number
|
|
46
54
|
revealRadius?: number
|
|
47
55
|
}) => {
|
|
56
|
+
const immersive = useImmersiveXRPresentation()
|
|
48
57
|
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
49
58
|
|
|
50
59
|
// Use slightly lighter colors for dark themes' grid to make it apparent
|
|
@@ -60,6 +69,9 @@ export const Grid = ({
|
|
|
60
69
|
const wallCursorRef = useRef(new Vector3())
|
|
61
70
|
// Scratch for deriving a moving wall-item's host normal from its mesh.
|
|
62
71
|
const wallNormalRef = useRef(new Vector3())
|
|
72
|
+
const localSurfaceRef = useRef(new Vector3())
|
|
73
|
+
const localNormalRef = useRef(new Vector3())
|
|
74
|
+
const localCursorRef = useRef(new Vector3())
|
|
63
75
|
// Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
|
|
64
76
|
// a React re-render when the height actually changes (not every frame).
|
|
65
77
|
const lastGridYRef = useRef<number | null>(null)
|
|
@@ -176,10 +188,11 @@ export const Grid = ({
|
|
|
176
188
|
// laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
|
|
177
189
|
// mesh origin. The cursor is recomputed every frame from the stored world hit
|
|
178
190
|
// so the reveal stays put regardless of where the mesh origin sits.
|
|
179
|
-
const lastWorldCursorRef = useRef<
|
|
191
|
+
const lastWorldCursorRef = useRef<Vector3 | null>(null)
|
|
180
192
|
useEffect(() => {
|
|
181
193
|
const onGridMove = (event: GridEvent) => {
|
|
182
|
-
lastWorldCursorRef.current
|
|
194
|
+
lastWorldCursorRef.current ??= new Vector3()
|
|
195
|
+
lastWorldCursorRef.current.set(...event.position)
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
emitter.on('grid:move', onGridMove)
|
|
@@ -194,7 +207,12 @@ export const Grid = ({
|
|
|
194
207
|
if (levelId) {
|
|
195
208
|
const levelMesh = sceneRegistry.nodes.get(levelId)
|
|
196
209
|
if (levelMesh) {
|
|
197
|
-
|
|
210
|
+
levelMesh.getWorldPosition(worldPosRef.current)
|
|
211
|
+
levelY = gridLocalPoint(
|
|
212
|
+
gridRef.current.parent,
|
|
213
|
+
worldPosRef.current,
|
|
214
|
+
localSurfaceRef.current,
|
|
215
|
+
).y
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
218
|
|
|
@@ -229,6 +247,10 @@ export const Grid = ({
|
|
|
229
247
|
}
|
|
230
248
|
|
|
231
249
|
const gridMesh = gridRef.current
|
|
250
|
+
if (surfacePoint) {
|
|
251
|
+
surfacePoint = gridLocalPoint(gridMesh.parent, surfacePoint, localSurfaceRef.current)
|
|
252
|
+
surfaceNormal = gridLocalNormal(gridMesh.parent, surfaceNormal, localNormalRef.current)
|
|
253
|
+
}
|
|
232
254
|
const onOrientedPlane = surfacePoint != null && usesOrientedPlacementPlane(surfaceNormal)
|
|
233
255
|
if (onOrientedPlane && surfacePoint) {
|
|
234
256
|
// Surface-anchored lattice: orient the plane into the host and pin the mesh to
|
|
@@ -269,7 +291,8 @@ export const Grid = ({
|
|
|
269
291
|
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
|
|
270
292
|
const world = lastWorldCursorRef.current
|
|
271
293
|
if (world) {
|
|
272
|
-
|
|
294
|
+
const local = gridLocalPoint(gridMesh.parent, world, localCursorRef.current)
|
|
295
|
+
cursorPositionRef.current.set(local.x, -local.z)
|
|
273
296
|
}
|
|
274
297
|
if (lastGridYRef.current !== targetY) {
|
|
275
298
|
lastGridYRef.current = targetY
|
|
@@ -293,7 +316,7 @@ export const Grid = ({
|
|
|
293
316
|
// from the interaction scope OR the armed build tool and is true only when
|
|
294
317
|
// that context resolves to grid, so it IS the gate. (Previously this also
|
|
295
318
|
// required a ghost in flight, so a merely-armed draft tool showed nothing.)
|
|
296
|
-
const snapPatchVisible = isGridSnapActive()
|
|
319
|
+
const snapPatchVisible = isGridSnapActive() || (immersive && getActiveSnapContext() !== null)
|
|
297
320
|
revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
|
|
298
321
|
baseAlphaUniform.value = 0
|
|
299
322
|
cellSizeUniform.value = useEditor.getState().gridSnapStep
|
|
@@ -320,6 +343,7 @@ export const Grid = ({
|
|
|
320
343
|
geometry={geometry}
|
|
321
344
|
layers={GRID_LAYER}
|
|
322
345
|
material={material}
|
|
346
|
+
name={EDITOR_GRID_INPUT_NAME}
|
|
323
347
|
ref={gridRef}
|
|
324
348
|
renderOrder={1}
|
|
325
349
|
/>
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
classifyParticipant,
|
|
40
40
|
collectParticipants,
|
|
41
41
|
computeGroupBox,
|
|
42
|
-
expandToComponent,
|
|
43
42
|
groupPlanBounds,
|
|
44
43
|
levelFrame,
|
|
45
44
|
planBoundsCenter,
|
|
@@ -83,8 +82,8 @@ export function canGroupPickUp(): boolean {
|
|
|
83
82
|
* until a click commits, mirroring the single-node `movingNode` flow. Returns
|
|
84
83
|
* false when the selection holds no transformable participants.
|
|
85
84
|
*
|
|
86
|
-
* `scopeToSelection`
|
|
87
|
-
*
|
|
85
|
+
* `scopeToSelection` drops the welded-neighbor endpoints, so connected walls
|
|
86
|
+
* don't stretch along with the move. The
|
|
88
87
|
* Duplicate flow needs this: its clones sit EXACTLY on the originals, so
|
|
89
88
|
* junction coincidence would otherwise weld the originals into the pick-up
|
|
90
89
|
* and drag them along with the copies.
|
|
@@ -96,10 +95,7 @@ export function startGroupPickUp(
|
|
|
96
95
|
const participantIds = groupParticipantIds()
|
|
97
96
|
if (participantIds.length === 0) return false
|
|
98
97
|
const nodes = useScene.getState().nodes
|
|
99
|
-
const
|
|
100
|
-
? participantIds
|
|
101
|
-
: expandToComponent(participantIds, nodes, levelId)
|
|
102
|
-
const collected = collectParticipants(fullIds, nodes, levelId)
|
|
98
|
+
const collected = collectParticipants(participantIds, nodes, levelId)
|
|
103
99
|
// Mutable: mid-carry R/T rotates these snapshots in place.
|
|
104
100
|
let starts = collected.starts
|
|
105
101
|
let links = opts.scopeToSelection ? [] : collected.links
|
|
@@ -107,8 +103,8 @@ export function startGroupPickUp(
|
|
|
107
103
|
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
108
104
|
|
|
109
105
|
const { inverse: frameInv } = levelFrame(levelId)
|
|
110
|
-
const restBox = computeGroupBox(
|
|
111
|
-
const startBounds = groupPlanBounds(
|
|
106
|
+
const restBox = computeGroupBox(participantIds)
|
|
107
|
+
const startBounds = groupPlanBounds(starts, frameInv)
|
|
112
108
|
if (!startBounds) return false
|
|
113
109
|
// Mutable: mid-carry R/T re-seeds the footprint around the same pivot.
|
|
114
110
|
let restBounds = startBounds
|
|
@@ -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 {
|
|
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
|
|
73
|
-
const box = computeGroupBox(fullIds)
|
|
77
|
+
const box = computeGroupBox(participantIds)
|
|
74
78
|
if (!box) return null
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
111
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// - `
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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: [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
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
|