@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,46 +1,62 @@
|
|
|
1
|
-
import type { AssetInput } from '@pascal-app/core'
|
|
1
|
+
import type { AssetInput, ItemNode } from '@pascal-app/core'
|
|
2
2
|
import {
|
|
3
|
+
type AlignmentAnchor,
|
|
4
|
+
type AnyNode,
|
|
3
5
|
type AnyNodeId,
|
|
4
6
|
type CeilingEvent,
|
|
7
|
+
collectAlignmentAnchors,
|
|
5
8
|
emitter,
|
|
6
9
|
type GridEvent,
|
|
7
10
|
getScaledDimensions,
|
|
8
11
|
type ItemEvent,
|
|
12
|
+
movingFootprintAnchors,
|
|
9
13
|
resolveLevelId,
|
|
14
|
+
type ShelfEvent,
|
|
10
15
|
sceneRegistry,
|
|
11
|
-
spatialGridManager,
|
|
12
16
|
useLiveTransforms,
|
|
13
17
|
useScene,
|
|
14
18
|
useSpatialQuery,
|
|
15
19
|
type WallEvent,
|
|
16
20
|
type WallNode,
|
|
17
21
|
} from '@pascal-app/core'
|
|
22
|
+
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
18
23
|
import { useViewer } from '@pascal-app/viewer'
|
|
19
24
|
import { Html } from '@react-three/drei'
|
|
20
|
-
import { useFrame } from '@react-three/fiber'
|
|
21
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
25
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
26
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
22
27
|
import {
|
|
23
|
-
|
|
28
|
+
Box3,
|
|
24
29
|
Euler,
|
|
25
|
-
Float32BufferAttribute,
|
|
26
30
|
type Group,
|
|
27
31
|
type LineSegments,
|
|
32
|
+
Matrix4,
|
|
28
33
|
type Mesh,
|
|
34
|
+
type Object3D,
|
|
29
35
|
PlaneGeometry,
|
|
30
36
|
Quaternion,
|
|
37
|
+
Ray,
|
|
31
38
|
Vector3,
|
|
32
39
|
} from 'three'
|
|
33
40
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
34
41
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
35
42
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
36
43
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
44
|
+
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
|
37
45
|
import useEditor from '../../../store/use-editor'
|
|
46
|
+
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
47
|
+
import {
|
|
48
|
+
createLineGeometry,
|
|
49
|
+
getBoxEdgePoints,
|
|
50
|
+
type PreviewBounds,
|
|
51
|
+
updateLineGeometry,
|
|
52
|
+
} from '../shared/placement-box-geometry'
|
|
38
53
|
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
|
39
54
|
import {
|
|
40
55
|
ceilingStrategy,
|
|
41
56
|
checkCanPlace,
|
|
42
57
|
floorStrategy,
|
|
43
58
|
itemSurfaceStrategy,
|
|
59
|
+
shelfSurfaceStrategy,
|
|
44
60
|
wallStrategy,
|
|
45
61
|
} from './placement-strategies'
|
|
46
62
|
import type { PlacementState, TransitionResult } from './placement-types'
|
|
@@ -48,6 +64,10 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|
|
48
64
|
|
|
49
65
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
50
66
|
|
|
67
|
+
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
|
68
|
+
* floor-plan overlay and the 3D registry move tool. */
|
|
69
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
70
|
+
|
|
51
71
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
52
72
|
if (unit === 'imperial') {
|
|
53
73
|
const feet = value * 3.280_84
|
|
@@ -59,13 +79,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
|
59
79
|
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
type PreviewBounds = {
|
|
63
|
-
min: [number, number, number]
|
|
64
|
-
max: [number, number, number]
|
|
65
|
-
dimensions: [number, number, number]
|
|
66
|
-
center: [number, number, number]
|
|
67
|
-
}
|
|
68
|
-
|
|
69
82
|
/**
|
|
70
83
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
71
84
|
* The wireframe stays centered on the original bounds centre on each axis we
|
|
@@ -116,10 +129,10 @@ function expandBoundsToGrid(
|
|
|
116
129
|
|
|
117
130
|
function getFallbackPreviewBounds(
|
|
118
131
|
item: import('@pascal-app/core').ItemNode | null,
|
|
119
|
-
asset: AssetInput,
|
|
120
|
-
attachTo: AssetInput['attachTo'],
|
|
132
|
+
asset: AssetInput | null | undefined,
|
|
133
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
121
134
|
): PreviewBounds {
|
|
122
|
-
const dims = item ? getScaledDimensions(item) : (asset
|
|
135
|
+
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
123
136
|
return {
|
|
124
137
|
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
|
125
138
|
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
|
|
@@ -128,112 +141,20 @@ function getFallbackPreviewBounds(
|
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
|
|
131
|
-
function
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
function getBoxEdgePoints(bounds: PreviewBounds): number[] {
|
|
138
|
-
const [width, height, depth] = bounds.dimensions
|
|
139
|
-
const [centerX, centerY, centerZ] = bounds.center
|
|
140
|
-
const minX = centerX - width / 2
|
|
141
|
-
const maxX = centerX + width / 2
|
|
142
|
-
const minY = centerY - height / 2
|
|
143
|
-
const maxY = centerY + height / 2
|
|
144
|
-
const minZ = centerZ - depth / 2
|
|
145
|
-
const maxZ = centerZ + depth / 2
|
|
146
|
-
|
|
147
|
-
return [
|
|
148
|
-
minX,
|
|
149
|
-
minY,
|
|
150
|
-
minZ,
|
|
151
|
-
maxX,
|
|
152
|
-
minY,
|
|
153
|
-
minZ,
|
|
154
|
-
maxX,
|
|
155
|
-
minY,
|
|
156
|
-
minZ,
|
|
157
|
-
maxX,
|
|
158
|
-
minY,
|
|
159
|
-
maxZ,
|
|
160
|
-
maxX,
|
|
161
|
-
minY,
|
|
162
|
-
maxZ,
|
|
163
|
-
minX,
|
|
164
|
-
minY,
|
|
165
|
-
maxZ,
|
|
166
|
-
minX,
|
|
167
|
-
minY,
|
|
168
|
-
maxZ,
|
|
169
|
-
minX,
|
|
170
|
-
minY,
|
|
171
|
-
minZ,
|
|
172
|
-
|
|
173
|
-
minX,
|
|
174
|
-
maxY,
|
|
175
|
-
minZ,
|
|
176
|
-
maxX,
|
|
177
|
-
maxY,
|
|
178
|
-
minZ,
|
|
179
|
-
maxX,
|
|
180
|
-
maxY,
|
|
181
|
-
minZ,
|
|
182
|
-
maxX,
|
|
183
|
-
maxY,
|
|
184
|
-
maxZ,
|
|
185
|
-
maxX,
|
|
186
|
-
maxY,
|
|
187
|
-
maxZ,
|
|
188
|
-
minX,
|
|
189
|
-
maxY,
|
|
190
|
-
maxZ,
|
|
191
|
-
minX,
|
|
192
|
-
maxY,
|
|
193
|
-
maxZ,
|
|
194
|
-
minX,
|
|
195
|
-
maxY,
|
|
196
|
-
minZ,
|
|
197
|
-
|
|
198
|
-
minX,
|
|
199
|
-
minY,
|
|
200
|
-
minZ,
|
|
201
|
-
minX,
|
|
202
|
-
maxY,
|
|
203
|
-
minZ,
|
|
204
|
-
maxX,
|
|
205
|
-
minY,
|
|
206
|
-
minZ,
|
|
207
|
-
maxX,
|
|
208
|
-
maxY,
|
|
209
|
-
minZ,
|
|
210
|
-
maxX,
|
|
211
|
-
minY,
|
|
212
|
-
maxZ,
|
|
213
|
-
maxX,
|
|
214
|
-
maxY,
|
|
215
|
-
maxZ,
|
|
216
|
-
minX,
|
|
217
|
-
minY,
|
|
218
|
-
maxZ,
|
|
219
|
-
minX,
|
|
220
|
-
maxY,
|
|
221
|
-
maxZ,
|
|
222
|
-
]
|
|
223
|
-
}
|
|
144
|
+
function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
|
|
145
|
+
const scaled = getScaledDimensions(item)
|
|
146
|
+
const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo)
|
|
147
|
+
if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) {
|
|
148
|
+
return item
|
|
149
|
+
}
|
|
224
150
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (!geometry) return
|
|
151
|
+
const scaleAxis = (axis: 0 | 1 | 2) =>
|
|
152
|
+
scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
|
|
228
153
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
} else {
|
|
233
|
-
attribute.set(points)
|
|
234
|
-
attribute.needsUpdate = true
|
|
154
|
+
return {
|
|
155
|
+
...item,
|
|
156
|
+
scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
|
|
235
157
|
}
|
|
236
|
-
geometry.computeBoundingSphere()
|
|
237
158
|
}
|
|
238
159
|
|
|
239
160
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
@@ -274,6 +195,8 @@ export interface PlacementCoordinatorConfig {
|
|
|
274
195
|
initialState?: PlacementState
|
|
275
196
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
276
197
|
defaultScale?: [number, number, number]
|
|
198
|
+
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
|
|
199
|
+
preserveFloorDragOffset?: boolean
|
|
277
200
|
}
|
|
278
201
|
|
|
279
202
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
@@ -285,13 +208,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
285
208
|
const basePlaneRef = useRef<Mesh>(null!)
|
|
286
209
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
287
210
|
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
|
211
|
+
const lastWallDirtyAtRef = useRef(new Map<string, number>())
|
|
288
212
|
const placementState = useRef<PlacementState>(
|
|
289
|
-
config.initialState ?? {
|
|
213
|
+
config.initialState ?? {
|
|
214
|
+
surface: 'floor',
|
|
215
|
+
wallId: null,
|
|
216
|
+
ceilingId: null,
|
|
217
|
+
surfaceItemId: null,
|
|
218
|
+
shelfId: null,
|
|
219
|
+
},
|
|
290
220
|
)
|
|
291
221
|
const shiftFreeRef = useRef(false)
|
|
292
222
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
223
|
+
// Goes true the first time a 3D pointer event drives this coordinator.
|
|
224
|
+
// The per-frame mesh-position lerp below is only useful for that path;
|
|
225
|
+
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
|
|
226
|
+
// writing scene.position directly), the lerp fights React's render and
|
|
227
|
+
// pulls the rendered item back toward its pre-move position. Gating
|
|
228
|
+
// the lerp on this flag keeps 3D placement smooth without hijacking
|
|
229
|
+
// 2D drags that share the same draft.
|
|
230
|
+
const has3DPointerDrivenMoveRef = useRef(false)
|
|
231
|
+
// The draft mesh's raycast is disabled while placing so the cursor ray
|
|
232
|
+
// passes through it to the surface beneath (grid / item / shelf). Without
|
|
233
|
+
// this, the ray hits the moving draft first and the surface strategy keeps
|
|
234
|
+
// re-deriving the host point from the draft's own (just-moved) geometry —
|
|
235
|
+
// on a multi-row shelf this oscillates the chosen row, jittering the item.
|
|
236
|
+
// Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
|
|
237
|
+
// recreated mid-session) and restored on unmount.
|
|
238
|
+
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
|
|
239
|
+
const restoreRaycastsRef = useRef<Array<() => void>>([])
|
|
240
|
+
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
|
|
293
241
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
294
242
|
|
|
243
|
+
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
|
|
244
|
+
// ray (camera → grid hit) to check it still points at the shelf volume.
|
|
245
|
+
const camera = useThree((s) => s.camera)
|
|
246
|
+
const cameraRef = useRef(camera)
|
|
247
|
+
cameraRef.current = camera
|
|
248
|
+
|
|
295
249
|
// Store config callbacks in refs to avoid re-running effect when they change
|
|
296
250
|
const configRef = useRef(config)
|
|
297
251
|
configRef.current = config
|
|
@@ -300,7 +254,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
300
254
|
const { asset, draftNode } = config
|
|
301
255
|
const unit = useViewer((state) => state.unit)
|
|
302
256
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
|
303
|
-
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
|
257
|
+
const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
|
|
304
258
|
const [width, height, depth] = bounds.dimensions
|
|
305
259
|
const [centerX, centerY, centerZ] = bounds.center
|
|
306
260
|
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
|
@@ -317,9 +271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
317
271
|
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
|
318
272
|
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
|
319
273
|
oldBasePlaneGeometry.dispose()
|
|
320
|
-
}
|
|
274
|
+
}, [])
|
|
321
275
|
|
|
322
|
-
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
|
276
|
+
const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
|
|
323
277
|
setDimensionBounds((current) => {
|
|
324
278
|
if (
|
|
325
279
|
current &&
|
|
@@ -421,7 +375,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
421
375
|
applyPoints(measurementWidthRef, widthPoints)
|
|
422
376
|
applyPoints(measurementDepthRef, depthPoints)
|
|
423
377
|
applyPoints(measurementHeightRef, heightPoints)
|
|
424
|
-
}
|
|
378
|
+
}, [])
|
|
379
|
+
|
|
380
|
+
const getFloorVisualPosition = useCallback(
|
|
381
|
+
(
|
|
382
|
+
position: [number, number, number],
|
|
383
|
+
nodeUpdate?: Partial<ItemNode>,
|
|
384
|
+
): [number, number, number] => {
|
|
385
|
+
const draft = draftNode.current
|
|
386
|
+
if (!(draft && !asset?.attachTo)) return position
|
|
387
|
+
const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
|
|
388
|
+
return getFloorStackPreviewPosition({
|
|
389
|
+
node: previewNode,
|
|
390
|
+
position,
|
|
391
|
+
rotation: previewNode.rotation,
|
|
392
|
+
})
|
|
393
|
+
},
|
|
394
|
+
[asset?.attachTo, draftNode],
|
|
395
|
+
)
|
|
425
396
|
|
|
426
397
|
useEffect(() => {
|
|
427
398
|
if (!asset) return
|
|
@@ -429,16 +400,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
429
400
|
|
|
430
401
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
431
402
|
|
|
403
|
+
// Lazily-gathered alignment candidates — the corner anchors of every
|
|
404
|
+
// OTHER floor-placed node, excluding the draft. Computed on the first
|
|
405
|
+
// floor move (once the draft id exists) and reused for the rest of the
|
|
406
|
+
// drag; the scene graph is stable during placement. Coords are
|
|
407
|
+
// building-local, matching the draft's grid position and the guide
|
|
408
|
+
// layer's frame.
|
|
409
|
+
let alignmentCandidates: AlignmentAnchor[] | null = null
|
|
410
|
+
let floorDragAnchor: [number, number] | null = null
|
|
411
|
+
|
|
432
412
|
// Reset placement state
|
|
433
413
|
placementState.current = configRef.current.initialState ?? {
|
|
434
414
|
surface: 'floor',
|
|
435
415
|
wallId: null,
|
|
436
416
|
ceilingId: null,
|
|
437
417
|
surfaceItemId: null,
|
|
418
|
+
shelfId: null,
|
|
438
419
|
}
|
|
439
420
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
440
421
|
gridPosition.current.y = 0
|
|
441
|
-
cursorGroupRef.current
|
|
422
|
+
if (cursorGroupRef.current) {
|
|
423
|
+
cursorGroupRef.current.position.y = 0
|
|
424
|
+
}
|
|
442
425
|
}
|
|
443
426
|
|
|
444
427
|
// ---- Helpers ----
|
|
@@ -449,7 +432,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
449
432
|
draftItem: draftNode.current,
|
|
450
433
|
gridPosition: gridPosition.current,
|
|
451
434
|
state: { ...placementState.current },
|
|
452
|
-
currentCursorRotationY:
|
|
435
|
+
currentCursorRotationY:
|
|
436
|
+
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
|
453
437
|
})
|
|
454
438
|
|
|
455
439
|
const getActiveValidators = () =>
|
|
@@ -479,12 +463,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
479
463
|
}
|
|
480
464
|
|
|
481
465
|
const applyTransition = (result: TransitionResult) => {
|
|
466
|
+
// Alignment guides are floor-only; clear them when the cursor moves
|
|
467
|
+
// onto a wall / ceiling / item surface (only those paths call this).
|
|
468
|
+
useAlignmentGuides.getState().clear()
|
|
482
469
|
Object.assign(placementState.current, result.stateUpdate)
|
|
483
470
|
gridPosition.current.set(...result.gridPosition)
|
|
484
471
|
|
|
485
472
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
486
|
-
cursorGroupRef.current
|
|
487
|
-
|
|
473
|
+
if (cursorGroupRef.current) {
|
|
474
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
475
|
+
if (result.cursorRotation) {
|
|
476
|
+
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
|
477
|
+
} else {
|
|
478
|
+
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
488
481
|
|
|
489
482
|
const draft = draftNode.current
|
|
490
483
|
if (draft) {
|
|
@@ -497,15 +490,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
497
490
|
const ensureDraft = (result: TransitionResult) => {
|
|
498
491
|
gridPosition.current.set(...result.gridPosition)
|
|
499
492
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
500
|
-
cursorGroupRef.current
|
|
501
|
-
|
|
493
|
+
if (cursorGroupRef.current) {
|
|
494
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
495
|
+
if (result.cursorRotation) {
|
|
496
|
+
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
|
497
|
+
} else {
|
|
498
|
+
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
502
501
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
502
|
+
const initRotation: [number, number, number] = result.cursorRotation ?? [
|
|
503
|
+
0,
|
|
504
|
+
result.cursorRotationY,
|
|
505
|
+
0,
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
|
|
509
509
|
|
|
510
510
|
const draft = draftNode.current
|
|
511
511
|
if (draft) {
|
|
@@ -514,6 +514,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
514
514
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
const previewBounds = expandBoundsToGrid(
|
|
518
|
+
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
519
|
+
asset.attachTo,
|
|
520
|
+
gridSnapStep,
|
|
521
|
+
)
|
|
522
|
+
updatePreviewGeometry(previewBounds)
|
|
523
|
+
updateDimensionGuides(previewBounds)
|
|
524
|
+
|
|
517
525
|
if (!revalidate()) {
|
|
518
526
|
draftNode.destroy()
|
|
519
527
|
}
|
|
@@ -521,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
521
529
|
|
|
522
530
|
// ---- Init draft ----
|
|
523
531
|
configRef.current.initDraft(gridPosition.current)
|
|
532
|
+
const preserveFloorDragOffset =
|
|
533
|
+
configRef.current.preserveFloorDragOffset === true &&
|
|
534
|
+
placementState.current.surface === 'floor' &&
|
|
535
|
+
!asset.attachTo
|
|
536
|
+
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
|
|
524
537
|
|
|
525
538
|
// Sync cursor to the draft mesh's world position and rotation
|
|
526
539
|
if (draftNode.current) {
|
|
@@ -529,12 +542,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
529
542
|
const worldPos = new Vector3()
|
|
530
543
|
mesh.getWorldPosition(worldPos)
|
|
531
544
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
532
|
-
cursorGroupRef.current
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
545
|
+
if (cursorGroupRef.current) {
|
|
546
|
+
cursorGroupRef.current.position.copy(localPos)
|
|
547
|
+
if (draftNode.current.asset.attachTo) {
|
|
548
|
+
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
|
|
549
|
+
const q = new Quaternion()
|
|
550
|
+
mesh.getWorldQuaternion(q)
|
|
551
|
+
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
|
552
|
+
} else {
|
|
553
|
+
// Floor items: the cursor group lives in building-local space, so use the
|
|
554
|
+
// node's local Y rotation — the same value onGridMove applies. The world
|
|
555
|
+
// quaternion would double-count any building rotation, leaving the initial
|
|
556
|
+
// box mis-rotated until the first cursor move.
|
|
557
|
+
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} else if (cursorGroupRef.current) {
|
|
538
561
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
|
539
562
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
540
563
|
}
|
|
@@ -542,44 +565,190 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
542
565
|
|
|
543
566
|
revalidate()
|
|
544
567
|
|
|
568
|
+
// ---- Press-drag commit-on-release ----
|
|
569
|
+
// When the move was engaged by the press-drag move cross (vs. click-to-
|
|
570
|
+
// place), commit on pointer-up instead of waiting for a click. Each surface
|
|
571
|
+
// move handler records how to commit at the current cursor; the release
|
|
572
|
+
// replays it. Captured once at setup — a fresh coordinator mounts per move.
|
|
573
|
+
const dragMode = useEditor.getState().placementDragMode
|
|
574
|
+
let releaseCommit: (() => void) | null = null
|
|
575
|
+
// Eat the click the browser fires after pointer-up so the surface
|
|
576
|
+
// `:click` handlers don't commit a second time.
|
|
577
|
+
const swallowNextClick = () => {
|
|
578
|
+
const swallow = (e: Event) => {
|
|
579
|
+
e.stopPropagation()
|
|
580
|
+
e.preventDefault()
|
|
581
|
+
}
|
|
582
|
+
window.addEventListener('click', swallow, { capture: true, once: true })
|
|
583
|
+
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
|
|
584
|
+
}
|
|
585
|
+
const onReleaseCommit = () => {
|
|
586
|
+
if (!releaseCommit) return
|
|
587
|
+
const commit = releaseCommit
|
|
588
|
+
releaseCommit = null
|
|
589
|
+
swallowNextClick()
|
|
590
|
+
commit()
|
|
591
|
+
}
|
|
592
|
+
|
|
545
593
|
// ---- Floor Handlers ----
|
|
546
594
|
|
|
547
595
|
let previousGridPos: [number, number, number] | null = null
|
|
548
596
|
|
|
597
|
+
// Scratch objects reused by the stickiness test (runs per grid:move).
|
|
598
|
+
const stickyRay = new Ray()
|
|
599
|
+
const stickyBox = new Box3()
|
|
600
|
+
const stickyMat = new Matrix4()
|
|
601
|
+
const stickyCamPos = new Vector3()
|
|
602
|
+
|
|
603
|
+
// True while the cursor ray still points at the active shelf's volume.
|
|
604
|
+
// Used to keep an item hosted on a shelf "sticky": from an angled camera
|
|
605
|
+
// the cursor ray slips off the shelf's thin boards / through its gaps and
|
|
606
|
+
// lands on the floor *behind* the shelf, which would otherwise thrash the
|
|
607
|
+
// placement between the shelf row and the floor on every micro-move. We
|
|
608
|
+
// reconstruct the world ray (camera → grid hit point) and test it against
|
|
609
|
+
// the shelf's bounding box — so a ray that passes *through* the shelf but
|
|
610
|
+
// lands behind it still counts as "on the shelf". Only a ray that misses
|
|
611
|
+
// the shelf box entirely means the user genuinely moved off it. A simple
|
|
612
|
+
// footprint test on the floor hit point can't distinguish those.
|
|
613
|
+
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
|
|
614
|
+
const shelfId = placementState.current.shelfId
|
|
615
|
+
if (!shelfId) return false
|
|
616
|
+
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
|
|
617
|
+
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
|
|
618
|
+
| { width?: number; depth?: number; height?: number }
|
|
619
|
+
| undefined
|
|
620
|
+
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
|
|
621
|
+
|
|
622
|
+
cameraRef.current.getWorldPosition(stickyCamPos)
|
|
623
|
+
stickyRay.origin.copy(stickyCamPos)
|
|
624
|
+
stickyRay.direction
|
|
625
|
+
.set(
|
|
626
|
+
gridWorldPoint[0] - stickyCamPos.x,
|
|
627
|
+
gridWorldPoint[1] - stickyCamPos.y,
|
|
628
|
+
gridWorldPoint[2] - stickyCamPos.z,
|
|
629
|
+
)
|
|
630
|
+
.normalize()
|
|
631
|
+
|
|
632
|
+
// Into shelf-local space, then test the shelf's local AABB (origin at the
|
|
633
|
+
// base: y ∈ [0, height]) with a small margin.
|
|
634
|
+
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
|
|
635
|
+
const m = 0.08
|
|
636
|
+
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
|
|
637
|
+
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
|
|
638
|
+
return stickyRay.intersectsBox(stickyBox)
|
|
639
|
+
}
|
|
640
|
+
|
|
549
641
|
const onGridMove = (event: GridEvent) => {
|
|
642
|
+
releaseCommit = () => onGridClick(event)
|
|
550
643
|
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
551
644
|
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
552
645
|
configRef.current.initDraft(gridPosition.current)
|
|
553
646
|
}
|
|
554
647
|
|
|
555
|
-
|
|
556
|
-
|
|
648
|
+
has3DPointerDrivenMoveRef.current = true
|
|
649
|
+
|
|
650
|
+
// Shelf stickiness: while hosting on a shelf, ignore floor events while
|
|
651
|
+
// the cursor ray still points at the shelf volume (the ray merely slipped
|
|
652
|
+
// off a board / through a gap and hit the floor behind). Detach to the
|
|
653
|
+
// floor only once the ray misses the shelf entirely — without this the
|
|
654
|
+
// item oscillates between the shelf row and the floor on every micro-move.
|
|
655
|
+
if (placementState.current.surface === 'shelf-surface') {
|
|
656
|
+
if (cursorRayIntersectsActiveShelf(event.position)) return
|
|
657
|
+
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const floorEvent =
|
|
661
|
+
relativeFloorStart !== null
|
|
662
|
+
? (() => {
|
|
663
|
+
const rawX = event.localPosition[0]
|
|
664
|
+
const rawZ = event.localPosition[2]
|
|
665
|
+
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
|
666
|
+
floorDragAnchor = anchor
|
|
667
|
+
return {
|
|
668
|
+
...event,
|
|
669
|
+
localPosition: [
|
|
670
|
+
relativeFloorStart.x + (rawX - anchor[0]),
|
|
671
|
+
event.localPosition[1],
|
|
672
|
+
relativeFloorStart.z + (rawZ - anchor[1]),
|
|
673
|
+
] as [number, number, number],
|
|
674
|
+
}
|
|
675
|
+
})()
|
|
676
|
+
: event
|
|
677
|
+
|
|
678
|
+
lastRawPos.current.set(
|
|
679
|
+
floorEvent.localPosition[0],
|
|
680
|
+
floorEvent.localPosition[1],
|
|
681
|
+
floorEvent.localPosition[2],
|
|
682
|
+
)
|
|
683
|
+
if (!cursorGroupRef.current) return
|
|
684
|
+
const result = floorStrategy.move(getContext(), floorEvent)
|
|
557
685
|
if (!result) return
|
|
558
686
|
|
|
687
|
+
// Figma-style alignment snap layered on top of the floor strategy's
|
|
688
|
+
// grid snap: when the draft's edge lines up (on X or Z) with another
|
|
689
|
+
// item's edge, snap and publish a guide. The guide connects to the
|
|
690
|
+
// nearest real corner of the candidate (resolver tie-break), so the dot
|
|
691
|
+
// always sits on an actual point. The delta is applied to BOTH the grid
|
|
692
|
+
// and cursor positions below. Alt bypasses.
|
|
693
|
+
const draft = draftNode.current
|
|
694
|
+
let alignX = 0
|
|
695
|
+
let alignZ = 0
|
|
696
|
+
const bypassAlign = floorEvent.nativeEvent?.altKey === true
|
|
697
|
+
if (!bypassAlign && draft) {
|
|
698
|
+
alignmentCandidates ??= collectAlignmentAnchors(
|
|
699
|
+
useScene.getState().nodes,
|
|
700
|
+
draft.id,
|
|
701
|
+
useViewer.getState().selection.levelId,
|
|
702
|
+
)
|
|
703
|
+
const ar = resolveAlignmentForActiveBuilding({
|
|
704
|
+
moving: movingFootprintAnchors(
|
|
705
|
+
draft as unknown as AnyNode,
|
|
706
|
+
result.gridPosition[0],
|
|
707
|
+
result.gridPosition[2],
|
|
708
|
+
cursorGroupRef.current.rotation.y,
|
|
709
|
+
),
|
|
710
|
+
candidates: alignmentCandidates,
|
|
711
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
712
|
+
})
|
|
713
|
+
if (ar.snap) {
|
|
714
|
+
alignX = ar.snap.dx
|
|
715
|
+
alignZ = ar.snap.dz
|
|
716
|
+
}
|
|
717
|
+
useAlignmentGuides.getState().set(ar.guides)
|
|
718
|
+
} else {
|
|
719
|
+
useAlignmentGuides.getState().clear()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const gridPos: [number, number, number] = [
|
|
723
|
+
result.gridPosition[0] + alignX,
|
|
724
|
+
result.gridPosition[1],
|
|
725
|
+
result.gridPosition[2] + alignZ,
|
|
726
|
+
]
|
|
727
|
+
|
|
559
728
|
// Play snap sound when grid position changes
|
|
560
729
|
if (
|
|
561
730
|
previousGridPos &&
|
|
562
|
-
(
|
|
563
|
-
result.gridPosition[2] !== previousGridPos[2])
|
|
731
|
+
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
|
564
732
|
) {
|
|
565
733
|
sfxEmitter.emit('sfx:grid-snap')
|
|
566
734
|
}
|
|
567
735
|
|
|
568
|
-
previousGridPos = [...
|
|
569
|
-
gridPosition.current.set(...
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
736
|
+
previousGridPos = [...gridPos]
|
|
737
|
+
gridPosition.current.set(...gridPos)
|
|
738
|
+
const cursorPosition = getFloorVisualPosition(gridPos)
|
|
739
|
+
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
|
740
|
+
// Floor items only rotate on Y; keep the preview box (and the live
|
|
741
|
+
// transform the 2D floorplan mirrors) aligned with the draft's
|
|
742
|
+
// rotation. Without this the box stays at its seed rotation until a
|
|
743
|
+
// manual R/T, so a moved already-rotated item shows an axis-aligned box.
|
|
744
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
575
745
|
|
|
576
|
-
|
|
577
|
-
if (draft) draft.position = result.gridPosition
|
|
746
|
+
if (draft) draft.position = gridPos
|
|
578
747
|
|
|
579
748
|
// Publish live transform for 2D floorplan
|
|
580
749
|
if (draft) {
|
|
581
750
|
useLiveTransforms.getState().set(draft.id, {
|
|
582
|
-
position:
|
|
751
|
+
position: gridPos,
|
|
583
752
|
rotation: cursorGroupRef.current.rotation.y,
|
|
584
753
|
})
|
|
585
754
|
}
|
|
@@ -588,11 +757,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
588
757
|
}
|
|
589
758
|
|
|
590
759
|
const onGridClick = (event: GridEvent) => {
|
|
760
|
+
// Drop alignment guides on click — the move commits (guides done) or
|
|
761
|
+
// placement re-arms (the next move republishes them).
|
|
762
|
+
useAlignmentGuides.getState().clear()
|
|
591
763
|
const result = floorStrategy.click(getContext(), event, getActiveValidators())
|
|
592
764
|
if (!result) return
|
|
593
765
|
|
|
594
766
|
// Preserve cursor rotation for the next draft
|
|
595
|
-
const currentRotation: [number, number, number] = [
|
|
767
|
+
const currentRotation: [number, number, number] = [
|
|
768
|
+
0,
|
|
769
|
+
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
|
770
|
+
0,
|
|
771
|
+
]
|
|
596
772
|
|
|
597
773
|
// Clear live transform before commit
|
|
598
774
|
if (draftNode.current) {
|
|
@@ -602,6 +778,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
602
778
|
draftNode.commit(result.nodeUpdate)
|
|
603
779
|
if (configRef.current.onCommitted()) {
|
|
604
780
|
draftNode.create(gridPosition.current, asset, currentRotation)
|
|
781
|
+
const previewBounds = expandBoundsToGrid(
|
|
782
|
+
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
783
|
+
asset.attachTo,
|
|
784
|
+
gridSnapStep,
|
|
785
|
+
)
|
|
786
|
+
updatePreviewGeometry(previewBounds)
|
|
787
|
+
updateDimensionGuides(previewBounds)
|
|
605
788
|
revalidate()
|
|
606
789
|
}
|
|
607
790
|
}
|
|
@@ -609,6 +792,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
609
792
|
// ---- Wall Handlers ----
|
|
610
793
|
|
|
611
794
|
const onWallEnter = (event: WallEvent) => {
|
|
795
|
+
has3DPointerDrivenMoveRef.current = true
|
|
612
796
|
const nodes = useScene.getState().nodes
|
|
613
797
|
const result = wallStrategy.enter(
|
|
614
798
|
getContext(),
|
|
@@ -634,6 +818,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
634
818
|
}
|
|
635
819
|
|
|
636
820
|
const onWallMove = (event: WallEvent) => {
|
|
821
|
+
releaseCommit = () => onWallClick(event)
|
|
822
|
+
has3DPointerDrivenMoveRef.current = true
|
|
823
|
+
if (!cursorGroupRef.current) return
|
|
637
824
|
const ctx = getContext()
|
|
638
825
|
|
|
639
826
|
if (ctx.state.surface !== 'wall') {
|
|
@@ -722,7 +909,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
722
909
|
}
|
|
723
910
|
// Mark parent wall dirty so it rebuilds geometry — only when position changed
|
|
724
911
|
if (result.dirtyNodeId && posChanged) {
|
|
725
|
-
|
|
912
|
+
const now = globalThis.performance?.now?.() ?? Date.now()
|
|
913
|
+
const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0
|
|
914
|
+
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
|
915
|
+
if (now - last > 120) {
|
|
916
|
+
lastWallDirtyAtRef.current.set(result.dirtyNodeId, now)
|
|
917
|
+
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
918
|
+
}
|
|
726
919
|
}
|
|
727
920
|
|
|
728
921
|
// Publish live transform for 2D floorplan
|
|
@@ -806,9 +999,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
806
999
|
const wz = Math.round(buildingLocalPoint.z * 2) / 2
|
|
807
1000
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
808
1001
|
|
|
809
|
-
Object.assign(placementState.current, {
|
|
1002
|
+
Object.assign(placementState.current, {
|
|
1003
|
+
surface: 'floor',
|
|
1004
|
+
surfaceItemId: null,
|
|
1005
|
+
shelfId: null,
|
|
1006
|
+
})
|
|
810
1007
|
gridPosition.current.set(wx, 0, wz)
|
|
811
|
-
|
|
1008
|
+
const levelId = useViewer.getState().selection.levelId as AnyNodeId | null
|
|
1009
|
+
const floorVisualPosition = getFloorVisualPosition(
|
|
1010
|
+
floorPos,
|
|
1011
|
+
levelId ? { parentId: levelId } : undefined,
|
|
1012
|
+
)
|
|
1013
|
+
if (cursorGroupRef.current) {
|
|
1014
|
+
cursorGroupRef.current.position.set(...floorVisualPosition)
|
|
1015
|
+
}
|
|
812
1016
|
|
|
813
1017
|
const draft = draftNode.current
|
|
814
1018
|
if (draft) {
|
|
@@ -824,6 +1028,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
824
1028
|
|
|
825
1029
|
const onItemEnter = (event: ItemEvent) => {
|
|
826
1030
|
if (event.node.id === draftNode.current?.id) return
|
|
1031
|
+
has3DPointerDrivenMoveRef.current = true
|
|
827
1032
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
|
828
1033
|
if (!result) return
|
|
829
1034
|
|
|
@@ -840,6 +1045,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
840
1045
|
|
|
841
1046
|
const onItemMove = (event: ItemEvent) => {
|
|
842
1047
|
if (event.node.id === draftNode.current?.id) return
|
|
1048
|
+
releaseCommit = () => onItemClick(event)
|
|
1049
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1050
|
+
if (!cursorGroupRef.current) return
|
|
843
1051
|
const ctx = getContext()
|
|
844
1052
|
|
|
845
1053
|
if (ctx.state.surface !== 'item-surface') {
|
|
@@ -923,7 +1131,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
923
1131
|
}
|
|
924
1132
|
|
|
925
1133
|
const onItemClick = (event: ItemEvent) => {
|
|
926
|
-
|
|
1134
|
+
// Click on the draft item itself. R3F dispatches click events to
|
|
1135
|
+
// the closest intersected mesh only — when the draft is hovering
|
|
1136
|
+
// on a host (shelf / table / etc.) the draft's mesh is *above*
|
|
1137
|
+
// the host's mesh, so the host's `${kind}:click` never fires.
|
|
1138
|
+
// If we're currently hosting on a shelf-surface, treat the
|
|
1139
|
+
// self-click as a commit on the active shelf so the user doesn't
|
|
1140
|
+
// have to aim around the cursor preview to drop the item.
|
|
1141
|
+
if (event.node.id === draftNode.current?.id) {
|
|
1142
|
+
const ctx = getContext()
|
|
1143
|
+
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
|
|
1144
|
+
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
|
|
1145
|
+
if (shelfNode && shelfNode.type === 'shelf') {
|
|
1146
|
+
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
|
|
1147
|
+
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
|
|
1148
|
+
if (result) {
|
|
1149
|
+
event.stopPropagation()
|
|
1150
|
+
if (draftNode.current) {
|
|
1151
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1152
|
+
}
|
|
1153
|
+
draftNode.commit(result.nodeUpdate)
|
|
1154
|
+
if (configRef.current.onCommitted()) {
|
|
1155
|
+
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
|
1156
|
+
if (enterResult) {
|
|
1157
|
+
applyTransition(enterResult)
|
|
1158
|
+
} else {
|
|
1159
|
+
revalidate()
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// Same self-click forwarding for item-surface hosts (tables,
|
|
1167
|
+
// counters) — the draft mesh sits on top of the host mesh, so
|
|
1168
|
+
// the host's own click event is blocked by the cursor preview.
|
|
1169
|
+
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
|
|
1170
|
+
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
1171
|
+
if (hostNode && hostNode.type === 'item') {
|
|
1172
|
+
const synthetic = { ...event, node: hostNode } as ItemEvent
|
|
1173
|
+
const result = itemSurfaceStrategy.click(ctx, synthetic)
|
|
1174
|
+
if (result) {
|
|
1175
|
+
event.stopPropagation()
|
|
1176
|
+
if (draftNode.current) {
|
|
1177
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1178
|
+
}
|
|
1179
|
+
draftNode.commit(result.nodeUpdate)
|
|
1180
|
+
if (configRef.current.onCommitted()) {
|
|
1181
|
+
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
|
1182
|
+
if (enterResult) {
|
|
1183
|
+
applyTransition(enterResult)
|
|
1184
|
+
} else {
|
|
1185
|
+
revalidate()
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Ceiling-hosted draft: when placing a ceiling-attached item the
|
|
1193
|
+
// draft hangs below the ceiling and intercepts the click ray
|
|
1194
|
+
// before the ceiling-grid mesh does — so `ceiling:click` never
|
|
1195
|
+
// fires and the user's commit click is dropped. Forward the
|
|
1196
|
+
// self-click to `ceilingStrategy.click` so placement commits the
|
|
1197
|
+
// same way it would from a click on the ceiling itself.
|
|
1198
|
+
if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
|
|
1199
|
+
const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
|
|
1200
|
+
if (ceilingNode && ceilingNode.type === 'ceiling') {
|
|
1201
|
+
const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
|
|
1202
|
+
const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
|
|
1203
|
+
if (result) {
|
|
1204
|
+
event.stopPropagation()
|
|
1205
|
+
if (draftNode.current) {
|
|
1206
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1207
|
+
}
|
|
1208
|
+
draftNode.commit(result.nodeUpdate)
|
|
1209
|
+
if (configRef.current.onCommitted()) {
|
|
1210
|
+
const nodes = useScene.getState().nodes
|
|
1211
|
+
const enterResult = ceilingStrategy.enter(
|
|
1212
|
+
getContext(),
|
|
1213
|
+
synthetic,
|
|
1214
|
+
resolveLevelId,
|
|
1215
|
+
nodes,
|
|
1216
|
+
)
|
|
1217
|
+
if (enterResult) {
|
|
1218
|
+
applyTransition(enterResult)
|
|
1219
|
+
} else {
|
|
1220
|
+
revalidate()
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
|
|
927
1230
|
const result = itemSurfaceStrategy.click(getContext(), event)
|
|
928
1231
|
if (!result) return
|
|
929
1232
|
|
|
@@ -948,6 +1251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
948
1251
|
// ---- Ceiling Handlers ----
|
|
949
1252
|
|
|
950
1253
|
const onCeilingEnter = (event: CeilingEvent) => {
|
|
1254
|
+
has3DPointerDrivenMoveRef.current = true
|
|
951
1255
|
const nodes = useScene.getState().nodes
|
|
952
1256
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
953
1257
|
if (!result) return
|
|
@@ -967,6 +1271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
967
1271
|
}
|
|
968
1272
|
|
|
969
1273
|
const onCeilingMove = (event: CeilingEvent) => {
|
|
1274
|
+
releaseCommit = () => onCeilingClick(event)
|
|
1275
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1276
|
+
if (!cursorGroupRef.current) return
|
|
970
1277
|
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
|
971
1278
|
const nodes = useScene.getState().nodes
|
|
972
1279
|
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
@@ -1005,9 +1312,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1005
1312
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1006
1313
|
if (mesh) mesh.position.copy(gridPosition.current)
|
|
1007
1314
|
|
|
1008
|
-
// Publish live transform for 2D floorplan
|
|
1315
|
+
// Publish live transform for 2D floorplan. The item override in
|
|
1316
|
+
// `floorplan-registry-layer` treats `live.position` as building-local
|
|
1317
|
+
// plan coords (parentId forced to null so the resolver renders it
|
|
1318
|
+
// directly), so publish the building-local cursor — not the
|
|
1319
|
+
// world-space `result.cursorPosition`, which otherwise lands the 2D
|
|
1320
|
+
// visual off the cursor whenever the building isn't at the origin
|
|
1321
|
+
// with zero rotation.
|
|
1009
1322
|
useLiveTransforms.getState().set(draft.id, {
|
|
1010
|
-
position:
|
|
1323
|
+
position: [cc.x, cc.y, cc.z],
|
|
1011
1324
|
rotation: cursorGroupRef.current.rotation.y,
|
|
1012
1325
|
})
|
|
1013
1326
|
}
|
|
@@ -1065,9 +1378,112 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1065
1378
|
}
|
|
1066
1379
|
}
|
|
1067
1380
|
|
|
1381
|
+
// ---- Shelf Handlers ----
|
|
1382
|
+
//
|
|
1383
|
+
// Items can host on shelves the same way they host on tables and
|
|
1384
|
+
// counters (item-surface). The shelf's `surfaces.custom` exposes one
|
|
1385
|
+
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
|
|
1386
|
+
// to the cursor's local-Y so the user can target a specific row.
|
|
1387
|
+
|
|
1388
|
+
const onShelfEnter = (event: ShelfEvent) => {
|
|
1389
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1390
|
+
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1391
|
+
if (!result) return
|
|
1392
|
+
|
|
1393
|
+
event.stopPropagation()
|
|
1394
|
+
applyTransition(result)
|
|
1395
|
+
|
|
1396
|
+
if (!draftNode.current) {
|
|
1397
|
+
ensureDraft(result)
|
|
1398
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1399
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const onShelfMove = (event: ShelfEvent) => {
|
|
1404
|
+
releaseCommit = () => onShelfClick(event)
|
|
1405
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1406
|
+
// A shelf event can fire before the cursor group mounts or after
|
|
1407
|
+
// teardown, leaving the ref null; bail before dereferencing it below.
|
|
1408
|
+
if (!cursorGroupRef.current) return
|
|
1409
|
+
const ctx = getContext()
|
|
1410
|
+
if (ctx.state.surface !== 'shelf-surface') {
|
|
1411
|
+
// Cursor entered via a move event without an enter — try
|
|
1412
|
+
// transitioning in so the user doesn't need to mouse out + back
|
|
1413
|
+
// in to start hosting.
|
|
1414
|
+
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
|
|
1415
|
+
if (!enterResult) return
|
|
1416
|
+
event.stopPropagation()
|
|
1417
|
+
applyTransition(enterResult)
|
|
1418
|
+
if (!draftNode.current) {
|
|
1419
|
+
ensureDraft(enterResult)
|
|
1420
|
+
} else if (enterResult.nodeUpdate.parentId) {
|
|
1421
|
+
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
1422
|
+
}
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1425
|
+
const result = shelfSurfaceStrategy.move(ctx, event)
|
|
1426
|
+
if (!result) return
|
|
1427
|
+
|
|
1428
|
+
event.stopPropagation()
|
|
1429
|
+
|
|
1430
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1431
|
+
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
1432
|
+
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
1433
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
1434
|
+
|
|
1435
|
+
const draft = draftNode.current
|
|
1436
|
+
if (draft) {
|
|
1437
|
+
draft.position = result.gridPosition
|
|
1438
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1439
|
+
if (mesh) mesh.position.set(...result.gridPosition)
|
|
1440
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
1441
|
+
position: result.cursorPosition,
|
|
1442
|
+
rotation: result.cursorRotationY,
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
revalidate()
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const onShelfLeave = (event: ShelfEvent) => {
|
|
1450
|
+
if (placementState.current.surface !== 'shelf-surface') return
|
|
1451
|
+
if (event.node.id !== placementState.current.shelfId) return
|
|
1452
|
+
// Intentionally do NOT detach to the floor here. `shelf:leave` fires
|
|
1453
|
+
// constantly while hosting because the cursor ray slips off the shelf's
|
|
1454
|
+
// thin boards and through its gaps — detaching on each of those would
|
|
1455
|
+
// thrash the item between the shelf row and the floor. The grid handler
|
|
1456
|
+
// owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
|
|
1457
|
+
// in `onGridMove`): it detaches only once the cursor is clearly off the
|
|
1458
|
+
// shelf footprint, which is the genuine "left the shelf" signal.
|
|
1459
|
+
event.stopPropagation()
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const onShelfClick = (event: ShelfEvent) => {
|
|
1463
|
+
const result = shelfSurfaceStrategy.click(getContext(), event)
|
|
1464
|
+
if (!result) return
|
|
1465
|
+
|
|
1466
|
+
event.stopPropagation()
|
|
1467
|
+
if (draftNode.current) {
|
|
1468
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1469
|
+
}
|
|
1470
|
+
draftNode.commit(result.nodeUpdate)
|
|
1471
|
+
|
|
1472
|
+
if (configRef.current.onCommitted()) {
|
|
1473
|
+
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1474
|
+
if (enterResult) {
|
|
1475
|
+
applyTransition(enterResult)
|
|
1476
|
+
} else {
|
|
1477
|
+
revalidate()
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1068
1482
|
// ---- Keyboard rotation ----
|
|
1069
1483
|
|
|
1070
|
-
|
|
1484
|
+
// 45° increments — matches the R-key rotation step for already-placed
|
|
1485
|
+
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
|
1486
|
+
const ROTATION_STEP = Math.PI / 4
|
|
1071
1487
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1072
1488
|
if (event.key === 'Shift') {
|
|
1073
1489
|
shiftFreeRef.current = true
|
|
@@ -1097,7 +1513,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1097
1513
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
1098
1514
|
|
|
1099
1515
|
// Ref + cursor mesh + item mesh — no store update during drag
|
|
1100
|
-
cursorGroupRef.current
|
|
1516
|
+
if (cursorGroupRef.current) {
|
|
1517
|
+
cursorGroupRef.current.rotation.y = newRotationY
|
|
1518
|
+
}
|
|
1101
1519
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1102
1520
|
if (mesh) mesh.rotation.y = newRotationY
|
|
1103
1521
|
|
|
@@ -1111,11 +1529,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1111
1529
|
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
|
1112
1530
|
gridPosition.current.set(x, gridPosition.current.y, z)
|
|
1113
1531
|
draft.position = [x, gridPosition.current.y, z]
|
|
1114
|
-
cursorGroupRef.current
|
|
1115
|
-
|
|
1532
|
+
if (cursorGroupRef.current) {
|
|
1533
|
+
if (surface === 'floor') {
|
|
1534
|
+
cursorGroupRef.current.position.set(
|
|
1535
|
+
...getFloorVisualPosition([x, gridPosition.current.y, z]),
|
|
1536
|
+
)
|
|
1537
|
+
} else {
|
|
1538
|
+
cursorGroupRef.current.position.x = x
|
|
1539
|
+
cursorGroupRef.current.position.z = z
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1116
1542
|
if (mesh) {
|
|
1117
1543
|
mesh.position.x = x
|
|
1118
1544
|
mesh.position.z = z
|
|
1545
|
+
if (surface === 'floor') {
|
|
1546
|
+
mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
|
|
1547
|
+
}
|
|
1119
1548
|
}
|
|
1120
1549
|
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
|
1121
1550
|
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
|
@@ -1135,7 +1564,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1135
1564
|
worldSnapped.y,
|
|
1136
1565
|
worldSnapped.z,
|
|
1137
1566
|
)
|
|
1138
|
-
cursorGroupRef.current
|
|
1567
|
+
if (cursorGroupRef.current) {
|
|
1568
|
+
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
1569
|
+
}
|
|
1139
1570
|
if (mesh) mesh.position.set(x, y, z)
|
|
1140
1571
|
}
|
|
1141
1572
|
}
|
|
@@ -1143,13 +1574,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1143
1574
|
// Update live transform for 2D floorplan with post-snap position
|
|
1144
1575
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
1145
1576
|
if (currentLive) {
|
|
1577
|
+
const livePosition: [number, number, number] =
|
|
1578
|
+
surface === 'floor'
|
|
1579
|
+
? [draft.position[0], draft.position[1], draft.position[2]]
|
|
1580
|
+
: cursorGroupRef.current
|
|
1581
|
+
? [
|
|
1582
|
+
cursorGroupRef.current.position.x,
|
|
1583
|
+
cursorGroupRef.current.position.y,
|
|
1584
|
+
cursorGroupRef.current.position.z,
|
|
1585
|
+
]
|
|
1586
|
+
: [draft.position[0], draft.position[1], draft.position[2]]
|
|
1146
1587
|
useLiveTransforms.getState().set(draft.id, {
|
|
1147
1588
|
...currentLive,
|
|
1148
|
-
position:
|
|
1149
|
-
cursorGroupRef.current.position.x,
|
|
1150
|
-
cursorGroupRef.current.position.y,
|
|
1151
|
-
cursorGroupRef.current.position.z,
|
|
1152
|
-
] as [number, number, number],
|
|
1589
|
+
position: livePosition,
|
|
1153
1590
|
rotation: newRotationY,
|
|
1154
1591
|
})
|
|
1155
1592
|
}
|
|
@@ -1170,6 +1607,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1170
1607
|
|
|
1171
1608
|
// ---- tool:cancel (Escape / programmatic) ----
|
|
1172
1609
|
const onCancel = () => {
|
|
1610
|
+
useAlignmentGuides.getState().clear()
|
|
1173
1611
|
if (configRef.current.onCancel) {
|
|
1174
1612
|
configRef.current.onCancel()
|
|
1175
1613
|
}
|
|
@@ -1239,10 +1677,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1239
1677
|
emitter.on('ceiling:move', onCeilingMove)
|
|
1240
1678
|
emitter.on('ceiling:click', onCeilingClick)
|
|
1241
1679
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
1680
|
+
emitter.on('shelf:enter', onShelfEnter)
|
|
1681
|
+
emitter.on('shelf:move', onShelfMove)
|
|
1682
|
+
emitter.on('shelf:click', onShelfClick)
|
|
1683
|
+
emitter.on('shelf:leave', onShelfLeave)
|
|
1684
|
+
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
|
1242
1685
|
|
|
1243
1686
|
return () => {
|
|
1244
1687
|
tearingDown = true
|
|
1688
|
+
if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
|
|
1245
1689
|
unsubDraftWatch()
|
|
1690
|
+
useAlignmentGuides.getState().clear()
|
|
1246
1691
|
// Clear live transform for any remaining draft
|
|
1247
1692
|
if (draftNode.current) {
|
|
1248
1693
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
@@ -1263,12 +1708,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1263
1708
|
emitter.off('ceiling:move', onCeilingMove)
|
|
1264
1709
|
emitter.off('ceiling:click', onCeilingClick)
|
|
1265
1710
|
emitter.off('ceiling:leave', onCeilingLeave)
|
|
1711
|
+
emitter.off('shelf:enter', onShelfEnter)
|
|
1712
|
+
emitter.off('shelf:move', onShelfMove)
|
|
1713
|
+
emitter.off('shelf:click', onShelfClick)
|
|
1714
|
+
emitter.off('shelf:leave', onShelfLeave)
|
|
1266
1715
|
emitter.off('tool:cancel', onCancel)
|
|
1267
1716
|
window.removeEventListener('keydown', onKeyDown)
|
|
1268
1717
|
window.removeEventListener('keyup', onKeyUp)
|
|
1269
1718
|
window.removeEventListener('contextmenu', onContextMenu)
|
|
1270
1719
|
}
|
|
1271
|
-
}, [
|
|
1720
|
+
}, [
|
|
1721
|
+
asset,
|
|
1722
|
+
canPlaceOnFloor,
|
|
1723
|
+
canPlaceOnWall,
|
|
1724
|
+
canPlaceOnCeiling,
|
|
1725
|
+
draftNode,
|
|
1726
|
+
getFloorVisualPosition,
|
|
1727
|
+
gridSnapStep,
|
|
1728
|
+
updateDimensionGuides,
|
|
1729
|
+
updatePreviewGeometry,
|
|
1730
|
+
])
|
|
1272
1731
|
|
|
1273
1732
|
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
|
1274
1733
|
// box snaps to the new cell size right away.
|
|
@@ -1282,7 +1741,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1282
1741
|
)
|
|
1283
1742
|
updatePreviewGeometry(previewBounds)
|
|
1284
1743
|
updateDimensionGuides(previewBounds)
|
|
1285
|
-
}, [gridSnapStep, asset, draftNode])
|
|
1744
|
+
}, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
|
|
1286
1745
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
1287
1746
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
1288
1747
|
useEffect(() => {
|
|
@@ -1294,10 +1753,58 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1294
1753
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
1295
1754
|
}, [viewerLevelId, draftNode, asset])
|
|
1296
1755
|
|
|
1756
|
+
// Disable raycasting on the live draft mesh (and restore it when the draft
|
|
1757
|
+
// changes or goes away) so the cursor ray passes through the item being
|
|
1758
|
+
// moved and lands on the surface beneath it.
|
|
1759
|
+
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
|
|
1760
|
+
if (raycastDisabledMeshRef.current !== mesh) {
|
|
1761
|
+
// New draft root (or cleared): restore the prior mesh and reset tracking.
|
|
1762
|
+
for (const restore of restoreRaycastsRef.current) restore()
|
|
1763
|
+
restoreRaycastsRef.current = []
|
|
1764
|
+
raycastDisabledChildrenRef.current = new WeakSet()
|
|
1765
|
+
raycastDisabledMeshRef.current = mesh
|
|
1766
|
+
}
|
|
1767
|
+
if (!mesh) return
|
|
1768
|
+
// Disable any descendant not handled yet. Item drafts are GLB models whose
|
|
1769
|
+
// child meshes mount asynchronously (Suspense), so a one-shot traverse
|
|
1770
|
+
// misses them — those late children keep intercepting the ray and corrupt
|
|
1771
|
+
// the shelf-row hit the moment the item moves onto a row. Re-walking each
|
|
1772
|
+
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
|
|
1773
|
+
mesh.traverse((child) => {
|
|
1774
|
+
if (raycastDisabledChildrenRef.current.has(child)) return
|
|
1775
|
+
raycastDisabledChildrenRef.current.add(child)
|
|
1776
|
+
const original = child.raycast
|
|
1777
|
+
child.raycast = () => {}
|
|
1778
|
+
restoreRaycastsRef.current.push(() => {
|
|
1779
|
+
child.raycast = original
|
|
1780
|
+
})
|
|
1781
|
+
})
|
|
1782
|
+
}, [])
|
|
1783
|
+
|
|
1784
|
+
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
|
1785
|
+
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
|
1786
|
+
|
|
1297
1787
|
useFrame((_, delta) => {
|
|
1298
|
-
if (!asset)
|
|
1299
|
-
|
|
1300
|
-
|
|
1788
|
+
if (!asset) {
|
|
1789
|
+
reconcileDraftRaycast(null)
|
|
1790
|
+
return
|
|
1791
|
+
}
|
|
1792
|
+
if (!draftNode.current) {
|
|
1793
|
+
reconcileDraftRaycast(null)
|
|
1794
|
+
return
|
|
1795
|
+
}
|
|
1796
|
+
const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
|
|
1797
|
+
reconcileDraftRaycast(mesh)
|
|
1798
|
+
// mitt listeners outlive the cursor group's mount; bail if it's gone
|
|
1799
|
+
// (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
|
|
1800
|
+
// draft's raycast is still restored during that window.
|
|
1801
|
+
if (!cursorGroupRef.current) return
|
|
1802
|
+
// The mesh-position lerp below only makes sense once this coordinator
|
|
1803
|
+
// owns the move via a 3D pointer event. Skip until then so that
|
|
1804
|
+
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
|
|
1805
|
+
// writing scene.position directly) aren't fought by useFrame pulling
|
|
1806
|
+
// the mesh back to its pre-move location.
|
|
1807
|
+
if (!has3DPointerDrivenMoveRef.current) return
|
|
1301
1808
|
if (!mesh) return
|
|
1302
1809
|
|
|
1303
1810
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
@@ -1317,18 +1824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1317
1824
|
|
|
1318
1825
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
1319
1826
|
if (!asset.attachTo) {
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
draftNode.current.asset.attachTo,
|
|
1328
|
-
),
|
|
1329
|
-
draftNode.current.rotation,
|
|
1330
|
-
)
|
|
1331
|
-
mesh.position.y = slabElevation
|
|
1827
|
+
const visualPosition = getFloorVisualPosition([
|
|
1828
|
+
gridPosition.current.x,
|
|
1829
|
+
gridPosition.current.y,
|
|
1830
|
+
gridPosition.current.z,
|
|
1831
|
+
])
|
|
1832
|
+
mesh.position.y = visualPosition[1]
|
|
1833
|
+
cursorGroupRef.current.position.y = visualPosition[1]
|
|
1332
1834
|
}
|
|
1333
1835
|
}
|
|
1334
1836
|
})
|
|
@@ -1341,7 +1843,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1341
1843
|
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1342
1844
|
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
1343
1845
|
const initialDimensionBounds = expandBoundsToGrid(
|
|
1344
|
-
getFallbackPreviewBounds(initialDraft, config.asset
|
|
1846
|
+
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
|
1345
1847
|
initialAttachTo,
|
|
1346
1848
|
gridSnapStep,
|
|
1347
1849
|
)
|
|
@@ -1354,6 +1856,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1354
1856
|
initialDimensionBounds.dimensions[0],
|
|
1355
1857
|
initialDimensionBounds.dimensions[1],
|
|
1356
1858
|
initialDimensionBounds.dimensions[2],
|
|
1859
|
+
initialDimensionBounds,
|
|
1357
1860
|
],
|
|
1358
1861
|
)
|
|
1359
1862
|
const basePlaneGeometry = useMemo(() => {
|