@pascal-app/editor 0.8.0 → 0.9.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.
- package/package.json +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
4
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import { useFrame } from '@react-three/fiber'
|
|
6
6
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
-
import { MathUtils, type Mesh, Vector2 } from 'three'
|
|
7
|
+
import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three'
|
|
8
8
|
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
|
9
9
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
10
|
+
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
|
10
11
|
import { useGridEvents } from '../../hooks/use-grid-events'
|
|
11
|
-
import { EDITOR_LAYER } from '../../lib/constants'
|
|
12
12
|
|
|
13
13
|
export const Grid = ({
|
|
14
14
|
cellSize = 0.5,
|
|
@@ -31,11 +31,11 @@ export const Grid = ({
|
|
|
31
31
|
fadeStrength?: number
|
|
32
32
|
revealRadius?: number
|
|
33
33
|
}) => {
|
|
34
|
-
const
|
|
34
|
+
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
35
35
|
|
|
36
|
-
// Use slightly lighter colors for dark
|
|
37
|
-
const effectiveCellColor =
|
|
38
|
-
const effectiveSectionColor =
|
|
36
|
+
// Use slightly lighter colors for dark themes' grid to make it apparent
|
|
37
|
+
const effectiveCellColor = isDark ? '#555566' : cellColor
|
|
38
|
+
const effectiveSectionColor = isDark ? '#666677' : sectionColor
|
|
39
39
|
|
|
40
40
|
const cursorPositionRef = useRef(new Vector2(0, 0))
|
|
41
41
|
|
|
@@ -118,11 +118,23 @@ export const Grid = ({
|
|
|
118
118
|
|
|
119
119
|
// Use custom raycasting for grid events (independent of mesh events)
|
|
120
120
|
useGridEvents(gridY)
|
|
121
|
-
|
|
122
|
-
//
|
|
121
|
+
// Same technique for ceiling-item placement: a math-plane raycast per ceiling,
|
|
122
|
+
// so commits don't depend on hitting the thin, single-sided `ceiling-grid`
|
|
123
|
+
// overlay mesh (which dropped clicks even with the green box showing).
|
|
124
|
+
useCeilingEvents()
|
|
125
|
+
|
|
126
|
+
// Track the last world-space cursor hit. The reveal-fade shader reads
|
|
127
|
+
// `positionLocal.xy` (vertex position on the un-transformed plane), and
|
|
128
|
+
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
|
|
129
|
+
// relative to the mesh origin. The mesh origin itself is lerped each
|
|
130
|
+
// frame toward the active building's world XZ (see `useFrame` below),
|
|
131
|
+
// so the local-frame cursor must be recomputed every frame from the
|
|
132
|
+
// stored world cursor — otherwise the ring drifts whenever the grid is
|
|
133
|
+
// mid-lerp (e.g. just after a building rotation commits).
|
|
134
|
+
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
|
|
123
135
|
useEffect(() => {
|
|
124
136
|
const onGridMove = (event: GridEvent) => {
|
|
125
|
-
|
|
137
|
+
lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] }
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
emitter.on('grid:move', onGridMove)
|
|
@@ -132,10 +144,13 @@ export const Grid = ({
|
|
|
132
144
|
}, [])
|
|
133
145
|
|
|
134
146
|
useFrame((_, delta) => {
|
|
135
|
-
const
|
|
147
|
+
const { levelId } = useViewer.getState().selection
|
|
148
|
+
// Grid stays anchored to world XZ (0, 0) — never chases the active
|
|
149
|
+
// building. The Y origin still lerps to the active level so the grid
|
|
150
|
+
// sits at floor height when a level is open.
|
|
136
151
|
let targetY = 0
|
|
137
|
-
if (
|
|
138
|
-
const levelMesh = sceneRegistry.nodes.get(
|
|
152
|
+
if (levelId) {
|
|
153
|
+
const levelMesh = sceneRegistry.nodes.get(levelId)
|
|
139
154
|
if (levelMesh) {
|
|
140
155
|
targetY = levelMesh.position.y
|
|
141
156
|
}
|
|
@@ -143,19 +158,38 @@ export const Grid = ({
|
|
|
143
158
|
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
|
|
144
159
|
gridRef.current.position.y = newY
|
|
145
160
|
setGridY(newY)
|
|
161
|
+
|
|
162
|
+
// Grid XZ is fixed at world origin, so the local-frame cursor uniform
|
|
163
|
+
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation
|
|
164
|
+
// of the plane).
|
|
165
|
+
const world = lastWorldCursorRef.current
|
|
166
|
+
if (world) {
|
|
167
|
+
cursorPositionRef.current.set(world.x, -world.z)
|
|
168
|
+
}
|
|
146
169
|
})
|
|
147
170
|
|
|
148
171
|
const showGrid = useViewer((state) => state.showGrid)
|
|
149
172
|
|
|
173
|
+
// Pass the geometry as a prop instead of a JSX child so the mesh
|
|
174
|
+
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
|
|
175
|
+
// Combined with the grid's `MeshBasicNodeMaterial`, the child-attach
|
|
176
|
+
// path can submit a `Draw(0, 1, 0, 0)` on the first frame before
|
|
177
|
+
// `<planeGeometry>` attaches — which WebGPU flags as "Vertex buffer
|
|
178
|
+
// slot 0 ... was not set" (see `wall-move-side-handles.tsx`).
|
|
179
|
+
const geometry = useMemo(
|
|
180
|
+
() => new PlaneGeometry(fadeDistance * 2, fadeDistance * 2),
|
|
181
|
+
[fadeDistance],
|
|
182
|
+
)
|
|
183
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
184
|
+
|
|
150
185
|
return (
|
|
151
186
|
<mesh
|
|
152
|
-
|
|
187
|
+
geometry={geometry}
|
|
188
|
+
layers={GRID_LAYER}
|
|
153
189
|
material={material}
|
|
154
190
|
ref={gridRef}
|
|
155
191
|
rotation-x={-Math.PI / 2}
|
|
156
192
|
visible={showGrid}
|
|
157
|
-
|
|
158
|
-
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
|
159
|
-
</mesh>
|
|
193
|
+
/>
|
|
160
194
|
)
|
|
161
195
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
useLiveNodeOverrides,
|
|
7
|
+
useLiveTransforms,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
13
|
+
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|
14
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
15
|
+
import useEditor from '../../store/use-editor'
|
|
16
|
+
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
17
|
+
import {
|
|
18
|
+
CORNER_OFFSET,
|
|
19
|
+
classifyParticipant,
|
|
20
|
+
collectParticipants,
|
|
21
|
+
computeGroupBox,
|
|
22
|
+
expandToComponent,
|
|
23
|
+
levelFrame,
|
|
24
|
+
type Vec2,
|
|
25
|
+
} from './group-transform-shared'
|
|
26
|
+
import {
|
|
27
|
+
ARROW_COLOR,
|
|
28
|
+
ARROW_HOVER_COLOR,
|
|
29
|
+
ARROW_SCALE,
|
|
30
|
+
createMoveCrossHandleGeometry,
|
|
31
|
+
swallowNextClick,
|
|
32
|
+
useArrowMaterial,
|
|
33
|
+
} from './node-arrow-handles'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
|
|
37
|
+
* transformable nodes are selected, a single move cross appears at the
|
|
38
|
+
* selection's front-left bounding-box corner (the rotate gizmo sits on the
|
|
39
|
+
* right). Dragging it slides every selected node by the same ground-plane
|
|
40
|
+
* delta; connected (unselected) wall/fence endpoints follow so junctions stay
|
|
41
|
+
* welded. Commits the whole slide in one batched `updateNodes` (one undo).
|
|
42
|
+
*/
|
|
43
|
+
export function GroupMoveHandle() {
|
|
44
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
45
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
46
|
+
const mode = useEditor((s) => s.mode)
|
|
47
|
+
const movingNode = useEditor((s) => s.movingNode)
|
|
48
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
49
|
+
const nodes = useScene((s) => s.nodes)
|
|
50
|
+
|
|
51
|
+
const participantIds = useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
selectedIds.filter(
|
|
54
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
55
|
+
),
|
|
56
|
+
[selectedIds, levelId, nodes],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// Gate on the explicit selection, but move the full connected wall/fence
|
|
60
|
+
// component so attached structure slides rigidly as one piece.
|
|
61
|
+
const fullIds = useMemo(
|
|
62
|
+
() => expandToComponent(participantIds, nodes, levelId),
|
|
63
|
+
[participantIds, levelId, nodes],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const shouldRender =
|
|
67
|
+
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
|
|
68
|
+
|
|
69
|
+
if (!shouldRender) return null
|
|
70
|
+
return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
|
74
|
+
const { camera, raycaster, gl, scene } = useThree()
|
|
75
|
+
const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
|
76
|
+
const arrowMaterial = useArrowMaterial()
|
|
77
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
78
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
79
|
+
// Live ground-plane delta so the gizmo rides along with the group it moves.
|
|
80
|
+
const [liveDelta, setLiveDelta] = useState<Vec2>([0, 0])
|
|
81
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
82
|
+
const frozenCorner = useRef<Vector3 | null>(null)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
86
|
+
}, [arrowMaterial, isHovered])
|
|
87
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
88
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
89
|
+
useEffect(() => () => dragCleanupRef.current?.(), [])
|
|
90
|
+
|
|
91
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
92
|
+
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
|
93
|
+
|
|
94
|
+
// Front-left bbox corner at mid-height (mirrors the rotate gizmo on the
|
|
95
|
+
// right), plus the group's base Y for the ground drag plane.
|
|
96
|
+
const rest = useMemo(() => {
|
|
97
|
+
const box = computeGroupBox(ids)
|
|
98
|
+
if (!box) return null
|
|
99
|
+
const corner = new Vector3(
|
|
100
|
+
box.min.x - CORNER_OFFSET,
|
|
101
|
+
(box.min.y + box.max.y) / 2,
|
|
102
|
+
box.max.z + CORNER_OFFSET,
|
|
103
|
+
)
|
|
104
|
+
return { corner, baseY: box.min.y }
|
|
105
|
+
}, [ids])
|
|
106
|
+
|
|
107
|
+
if (!rest) return null
|
|
108
|
+
const baseCorner = isDragging && frozenCorner.current ? frozenCorner.current : rest.corner
|
|
109
|
+
const corner: [number, number, number] = [
|
|
110
|
+
baseCorner.x + liveDelta[0],
|
|
111
|
+
baseCorner.y,
|
|
112
|
+
baseCorner.z + liveDelta[1],
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
116
|
+
event.stopPropagation()
|
|
117
|
+
suppressBoxSelectForPointer(event)
|
|
118
|
+
frozenCorner.current = rest.corner.clone()
|
|
119
|
+
const planeY = rest.baseY
|
|
120
|
+
|
|
121
|
+
// Snapshot selected participants + connected wall/fence neighbours.
|
|
122
|
+
const levelId = useViewer.getState().selection.levelId
|
|
123
|
+
const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
|
124
|
+
if (starts.length === 0) return
|
|
125
|
+
|
|
126
|
+
// Placements are stored in the level frame, so convert each world-space
|
|
127
|
+
// ground-plane hit into that frame before measuring the delta. Frozen at
|
|
128
|
+
// drag-start; `frameOrigin` lets us map the local delta back to world for
|
|
129
|
+
// the gizmo's own travel (it's portalled to the scene root).
|
|
130
|
+
const { matrix: frame, inverse: frameInv } = levelFrame(levelId)
|
|
131
|
+
const frameOrigin = new Vector3().applyMatrix4(frame)
|
|
132
|
+
|
|
133
|
+
// Horizontal drag plane at the group's base.
|
|
134
|
+
const plane = new Plane(new Vector3(0, 1, 0), -planeY)
|
|
135
|
+
const ndc = new Vector2()
|
|
136
|
+
const setNDC = (clientX: number, clientY: number) => {
|
|
137
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
138
|
+
ndc.set(
|
|
139
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
140
|
+
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
|
145
|
+
raycaster.setFromCamera(ndc, camera)
|
|
146
|
+
const hit = new Vector3()
|
|
147
|
+
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
|
148
|
+
const startLocal = hit.clone().applyMatrix4(frameInv)
|
|
149
|
+
|
|
150
|
+
document.body.style.cursor = 'grabbing'
|
|
151
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
152
|
+
useViewer.getState().setInputDragging(true)
|
|
153
|
+
useScene.temporal.getState().pause()
|
|
154
|
+
setIsDragging(true)
|
|
155
|
+
|
|
156
|
+
// Snap the slide to the active grid step so the group lands on the grid
|
|
157
|
+
// (Shift bypasses for free movement). Snapping the delta keeps the
|
|
158
|
+
// selection's internal layout intact — grid-aligned items stay aligned.
|
|
159
|
+
const step = useEditor.getState().gridSnapStep
|
|
160
|
+
let lastSnap: Vec2 | null = null
|
|
161
|
+
|
|
162
|
+
const onMove = (e: PointerEvent) => {
|
|
163
|
+
setNDC(e.clientX, e.clientY)
|
|
164
|
+
raycaster.setFromCamera(ndc, camera)
|
|
165
|
+
const moveHit = new Vector3()
|
|
166
|
+
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
|
167
|
+
const moveLocal = moveHit.applyMatrix4(frameInv)
|
|
168
|
+
const snap = !e.shiftKey && step > 0
|
|
169
|
+
const dx = snap
|
|
170
|
+
? Math.round((moveLocal.x - startLocal.x) / step) * step
|
|
171
|
+
: moveLocal.x - startLocal.x
|
|
172
|
+
const dz = snap
|
|
173
|
+
? Math.round((moveLocal.z - startLocal.z) / step) * step
|
|
174
|
+
: moveLocal.z - startLocal.z
|
|
175
|
+
|
|
176
|
+
// Ticker on each grid-cell crossing, like single-item placement.
|
|
177
|
+
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
|
|
178
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
179
|
+
lastSnap = [dx, dz]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
|
|
183
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
184
|
+
for (const s of starts) {
|
|
185
|
+
if (s.kind === 'endpoint') {
|
|
186
|
+
overrideEntries.push([
|
|
187
|
+
s.id,
|
|
188
|
+
{
|
|
189
|
+
start: [s.start[0] + dx, s.start[1] + dz],
|
|
190
|
+
end: [s.end[0] + dx, s.end[1] + dz],
|
|
191
|
+
},
|
|
192
|
+
])
|
|
193
|
+
} else {
|
|
194
|
+
// Slide on the floor: XZ shift, Y and rotation untouched.
|
|
195
|
+
const position: [number, number, number] = [
|
|
196
|
+
s.position[0] + dx,
|
|
197
|
+
s.position[1],
|
|
198
|
+
s.position[2] + dz,
|
|
199
|
+
]
|
|
200
|
+
overrideEntries.push([s.id, { position }])
|
|
201
|
+
if (s.kind === 'scalar') {
|
|
202
|
+
liveTransforms.set(s.id, { position, rotation: s.rotation })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
useScene.getState().markDirty(s.id)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Shared endpoints of connected neighbours follow by the same delta so
|
|
209
|
+
// the junction stays welded; the far end stays put.
|
|
210
|
+
for (const l of links) {
|
|
211
|
+
overrideEntries.push([
|
|
212
|
+
l.id,
|
|
213
|
+
{
|
|
214
|
+
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
|
|
215
|
+
end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
|
|
216
|
+
},
|
|
217
|
+
])
|
|
218
|
+
useScene.getState().markDirty(l.id)
|
|
219
|
+
}
|
|
220
|
+
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
|
221
|
+
|
|
222
|
+
// Gizmo rides the group in world space; map the level-frame delta back out.
|
|
223
|
+
const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin)
|
|
224
|
+
setLiveDelta([worldDelta.x, worldDelta.z])
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
228
|
+
const clearLivePreviews = () => {
|
|
229
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
230
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
231
|
+
for (const id of affectedIds) {
|
|
232
|
+
overrides.clear(id)
|
|
233
|
+
liveTransforms.clear(id)
|
|
234
|
+
useScene.getState().markDirty(id)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const cleanup = () => {
|
|
239
|
+
window.removeEventListener('pointermove', onMove)
|
|
240
|
+
window.removeEventListener('pointerup', onUp)
|
|
241
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
242
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
243
|
+
useScene.temporal.getState().resume()
|
|
244
|
+
useViewer.getState().setInputDragging(false)
|
|
245
|
+
setIsDragging(false)
|
|
246
|
+
setLiveDelta([0, 0])
|
|
247
|
+
frozenCorner.current = null
|
|
248
|
+
dragCleanupRef.current = null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const commitFromOverrides = () => {
|
|
252
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
253
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
254
|
+
for (const id of affectedIds) {
|
|
255
|
+
const patch = overrides.get(id)
|
|
256
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
257
|
+
}
|
|
258
|
+
return updates
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const onUp = () => {
|
|
262
|
+
// Eat the click that follows pointer-up so the selection manager doesn't
|
|
263
|
+
// treat it as a canvas click and clear the multi-selection.
|
|
264
|
+
swallowNextClick()
|
|
265
|
+
sfxEmitter.emit('sfx:item-place')
|
|
266
|
+
const updates = commitFromOverrides()
|
|
267
|
+
// Resume before the commit so the single batched `updateNodes` is the one
|
|
268
|
+
// tracked set — collapsing the whole group move into one undo.
|
|
269
|
+
useScene.temporal.getState().resume()
|
|
270
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
271
|
+
clearLivePreviews()
|
|
272
|
+
cleanup()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const onCancel = () => {
|
|
276
|
+
clearLivePreviews()
|
|
277
|
+
cleanup()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
dragCleanupRef.current = () => {
|
|
281
|
+
clearLivePreviews()
|
|
282
|
+
cleanup()
|
|
283
|
+
}
|
|
284
|
+
for (const id of affectedIds) {
|
|
285
|
+
useLiveTransforms.getState().clear(id)
|
|
286
|
+
}
|
|
287
|
+
window.addEventListener('pointermove', onMove)
|
|
288
|
+
window.addEventListener('pointerup', onUp)
|
|
289
|
+
window.addEventListener('pointercancel', onCancel)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return createPortal(
|
|
293
|
+
<group position={corner} scale={scale}>
|
|
294
|
+
<mesh
|
|
295
|
+
frustumCulled={false}
|
|
296
|
+
geometry={arrowGeometry}
|
|
297
|
+
material={arrowMaterial}
|
|
298
|
+
onPointerDown={activate}
|
|
299
|
+
onPointerEnter={(event) => {
|
|
300
|
+
event.stopPropagation()
|
|
301
|
+
setIsHovered(true)
|
|
302
|
+
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'move'
|
|
303
|
+
}}
|
|
304
|
+
onPointerLeave={(event) => {
|
|
305
|
+
event.stopPropagation()
|
|
306
|
+
setIsHovered(false)
|
|
307
|
+
if (document.body.style.cursor === 'move') document.body.style.cursor = ''
|
|
308
|
+
}}
|
|
309
|
+
renderOrder={1010}
|
|
310
|
+
/>
|
|
311
|
+
</group>,
|
|
312
|
+
scene,
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default GroupMoveHandle
|