@pascal-app/editor 0.7.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 +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- 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 +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- 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 +5837 -12006
- 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 +234 -190
- 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 +435 -158
- 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 +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- 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-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- 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 +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- 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 +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- 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 +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- 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 -543
- 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 +57 -1
- 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 +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- 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 +8 -12
- 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 +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -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 +24 -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 +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- 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.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- 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 +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- 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 +342 -91
- 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/tsconfig.json +2 -1
- 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/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- 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 -257
- 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 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- 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 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- 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 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- 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 -230
- 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 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,29 +1,32 @@
|
|
|
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
|
-
BufferGeometry,
|
|
25
29
|
Euler,
|
|
26
|
-
Float32BufferAttribute,
|
|
27
30
|
type Group,
|
|
28
31
|
type LineSegments,
|
|
29
32
|
Matrix4,
|
|
@@ -31,19 +34,29 @@ import {
|
|
|
31
34
|
type Object3D,
|
|
32
35
|
PlaneGeometry,
|
|
33
36
|
Quaternion,
|
|
37
|
+
Ray,
|
|
34
38
|
Vector3,
|
|
35
39
|
} from 'three'
|
|
36
40
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
37
41
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
38
42
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
39
43
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
44
|
+
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
|
40
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'
|
|
41
53
|
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
|
42
54
|
import {
|
|
43
55
|
ceilingStrategy,
|
|
44
56
|
checkCanPlace,
|
|
45
57
|
floorStrategy,
|
|
46
58
|
itemSurfaceStrategy,
|
|
59
|
+
shelfSurfaceStrategy,
|
|
47
60
|
wallStrategy,
|
|
48
61
|
} from './placement-strategies'
|
|
49
62
|
import type { PlacementState, TransitionResult } from './placement-types'
|
|
@@ -51,6 +64,10 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|
|
51
64
|
|
|
52
65
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
53
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
|
+
|
|
54
71
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
55
72
|
if (unit === 'imperial') {
|
|
56
73
|
const feet = value * 3.280_84
|
|
@@ -62,13 +79,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
|
62
79
|
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
63
80
|
}
|
|
64
81
|
|
|
65
|
-
type PreviewBounds = {
|
|
66
|
-
min: [number, number, number]
|
|
67
|
-
max: [number, number, number]
|
|
68
|
-
dimensions: [number, number, number]
|
|
69
|
-
center: [number, number, number]
|
|
70
|
-
}
|
|
71
|
-
|
|
72
82
|
/**
|
|
73
83
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
74
84
|
* The wireframe stays centered on the original bounds centre on each axis we
|
|
@@ -117,85 +127,12 @@ function expandBoundsToGrid(
|
|
|
117
127
|
}
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
|
|
121
|
-
if (!object) return null
|
|
122
|
-
|
|
123
|
-
object.updateWorldMatrix(true, true)
|
|
124
|
-
|
|
125
|
-
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
|
|
126
|
-
const localMatrix = new Matrix4()
|
|
127
|
-
const localBounds = new Box3()
|
|
128
|
-
const scratchBounds = new Box3()
|
|
129
|
-
const hasBounds = { current: false }
|
|
130
|
-
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
|
|
131
|
-
|
|
132
|
-
const expandBounds = (child: Object3D) => {
|
|
133
|
-
if (child !== object && registeredNodeObjects.has(child)) {
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const mesh = child as Object3D & {
|
|
138
|
-
isMesh?: boolean
|
|
139
|
-
name?: string
|
|
140
|
-
geometry?: {
|
|
141
|
-
boundingBox: Box3 | null
|
|
142
|
-
computeBoundingBox?: () => void
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
|
|
147
|
-
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
|
|
148
|
-
mesh.geometry.computeBoundingBox()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (mesh.geometry.boundingBox) {
|
|
152
|
-
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
|
|
153
|
-
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
|
|
154
|
-
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
|
|
155
|
-
if (!hasBounds.current) {
|
|
156
|
-
localBounds.copy(scratchBounds)
|
|
157
|
-
hasBounds.current = true
|
|
158
|
-
} else {
|
|
159
|
-
localBounds.union(scratchBounds)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const grandchild of child.children) {
|
|
166
|
-
expandBounds(grandchild)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
for (const child of object.children) {
|
|
171
|
-
expandBounds(child)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!hasBounds.current) return null
|
|
175
|
-
|
|
176
|
-
const size = new Vector3()
|
|
177
|
-
const center = new Vector3()
|
|
178
|
-
localBounds.getSize(size)
|
|
179
|
-
localBounds.getCenter(center)
|
|
180
|
-
|
|
181
|
-
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
|
|
182
|
-
return null
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
|
|
187
|
-
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
|
|
188
|
-
dimensions: [size.x, size.y, size.z],
|
|
189
|
-
center: [center.x, center.y, center.z],
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
130
|
function getFallbackPreviewBounds(
|
|
194
131
|
item: import('@pascal-app/core').ItemNode | null,
|
|
195
|
-
asset: AssetInput,
|
|
196
|
-
attachTo: AssetInput['attachTo'],
|
|
132
|
+
asset: AssetInput | null | undefined,
|
|
133
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
197
134
|
): PreviewBounds {
|
|
198
|
-
const dims = item ? getScaledDimensions(item) : (asset
|
|
135
|
+
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
199
136
|
return {
|
|
200
137
|
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
|
201
138
|
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
|
|
@@ -204,112 +141,20 @@ function getFallbackPreviewBounds(
|
|
|
204
141
|
}
|
|
205
142
|
}
|
|
206
143
|
|
|
207
|
-
function
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
function getBoxEdgePoints(bounds: PreviewBounds): number[] {
|
|
214
|
-
const [width, height, depth] = bounds.dimensions
|
|
215
|
-
const [centerX, centerY, centerZ] = bounds.center
|
|
216
|
-
const minX = centerX - width / 2
|
|
217
|
-
const maxX = centerX + width / 2
|
|
218
|
-
const minY = centerY - height / 2
|
|
219
|
-
const maxY = centerY + height / 2
|
|
220
|
-
const minZ = centerZ - depth / 2
|
|
221
|
-
const maxZ = centerZ + depth / 2
|
|
222
|
-
|
|
223
|
-
return [
|
|
224
|
-
minX,
|
|
225
|
-
minY,
|
|
226
|
-
minZ,
|
|
227
|
-
maxX,
|
|
228
|
-
minY,
|
|
229
|
-
minZ,
|
|
230
|
-
maxX,
|
|
231
|
-
minY,
|
|
232
|
-
minZ,
|
|
233
|
-
maxX,
|
|
234
|
-
minY,
|
|
235
|
-
maxZ,
|
|
236
|
-
maxX,
|
|
237
|
-
minY,
|
|
238
|
-
maxZ,
|
|
239
|
-
minX,
|
|
240
|
-
minY,
|
|
241
|
-
maxZ,
|
|
242
|
-
minX,
|
|
243
|
-
minY,
|
|
244
|
-
maxZ,
|
|
245
|
-
minX,
|
|
246
|
-
minY,
|
|
247
|
-
minZ,
|
|
248
|
-
|
|
249
|
-
minX,
|
|
250
|
-
maxY,
|
|
251
|
-
minZ,
|
|
252
|
-
maxX,
|
|
253
|
-
maxY,
|
|
254
|
-
minZ,
|
|
255
|
-
maxX,
|
|
256
|
-
maxY,
|
|
257
|
-
minZ,
|
|
258
|
-
maxX,
|
|
259
|
-
maxY,
|
|
260
|
-
maxZ,
|
|
261
|
-
maxX,
|
|
262
|
-
maxY,
|
|
263
|
-
maxZ,
|
|
264
|
-
minX,
|
|
265
|
-
maxY,
|
|
266
|
-
maxZ,
|
|
267
|
-
minX,
|
|
268
|
-
maxY,
|
|
269
|
-
maxZ,
|
|
270
|
-
minX,
|
|
271
|
-
maxY,
|
|
272
|
-
minZ,
|
|
273
|
-
|
|
274
|
-
minX,
|
|
275
|
-
minY,
|
|
276
|
-
minZ,
|
|
277
|
-
minX,
|
|
278
|
-
maxY,
|
|
279
|
-
minZ,
|
|
280
|
-
maxX,
|
|
281
|
-
minY,
|
|
282
|
-
minZ,
|
|
283
|
-
maxX,
|
|
284
|
-
maxY,
|
|
285
|
-
minZ,
|
|
286
|
-
maxX,
|
|
287
|
-
minY,
|
|
288
|
-
maxZ,
|
|
289
|
-
maxX,
|
|
290
|
-
maxY,
|
|
291
|
-
maxZ,
|
|
292
|
-
minX,
|
|
293
|
-
minY,
|
|
294
|
-
maxZ,
|
|
295
|
-
minX,
|
|
296
|
-
maxY,
|
|
297
|
-
maxZ,
|
|
298
|
-
]
|
|
299
|
-
}
|
|
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
|
+
}
|
|
300
150
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!geometry) return
|
|
151
|
+
const scaleAxis = (axis: 0 | 1 | 2) =>
|
|
152
|
+
scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
|
|
304
153
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
} else {
|
|
309
|
-
attribute.set(points)
|
|
310
|
-
attribute.needsUpdate = true
|
|
154
|
+
return {
|
|
155
|
+
...item,
|
|
156
|
+
scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
|
|
311
157
|
}
|
|
312
|
-
geometry.computeBoundingSphere()
|
|
313
158
|
}
|
|
314
159
|
|
|
315
160
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
@@ -350,6 +195,8 @@ export interface PlacementCoordinatorConfig {
|
|
|
350
195
|
initialState?: PlacementState
|
|
351
196
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
352
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
|
|
353
200
|
}
|
|
354
201
|
|
|
355
202
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
@@ -361,14 +208,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
361
208
|
const basePlaneRef = useRef<Mesh>(null!)
|
|
362
209
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
363
210
|
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
|
211
|
+
const lastWallDirtyAtRef = useRef(new Map<string, number>())
|
|
364
212
|
const placementState = useRef<PlacementState>(
|
|
365
|
-
config.initialState ?? {
|
|
213
|
+
config.initialState ?? {
|
|
214
|
+
surface: 'floor',
|
|
215
|
+
wallId: null,
|
|
216
|
+
ceilingId: null,
|
|
217
|
+
surfaceItemId: null,
|
|
218
|
+
shelfId: null,
|
|
219
|
+
},
|
|
366
220
|
)
|
|
367
221
|
const shiftFreeRef = useRef(false)
|
|
368
222
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
369
|
-
|
|
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>())
|
|
370
241
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
371
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
|
+
|
|
372
249
|
// Store config callbacks in refs to avoid re-running effect when they change
|
|
373
250
|
const configRef = useRef(config)
|
|
374
251
|
configRef.current = config
|
|
@@ -377,7 +254,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
377
254
|
const { asset, draftNode } = config
|
|
378
255
|
const unit = useViewer((state) => state.unit)
|
|
379
256
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
|
380
|
-
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
|
257
|
+
const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
|
|
381
258
|
const [width, height, depth] = bounds.dimensions
|
|
382
259
|
const [centerX, centerY, centerZ] = bounds.center
|
|
383
260
|
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
|
@@ -394,9 +271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
394
271
|
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
|
395
272
|
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
|
396
273
|
oldBasePlaneGeometry.dispose()
|
|
397
|
-
}
|
|
274
|
+
}, [])
|
|
398
275
|
|
|
399
|
-
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
|
276
|
+
const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
|
|
400
277
|
setDimensionBounds((current) => {
|
|
401
278
|
if (
|
|
402
279
|
current &&
|
|
@@ -498,25 +375,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
498
375
|
applyPoints(measurementWidthRef, widthPoints)
|
|
499
376
|
applyPoints(measurementDepthRef, depthPoints)
|
|
500
377
|
applyPoints(measurementHeightRef, heightPoints)
|
|
501
|
-
}
|
|
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
|
+
)
|
|
502
396
|
|
|
503
397
|
useEffect(() => {
|
|
504
398
|
if (!asset) return
|
|
505
399
|
useScene.temporal.getState().pause()
|
|
506
|
-
meshPreviewAppliedRef.current = false
|
|
507
400
|
|
|
508
401
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
509
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
|
+
|
|
510
412
|
// Reset placement state
|
|
511
413
|
placementState.current = configRef.current.initialState ?? {
|
|
512
414
|
surface: 'floor',
|
|
513
415
|
wallId: null,
|
|
514
416
|
ceilingId: null,
|
|
515
417
|
surfaceItemId: null,
|
|
418
|
+
shelfId: null,
|
|
516
419
|
}
|
|
517
420
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
518
421
|
gridPosition.current.y = 0
|
|
519
|
-
cursorGroupRef.current
|
|
422
|
+
if (cursorGroupRef.current) {
|
|
423
|
+
cursorGroupRef.current.position.y = 0
|
|
424
|
+
}
|
|
520
425
|
}
|
|
521
426
|
|
|
522
427
|
// ---- Helpers ----
|
|
@@ -527,7 +432,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
527
432
|
draftItem: draftNode.current,
|
|
528
433
|
gridPosition: gridPosition.current,
|
|
529
434
|
state: { ...placementState.current },
|
|
530
|
-
currentCursorRotationY:
|
|
435
|
+
currentCursorRotationY:
|
|
436
|
+
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
|
531
437
|
})
|
|
532
438
|
|
|
533
439
|
const getActiveValidators = () =>
|
|
@@ -557,12 +463,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
557
463
|
}
|
|
558
464
|
|
|
559
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()
|
|
560
469
|
Object.assign(placementState.current, result.stateUpdate)
|
|
561
470
|
gridPosition.current.set(...result.gridPosition)
|
|
562
471
|
|
|
563
472
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
564
|
-
cursorGroupRef.current
|
|
565
|
-
|
|
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
|
+
}
|
|
566
481
|
|
|
567
482
|
const draft = draftNode.current
|
|
568
483
|
if (draft) {
|
|
@@ -575,15 +490,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
575
490
|
const ensureDraft = (result: TransitionResult) => {
|
|
576
491
|
gridPosition.current.set(...result.gridPosition)
|
|
577
492
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
578
|
-
cursorGroupRef.current
|
|
579
|
-
|
|
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
|
+
}
|
|
580
501
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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)
|
|
587
509
|
|
|
588
510
|
const draft = draftNode.current
|
|
589
511
|
if (draft) {
|
|
@@ -592,6 +514,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
592
514
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
593
515
|
}
|
|
594
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
|
+
|
|
595
525
|
if (!revalidate()) {
|
|
596
526
|
draftNode.destroy()
|
|
597
527
|
}
|
|
@@ -599,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
599
529
|
|
|
600
530
|
// ---- Init draft ----
|
|
601
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
|
|
602
537
|
|
|
603
538
|
// Sync cursor to the draft mesh's world position and rotation
|
|
604
539
|
if (draftNode.current) {
|
|
@@ -607,12 +542,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
607
542
|
const worldPos = new Vector3()
|
|
608
543
|
mesh.getWorldPosition(worldPos)
|
|
609
544
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
610
|
-
cursorGroupRef.current
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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) {
|
|
616
561
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
|
617
562
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
618
563
|
}
|
|
@@ -620,44 +565,190 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
620
565
|
|
|
621
566
|
revalidate()
|
|
622
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
|
+
|
|
623
593
|
// ---- Floor Handlers ----
|
|
624
594
|
|
|
625
595
|
let previousGridPos: [number, number, number] | null = null
|
|
626
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
|
+
|
|
627
641
|
const onGridMove = (event: GridEvent) => {
|
|
642
|
+
releaseCommit = () => onGridClick(event)
|
|
628
643
|
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
629
644
|
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
630
645
|
configRef.current.initDraft(gridPosition.current)
|
|
631
646
|
}
|
|
632
647
|
|
|
633
|
-
|
|
634
|
-
|
|
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)
|
|
635
685
|
if (!result) return
|
|
636
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
|
+
|
|
637
728
|
// Play snap sound when grid position changes
|
|
638
729
|
if (
|
|
639
730
|
previousGridPos &&
|
|
640
|
-
(
|
|
641
|
-
result.gridPosition[2] !== previousGridPos[2])
|
|
731
|
+
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
|
642
732
|
) {
|
|
643
733
|
sfxEmitter.emit('sfx:grid-snap')
|
|
644
734
|
}
|
|
645
735
|
|
|
646
|
-
previousGridPos = [...
|
|
647
|
-
gridPosition.current.set(...
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
653
745
|
|
|
654
|
-
|
|
655
|
-
if (draft) draft.position = result.gridPosition
|
|
746
|
+
if (draft) draft.position = gridPos
|
|
656
747
|
|
|
657
748
|
// Publish live transform for 2D floorplan
|
|
658
749
|
if (draft) {
|
|
659
750
|
useLiveTransforms.getState().set(draft.id, {
|
|
660
|
-
position:
|
|
751
|
+
position: gridPos,
|
|
661
752
|
rotation: cursorGroupRef.current.rotation.y,
|
|
662
753
|
})
|
|
663
754
|
}
|
|
@@ -666,11 +757,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
666
757
|
}
|
|
667
758
|
|
|
668
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()
|
|
669
763
|
const result = floorStrategy.click(getContext(), event, getActiveValidators())
|
|
670
764
|
if (!result) return
|
|
671
765
|
|
|
672
766
|
// Preserve cursor rotation for the next draft
|
|
673
|
-
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
|
+
]
|
|
674
772
|
|
|
675
773
|
// Clear live transform before commit
|
|
676
774
|
if (draftNode.current) {
|
|
@@ -680,6 +778,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
680
778
|
draftNode.commit(result.nodeUpdate)
|
|
681
779
|
if (configRef.current.onCommitted()) {
|
|
682
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)
|
|
683
788
|
revalidate()
|
|
684
789
|
}
|
|
685
790
|
}
|
|
@@ -687,6 +792,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
687
792
|
// ---- Wall Handlers ----
|
|
688
793
|
|
|
689
794
|
const onWallEnter = (event: WallEvent) => {
|
|
795
|
+
has3DPointerDrivenMoveRef.current = true
|
|
690
796
|
const nodes = useScene.getState().nodes
|
|
691
797
|
const result = wallStrategy.enter(
|
|
692
798
|
getContext(),
|
|
@@ -712,6 +818,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
712
818
|
}
|
|
713
819
|
|
|
714
820
|
const onWallMove = (event: WallEvent) => {
|
|
821
|
+
releaseCommit = () => onWallClick(event)
|
|
822
|
+
has3DPointerDrivenMoveRef.current = true
|
|
823
|
+
if (!cursorGroupRef.current) return
|
|
715
824
|
const ctx = getContext()
|
|
716
825
|
|
|
717
826
|
if (ctx.state.surface !== 'wall') {
|
|
@@ -800,7 +909,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
800
909
|
}
|
|
801
910
|
// Mark parent wall dirty so it rebuilds geometry — only when position changed
|
|
802
911
|
if (result.dirtyNodeId && posChanged) {
|
|
803
|
-
|
|
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
|
+
}
|
|
804
919
|
}
|
|
805
920
|
|
|
806
921
|
// Publish live transform for 2D floorplan
|
|
@@ -884,9 +999,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
884
999
|
const wz = Math.round(buildingLocalPoint.z * 2) / 2
|
|
885
1000
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
886
1001
|
|
|
887
|
-
Object.assign(placementState.current, {
|
|
1002
|
+
Object.assign(placementState.current, {
|
|
1003
|
+
surface: 'floor',
|
|
1004
|
+
surfaceItemId: null,
|
|
1005
|
+
shelfId: null,
|
|
1006
|
+
})
|
|
888
1007
|
gridPosition.current.set(wx, 0, wz)
|
|
889
|
-
|
|
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
|
+
}
|
|
890
1016
|
|
|
891
1017
|
const draft = draftNode.current
|
|
892
1018
|
if (draft) {
|
|
@@ -902,6 +1028,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
902
1028
|
|
|
903
1029
|
const onItemEnter = (event: ItemEvent) => {
|
|
904
1030
|
if (event.node.id === draftNode.current?.id) return
|
|
1031
|
+
has3DPointerDrivenMoveRef.current = true
|
|
905
1032
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
|
906
1033
|
if (!result) return
|
|
907
1034
|
|
|
@@ -918,6 +1045,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
918
1045
|
|
|
919
1046
|
const onItemMove = (event: ItemEvent) => {
|
|
920
1047
|
if (event.node.id === draftNode.current?.id) return
|
|
1048
|
+
releaseCommit = () => onItemClick(event)
|
|
1049
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1050
|
+
if (!cursorGroupRef.current) return
|
|
921
1051
|
const ctx = getContext()
|
|
922
1052
|
|
|
923
1053
|
if (ctx.state.surface !== 'item-surface') {
|
|
@@ -1001,7 +1131,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1001
1131
|
}
|
|
1002
1132
|
|
|
1003
1133
|
const onItemClick = (event: ItemEvent) => {
|
|
1004
|
-
|
|
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
|
+
|
|
1005
1230
|
const result = itemSurfaceStrategy.click(getContext(), event)
|
|
1006
1231
|
if (!result) return
|
|
1007
1232
|
|
|
@@ -1026,6 +1251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1026
1251
|
// ---- Ceiling Handlers ----
|
|
1027
1252
|
|
|
1028
1253
|
const onCeilingEnter = (event: CeilingEvent) => {
|
|
1254
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1029
1255
|
const nodes = useScene.getState().nodes
|
|
1030
1256
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
1031
1257
|
if (!result) return
|
|
@@ -1045,6 +1271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1045
1271
|
}
|
|
1046
1272
|
|
|
1047
1273
|
const onCeilingMove = (event: CeilingEvent) => {
|
|
1274
|
+
releaseCommit = () => onCeilingClick(event)
|
|
1275
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1276
|
+
if (!cursorGroupRef.current) return
|
|
1048
1277
|
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
|
1049
1278
|
const nodes = useScene.getState().nodes
|
|
1050
1279
|
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
@@ -1083,9 +1312,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1083
1312
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1084
1313
|
if (mesh) mesh.position.copy(gridPosition.current)
|
|
1085
1314
|
|
|
1086
|
-
// 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.
|
|
1087
1322
|
useLiveTransforms.getState().set(draft.id, {
|
|
1088
|
-
position:
|
|
1323
|
+
position: [cc.x, cc.y, cc.z],
|
|
1089
1324
|
rotation: cursorGroupRef.current.rotation.y,
|
|
1090
1325
|
})
|
|
1091
1326
|
}
|
|
@@ -1143,9 +1378,112 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1143
1378
|
}
|
|
1144
1379
|
}
|
|
1145
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
|
+
|
|
1146
1482
|
// ---- Keyboard rotation ----
|
|
1147
1483
|
|
|
1148
|
-
|
|
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
|
|
1149
1487
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1150
1488
|
if (event.key === 'Shift') {
|
|
1151
1489
|
shiftFreeRef.current = true
|
|
@@ -1175,7 +1513,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1175
1513
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
1176
1514
|
|
|
1177
1515
|
// Ref + cursor mesh + item mesh — no store update during drag
|
|
1178
|
-
cursorGroupRef.current
|
|
1516
|
+
if (cursorGroupRef.current) {
|
|
1517
|
+
cursorGroupRef.current.rotation.y = newRotationY
|
|
1518
|
+
}
|
|
1179
1519
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1180
1520
|
if (mesh) mesh.rotation.y = newRotationY
|
|
1181
1521
|
|
|
@@ -1189,11 +1529,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1189
1529
|
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
|
1190
1530
|
gridPosition.current.set(x, gridPosition.current.y, z)
|
|
1191
1531
|
draft.position = [x, gridPosition.current.y, z]
|
|
1192
|
-
cursorGroupRef.current
|
|
1193
|
-
|
|
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
|
+
}
|
|
1194
1542
|
if (mesh) {
|
|
1195
1543
|
mesh.position.x = x
|
|
1196
1544
|
mesh.position.z = z
|
|
1545
|
+
if (surface === 'floor') {
|
|
1546
|
+
mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
|
|
1547
|
+
}
|
|
1197
1548
|
}
|
|
1198
1549
|
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
|
1199
1550
|
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
|
@@ -1213,7 +1564,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1213
1564
|
worldSnapped.y,
|
|
1214
1565
|
worldSnapped.z,
|
|
1215
1566
|
)
|
|
1216
|
-
cursorGroupRef.current
|
|
1567
|
+
if (cursorGroupRef.current) {
|
|
1568
|
+
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
1569
|
+
}
|
|
1217
1570
|
if (mesh) mesh.position.set(x, y, z)
|
|
1218
1571
|
}
|
|
1219
1572
|
}
|
|
@@ -1221,13 +1574,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1221
1574
|
// Update live transform for 2D floorplan with post-snap position
|
|
1222
1575
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
1223
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]]
|
|
1224
1587
|
useLiveTransforms.getState().set(draft.id, {
|
|
1225
1588
|
...currentLive,
|
|
1226
|
-
position:
|
|
1227
|
-
cursorGroupRef.current.position.x,
|
|
1228
|
-
cursorGroupRef.current.position.y,
|
|
1229
|
-
cursorGroupRef.current.position.z,
|
|
1230
|
-
] as [number, number, number],
|
|
1589
|
+
position: livePosition,
|
|
1231
1590
|
rotation: newRotationY,
|
|
1232
1591
|
})
|
|
1233
1592
|
}
|
|
@@ -1248,6 +1607,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1248
1607
|
|
|
1249
1608
|
// ---- tool:cancel (Escape / programmatic) ----
|
|
1250
1609
|
const onCancel = () => {
|
|
1610
|
+
useAlignmentGuides.getState().clear()
|
|
1251
1611
|
if (configRef.current.onCancel) {
|
|
1252
1612
|
configRef.current.onCancel()
|
|
1253
1613
|
}
|
|
@@ -1264,21 +1624,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1264
1624
|
window.addEventListener('contextmenu', onContextMenu)
|
|
1265
1625
|
|
|
1266
1626
|
// ---- Bounding box geometry ----
|
|
1627
|
+
// Always derive the wireframe from `asset.dimensions × scale` rather than
|
|
1628
|
+
// the rendered mesh bounds. Asset dimensions describe the item's footprint
|
|
1629
|
+
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
|
|
1630
|
+
// foliage or other visual overhang the snap logic intentionally ignores.
|
|
1267
1631
|
|
|
1268
1632
|
const draft = draftNode.current
|
|
1269
|
-
const
|
|
1633
|
+
const previewBounds = expandBoundsToGrid(
|
|
1270
1634
|
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1271
1635
|
asset.attachTo,
|
|
1272
1636
|
gridSnapStep,
|
|
1273
1637
|
)
|
|
1274
|
-
const previewBounds = draft
|
|
1275
|
-
? expandBoundsToGrid(
|
|
1276
|
-
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
|
1277
|
-
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1278
|
-
asset.attachTo,
|
|
1279
|
-
gridSnapStep,
|
|
1280
|
-
)
|
|
1281
|
-
: fallbackBounds
|
|
1282
1638
|
updatePreviewGeometry(previewBounds)
|
|
1283
1639
|
updateDimensionGuides(previewBounds)
|
|
1284
1640
|
|
|
@@ -1321,11 +1677,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1321
1677
|
emitter.on('ceiling:move', onCeilingMove)
|
|
1322
1678
|
emitter.on('ceiling:click', onCeilingClick)
|
|
1323
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)
|
|
1324
1685
|
|
|
1325
1686
|
return () => {
|
|
1326
1687
|
tearingDown = true
|
|
1327
|
-
|
|
1688
|
+
if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
|
|
1328
1689
|
unsubDraftWatch()
|
|
1690
|
+
useAlignmentGuides.getState().clear()
|
|
1329
1691
|
// Clear live transform for any remaining draft
|
|
1330
1692
|
if (draftNode.current) {
|
|
1331
1693
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
@@ -1346,32 +1708,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1346
1708
|
emitter.off('ceiling:move', onCeilingMove)
|
|
1347
1709
|
emitter.off('ceiling:click', onCeilingClick)
|
|
1348
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)
|
|
1349
1715
|
emitter.off('tool:cancel', onCancel)
|
|
1350
1716
|
window.removeEventListener('keydown', onKeyDown)
|
|
1351
1717
|
window.removeEventListener('keyup', onKeyUp)
|
|
1352
1718
|
window.removeEventListener('contextmenu', onContextMenu)
|
|
1353
1719
|
}
|
|
1354
|
-
}, [
|
|
1720
|
+
}, [
|
|
1721
|
+
asset,
|
|
1722
|
+
canPlaceOnFloor,
|
|
1723
|
+
canPlaceOnWall,
|
|
1724
|
+
canPlaceOnCeiling,
|
|
1725
|
+
draftNode,
|
|
1726
|
+
getFloorVisualPosition,
|
|
1727
|
+
gridSnapStep,
|
|
1728
|
+
updateDimensionGuides,
|
|
1729
|
+
updatePreviewGeometry,
|
|
1730
|
+
])
|
|
1355
1731
|
|
|
1356
1732
|
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
|
1357
1733
|
// box snaps to the new cell size right away.
|
|
1358
1734
|
useEffect(() => {
|
|
1359
1735
|
if (!asset) return
|
|
1360
1736
|
const draft = draftNode.current
|
|
1361
|
-
const
|
|
1737
|
+
const previewBounds = expandBoundsToGrid(
|
|
1362
1738
|
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1363
1739
|
asset.attachTo,
|
|
1364
1740
|
gridSnapStep,
|
|
1365
1741
|
)
|
|
1366
|
-
const meshBounds = draft
|
|
1367
|
-
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
|
|
1368
|
-
: null
|
|
1369
|
-
const previewBounds = meshBounds
|
|
1370
|
-
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
|
|
1371
|
-
: fallbackBounds
|
|
1372
1742
|
updatePreviewGeometry(previewBounds)
|
|
1373
1743
|
updateDimensionGuides(previewBounds)
|
|
1374
|
-
}, [gridSnapStep, asset, draftNode])
|
|
1744
|
+
}, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
|
|
1375
1745
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
1376
1746
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
1377
1747
|
useEffect(() => {
|
|
@@ -1383,24 +1753,59 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1383
1753
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
1384
1754
|
}, [viewerLevelId, draftNode, asset])
|
|
1385
1755
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
+
}
|
|
1390
1767
|
if (!mesh) return
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
+
|
|
1787
|
+
useFrame((_, delta) => {
|
|
1788
|
+
if (!asset) {
|
|
1789
|
+
reconcileDraftRaycast(null)
|
|
1790
|
+
return
|
|
1791
|
+
}
|
|
1792
|
+
if (!draftNode.current) {
|
|
1793
|
+
reconcileDraftRaycast(null)
|
|
1794
|
+
return
|
|
1403
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
|
|
1808
|
+
if (!mesh) return
|
|
1404
1809
|
|
|
1405
1810
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
1406
1811
|
if (asset.attachTo && placementState.current.surface === 'floor') {
|
|
@@ -1419,18 +1824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1419
1824
|
|
|
1420
1825
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
1421
1826
|
if (!asset.attachTo) {
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
draftNode.current.asset.attachTo,
|
|
1430
|
-
),
|
|
1431
|
-
draftNode.current.rotation,
|
|
1432
|
-
)
|
|
1433
|
-
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]
|
|
1434
1834
|
}
|
|
1435
1835
|
}
|
|
1436
1836
|
})
|
|
@@ -1443,7 +1843,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1443
1843
|
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1444
1844
|
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
1445
1845
|
const initialDimensionBounds = expandBoundsToGrid(
|
|
1446
|
-
getFallbackPreviewBounds(initialDraft, config.asset
|
|
1846
|
+
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
|
1447
1847
|
initialAttachTo,
|
|
1448
1848
|
gridSnapStep,
|
|
1449
1849
|
)
|
|
@@ -1456,6 +1856,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1456
1856
|
initialDimensionBounds.dimensions[0],
|
|
1457
1857
|
initialDimensionBounds.dimensions[1],
|
|
1458
1858
|
initialDimensionBounds.dimensions[2],
|
|
1859
|
+
initialDimensionBounds,
|
|
1459
1860
|
],
|
|
1460
1861
|
)
|
|
1461
1862
|
const basePlaneGeometry = useMemo(() => {
|
|
@@ -1490,22 +1891,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1490
1891
|
const measurementContent = (
|
|
1491
1892
|
<>
|
|
1492
1893
|
<lineSegments
|
|
1493
|
-
layers={EDITOR_LAYER}
|
|
1494
1894
|
geometry={initialWidthGuideGeometry}
|
|
1895
|
+
layers={EDITOR_LAYER}
|
|
1495
1896
|
material={measurementMaterial}
|
|
1496
1897
|
ref={measurementWidthRef}
|
|
1497
1898
|
renderOrder={998}
|
|
1498
1899
|
/>
|
|
1499
1900
|
<lineSegments
|
|
1500
|
-
layers={EDITOR_LAYER}
|
|
1501
1901
|
geometry={initialDepthGuideGeometry}
|
|
1902
|
+
layers={EDITOR_LAYER}
|
|
1502
1903
|
material={measurementMaterial}
|
|
1503
1904
|
ref={measurementDepthRef}
|
|
1504
1905
|
renderOrder={998}
|
|
1505
1906
|
/>
|
|
1506
1907
|
<lineSegments
|
|
1507
|
-
layers={EDITOR_LAYER}
|
|
1508
1908
|
geometry={initialHeightGuideGeometry}
|
|
1909
|
+
layers={EDITOR_LAYER}
|
|
1509
1910
|
material={measurementMaterial}
|
|
1510
1911
|
ref={measurementHeightRef}
|
|
1511
1912
|
renderOrder={998}
|