@pascal-app/editor 0.5.1 → 0.7.0
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 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -16,15 +16,19 @@ import {
|
|
|
16
16
|
type WallNode,
|
|
17
17
|
} from '@pascal-app/core'
|
|
18
18
|
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { Html } from '@react-three/drei'
|
|
19
20
|
import { useFrame } from '@react-three/fiber'
|
|
20
|
-
import { useEffect, useRef } from 'react'
|
|
21
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
21
22
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
Box3,
|
|
24
|
+
BufferGeometry,
|
|
24
25
|
Euler,
|
|
26
|
+
Float32BufferAttribute,
|
|
25
27
|
type Group,
|
|
26
28
|
type LineSegments,
|
|
29
|
+
Matrix4,
|
|
27
30
|
type Mesh,
|
|
31
|
+
type Object3D,
|
|
28
32
|
PlaneGeometry,
|
|
29
33
|
Quaternion,
|
|
30
34
|
Vector3,
|
|
@@ -33,7 +37,8 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
|
33
37
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
34
38
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
35
39
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
36
|
-
import
|
|
40
|
+
import useEditor from '../../../store/use-editor'
|
|
41
|
+
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
|
37
42
|
import {
|
|
38
43
|
ceilingStrategy,
|
|
39
44
|
checkCanPlace,
|
|
@@ -46,6 +51,267 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|
|
46
51
|
|
|
47
52
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
48
53
|
|
|
54
|
+
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
55
|
+
if (unit === 'imperial') {
|
|
56
|
+
const feet = value * 3.280_84
|
|
57
|
+
const wholeFeet = Math.floor(feet)
|
|
58
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
59
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
60
|
+
return `${wholeFeet}'${inches}"`
|
|
61
|
+
}
|
|
62
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
63
|
+
}
|
|
64
|
+
|
|
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
|
+
/**
|
|
73
|
+
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
74
|
+
* The wireframe stays centered on the original bounds centre on each axis we
|
|
75
|
+
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
|
|
76
|
+
* `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
|
|
77
|
+
* so the box still sits on the floor / attachment plane.
|
|
78
|
+
*
|
|
79
|
+
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
|
80
|
+
* Wall / wall-side: X and Y expand; Z stays exact.
|
|
81
|
+
*/
|
|
82
|
+
function expandBoundsToGrid(
|
|
83
|
+
bounds: PreviewBounds,
|
|
84
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
85
|
+
step: number,
|
|
86
|
+
): PreviewBounds {
|
|
87
|
+
const [w, h, d] = bounds.dimensions
|
|
88
|
+
const [cx, , cz] = bounds.center
|
|
89
|
+
const onWall = attachTo === 'wall' || attachTo === 'wall-side'
|
|
90
|
+
const expandedW = snapUpToGridStep(w, step)
|
|
91
|
+
const expandedH = onWall ? snapUpToGridStep(h, step) : h
|
|
92
|
+
const expandedD = onWall ? d : snapUpToGridStep(d, step)
|
|
93
|
+
|
|
94
|
+
const minX = cx - expandedW / 2
|
|
95
|
+
const maxX = cx + expandedW / 2
|
|
96
|
+
const minY = bounds.min[1]
|
|
97
|
+
const maxY = minY + expandedH
|
|
98
|
+
|
|
99
|
+
let minZ: number
|
|
100
|
+
let maxZ: number
|
|
101
|
+
let newCz: number
|
|
102
|
+
if (attachTo === 'wall-side') {
|
|
103
|
+
maxZ = 0
|
|
104
|
+
minZ = -expandedD
|
|
105
|
+
newCz = -expandedD / 2
|
|
106
|
+
} else {
|
|
107
|
+
minZ = cz - expandedD / 2
|
|
108
|
+
maxZ = cz + expandedD / 2
|
|
109
|
+
newCz = cz
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
min: [minX, minY, minZ],
|
|
114
|
+
max: [maxX, maxY, maxZ],
|
|
115
|
+
dimensions: [expandedW, expandedH, expandedD],
|
|
116
|
+
center: [cx, (minY + maxY) / 2, newCz],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
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
|
+
function getFallbackPreviewBounds(
|
|
194
|
+
item: import('@pascal-app/core').ItemNode | null,
|
|
195
|
+
asset: AssetInput,
|
|
196
|
+
attachTo: AssetInput['attachTo'],
|
|
197
|
+
): PreviewBounds {
|
|
198
|
+
const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
199
|
+
return {
|
|
200
|
+
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
|
201
|
+
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
|
|
202
|
+
dimensions: dims,
|
|
203
|
+
center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
|
|
208
|
+
const geometry = new BufferGeometry()
|
|
209
|
+
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
|
|
210
|
+
return geometry
|
|
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
|
+
}
|
|
300
|
+
|
|
301
|
+
function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
|
|
302
|
+
const geometry = ref.current?.geometry
|
|
303
|
+
if (!geometry) return
|
|
304
|
+
|
|
305
|
+
const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
|
|
306
|
+
if (!attribute || attribute.array.length !== points.length) {
|
|
307
|
+
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
|
|
308
|
+
} else {
|
|
309
|
+
attribute.set(points)
|
|
310
|
+
attribute.needsUpdate = true
|
|
311
|
+
}
|
|
312
|
+
geometry.computeBoundingSphere()
|
|
313
|
+
}
|
|
314
|
+
|
|
49
315
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
50
316
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
|
51
317
|
const edgeMaterial = new LineBasicNodeMaterial({
|
|
@@ -55,6 +321,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|
|
55
321
|
depthWrite: false,
|
|
56
322
|
})
|
|
57
323
|
|
|
324
|
+
const measurementMaterial = new LineBasicNodeMaterial({
|
|
325
|
+
color: 0x0f_17_2a,
|
|
326
|
+
linewidth: 2,
|
|
327
|
+
depthTest: false,
|
|
328
|
+
depthWrite: false,
|
|
329
|
+
})
|
|
330
|
+
|
|
58
331
|
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
|
59
332
|
color: 0xef_44_44, // red-500 (invalid)
|
|
60
333
|
transparent: true,
|
|
@@ -82,6 +355,9 @@ export interface PlacementCoordinatorConfig {
|
|
|
82
355
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
83
356
|
const cursorGroupRef = useRef<Group>(null!)
|
|
84
357
|
const edgesRef = useRef<LineSegments>(null!)
|
|
358
|
+
const measurementWidthRef = useRef<LineSegments>(null!)
|
|
359
|
+
const measurementDepthRef = useRef<LineSegments>(null!)
|
|
360
|
+
const measurementHeightRef = useRef<LineSegments>(null!)
|
|
85
361
|
const basePlaneRef = useRef<Mesh>(null!)
|
|
86
362
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
87
363
|
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
|
@@ -89,6 +365,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
89
365
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
|
90
366
|
)
|
|
91
367
|
const shiftFreeRef = useRef(false)
|
|
368
|
+
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
369
|
+
const meshPreviewAppliedRef = useRef(false)
|
|
370
|
+
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
92
371
|
|
|
93
372
|
// Store config callbacks in refs to avoid re-running effect when they change
|
|
94
373
|
const configRef = useRef(config)
|
|
@@ -96,10 +375,135 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
96
375
|
|
|
97
376
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
|
98
377
|
const { asset, draftNode } = config
|
|
378
|
+
const unit = useViewer((state) => state.unit)
|
|
379
|
+
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
|
380
|
+
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
|
381
|
+
const [width, height, depth] = bounds.dimensions
|
|
382
|
+
const [centerX, centerY, centerZ] = bounds.center
|
|
383
|
+
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
|
384
|
+
|
|
385
|
+
if (previewBoundsSignatureRef.current === signature) return
|
|
386
|
+
previewBoundsSignatureRef.current = signature
|
|
387
|
+
|
|
388
|
+
const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
|
|
389
|
+
nextBasePlaneGeometry.rotateX(-Math.PI / 2)
|
|
390
|
+
nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
|
|
391
|
+
|
|
392
|
+
updateLineGeometry(edgesRef, getBoxEdgePoints(bounds))
|
|
393
|
+
|
|
394
|
+
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
|
395
|
+
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
|
396
|
+
oldBasePlaneGeometry.dispose()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
|
400
|
+
setDimensionBounds((current) => {
|
|
401
|
+
if (
|
|
402
|
+
current &&
|
|
403
|
+
current.dimensions[0] === bounds.dimensions[0] &&
|
|
404
|
+
current.dimensions[1] === bounds.dimensions[1] &&
|
|
405
|
+
current.dimensions[2] === bounds.dimensions[2] &&
|
|
406
|
+
current.center[0] === bounds.center[0] &&
|
|
407
|
+
current.center[1] === bounds.center[1] &&
|
|
408
|
+
current.center[2] === bounds.center[2]
|
|
409
|
+
) {
|
|
410
|
+
return current
|
|
411
|
+
}
|
|
412
|
+
return bounds
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const [width, , depth] = bounds.dimensions
|
|
416
|
+
const [centerX, , centerZ] = bounds.center
|
|
417
|
+
const minX = centerX - width / 2
|
|
418
|
+
const maxX = centerX + width / 2
|
|
419
|
+
const minZ = centerZ - depth / 2
|
|
420
|
+
const maxZ = centerZ + depth / 2
|
|
421
|
+
const guideOffset = 0.18
|
|
422
|
+
const tick = 0.08
|
|
423
|
+
const y = 0.02
|
|
424
|
+
|
|
425
|
+
const widthPoints = [
|
|
426
|
+
minX,
|
|
427
|
+
y,
|
|
428
|
+
maxZ + guideOffset,
|
|
429
|
+
maxX,
|
|
430
|
+
y,
|
|
431
|
+
maxZ + guideOffset,
|
|
432
|
+
|
|
433
|
+
minX,
|
|
434
|
+
y,
|
|
435
|
+
maxZ + guideOffset - tick,
|
|
436
|
+
minX,
|
|
437
|
+
y,
|
|
438
|
+
maxZ + guideOffset + tick,
|
|
439
|
+
|
|
440
|
+
maxX,
|
|
441
|
+
y,
|
|
442
|
+
maxZ + guideOffset - tick,
|
|
443
|
+
maxX,
|
|
444
|
+
y,
|
|
445
|
+
maxZ + guideOffset + tick,
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
const depthPoints = [
|
|
449
|
+
maxX + guideOffset,
|
|
450
|
+
y,
|
|
451
|
+
minZ,
|
|
452
|
+
maxX + guideOffset,
|
|
453
|
+
y,
|
|
454
|
+
maxZ,
|
|
455
|
+
|
|
456
|
+
maxX + guideOffset - tick,
|
|
457
|
+
y,
|
|
458
|
+
minZ,
|
|
459
|
+
maxX + guideOffset + tick,
|
|
460
|
+
y,
|
|
461
|
+
minZ,
|
|
462
|
+
|
|
463
|
+
maxX + guideOffset - tick,
|
|
464
|
+
y,
|
|
465
|
+
maxZ,
|
|
466
|
+
maxX + guideOffset + tick,
|
|
467
|
+
y,
|
|
468
|
+
maxZ,
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
const heightPoints = [
|
|
472
|
+
minX - guideOffset,
|
|
473
|
+
0,
|
|
474
|
+
minZ,
|
|
475
|
+
minX - guideOffset,
|
|
476
|
+
bounds.dimensions[1],
|
|
477
|
+
minZ,
|
|
478
|
+
|
|
479
|
+
minX - guideOffset - tick,
|
|
480
|
+
0,
|
|
481
|
+
minZ,
|
|
482
|
+
minX - guideOffset + tick,
|
|
483
|
+
0,
|
|
484
|
+
minZ,
|
|
485
|
+
|
|
486
|
+
minX - guideOffset - tick,
|
|
487
|
+
bounds.dimensions[1],
|
|
488
|
+
minZ,
|
|
489
|
+
minX - guideOffset + tick,
|
|
490
|
+
bounds.dimensions[1],
|
|
491
|
+
minZ,
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => {
|
|
495
|
+
updateLineGeometry(ref, points)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
applyPoints(measurementWidthRef, widthPoints)
|
|
499
|
+
applyPoints(measurementDepthRef, depthPoints)
|
|
500
|
+
applyPoints(measurementHeightRef, heightPoints)
|
|
501
|
+
}
|
|
99
502
|
|
|
100
503
|
useEffect(() => {
|
|
101
504
|
if (!asset) return
|
|
102
505
|
useScene.temporal.getState().pause()
|
|
506
|
+
meshPreviewAppliedRef.current = false
|
|
103
507
|
|
|
104
508
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
105
509
|
|
|
@@ -110,6 +514,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
110
514
|
ceilingId: null,
|
|
111
515
|
surfaceItemId: null,
|
|
112
516
|
}
|
|
517
|
+
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
518
|
+
gridPosition.current.y = 0
|
|
519
|
+
cursorGroupRef.current.position.y = 0
|
|
520
|
+
}
|
|
113
521
|
|
|
114
522
|
// ---- Helpers ----
|
|
115
523
|
|
|
@@ -119,6 +527,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
119
527
|
draftItem: draftNode.current,
|
|
120
528
|
gridPosition: gridPosition.current,
|
|
121
529
|
state: { ...placementState.current },
|
|
530
|
+
currentCursorRotationY: cursorGroupRef.current.rotation.y,
|
|
122
531
|
})
|
|
123
532
|
|
|
124
533
|
const getActiveValidators = () =>
|
|
@@ -216,6 +625,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
216
625
|
let previousGridPos: [number, number, number] | null = null
|
|
217
626
|
|
|
218
627
|
const onGridMove = (event: GridEvent) => {
|
|
628
|
+
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
629
|
+
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
630
|
+
configRef.current.initDraft(gridPosition.current)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
219
634
|
const result = floorStrategy.move(getContext(), event)
|
|
220
635
|
if (!result) return
|
|
221
636
|
|
|
@@ -230,10 +645,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
230
645
|
|
|
231
646
|
previousGridPos = [...result.gridPosition]
|
|
232
647
|
gridPosition.current.set(...result.gridPosition)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
648
|
+
cursorGroupRef.current.position.set(
|
|
649
|
+
result.cursorPosition[0],
|
|
650
|
+
result.cursorPosition[1],
|
|
651
|
+
result.cursorPosition[2],
|
|
652
|
+
)
|
|
237
653
|
|
|
238
654
|
const draft = draftNode.current
|
|
239
655
|
if (draft) draft.position = result.gridPosition
|
|
@@ -458,6 +874,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
458
874
|
|
|
459
875
|
// ---- Item Surface Handlers ----
|
|
460
876
|
|
|
877
|
+
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
|
878
|
+
const buildingLocalPoint = worldToBuildingLocal(
|
|
879
|
+
event.position[0],
|
|
880
|
+
event.position[1],
|
|
881
|
+
event.position[2],
|
|
882
|
+
)
|
|
883
|
+
const wx = Math.round(buildingLocalPoint.x * 2) / 2
|
|
884
|
+
const wz = Math.round(buildingLocalPoint.z * 2) / 2
|
|
885
|
+
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
886
|
+
|
|
887
|
+
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
|
888
|
+
gridPosition.current.set(wx, 0, wz)
|
|
889
|
+
cursorGroupRef.current.position.set(wx, 0, wz)
|
|
890
|
+
|
|
891
|
+
const draft = draftNode.current
|
|
892
|
+
if (draft) {
|
|
893
|
+
draft.position = floorPos
|
|
894
|
+
useScene.getState().updateNode(draft.id, {
|
|
895
|
+
parentId: useViewer.getState().selection.levelId as string,
|
|
896
|
+
position: floorPos,
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
revalidate()
|
|
901
|
+
}
|
|
902
|
+
|
|
461
903
|
const onItemEnter = (event: ItemEvent) => {
|
|
462
904
|
if (event.node.id === draftNode.current?.id) return
|
|
463
905
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
|
@@ -491,6 +933,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
491
933
|
return
|
|
492
934
|
}
|
|
493
935
|
|
|
936
|
+
if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) {
|
|
937
|
+
const enterResult = itemSurfaceStrategy.enter(
|
|
938
|
+
{ ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } },
|
|
939
|
+
event,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
event.stopPropagation()
|
|
943
|
+
if (enterResult) {
|
|
944
|
+
applyTransition(enterResult)
|
|
945
|
+
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
946
|
+
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
detachItemSurfaceToFloor(event)
|
|
950
|
+
}
|
|
951
|
+
return
|
|
952
|
+
}
|
|
953
|
+
|
|
494
954
|
if (!draftNode.current) {
|
|
495
955
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
496
956
|
if (!enterResult) return
|
|
@@ -499,13 +959,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
499
959
|
return
|
|
500
960
|
}
|
|
501
961
|
|
|
962
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
502
963
|
const result = itemSurfaceStrategy.move(ctx, event)
|
|
503
964
|
if (!result) return
|
|
504
965
|
|
|
505
966
|
event.stopPropagation()
|
|
506
967
|
|
|
507
968
|
gridPosition.current.set(...result.gridPosition)
|
|
508
|
-
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
509
969
|
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
510
970
|
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
511
971
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
@@ -532,26 +992,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
532
992
|
|
|
533
993
|
event.stopPropagation()
|
|
534
994
|
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
gridPosition.current.set(wx, 0, wz)
|
|
542
|
-
cursorGroupRef.current.position.x = wx
|
|
543
|
-
cursorGroupRef.current.position.z = wz
|
|
544
|
-
|
|
545
|
-
const draft = draftNode.current
|
|
546
|
-
if (draft) {
|
|
547
|
-
draft.position = floorPos
|
|
548
|
-
useScene.getState().updateNode(draft.id, {
|
|
549
|
-
parentId: useViewer.getState().selection.levelId as string,
|
|
550
|
-
position: floorPos,
|
|
551
|
-
})
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
revalidate()
|
|
995
|
+
// `event.localPosition` from useNodeEvents is in the LEAVING item's
|
|
996
|
+
// local space (the sofa/table the draft is detaching from), not
|
|
997
|
+
// building-local. Convert from world via worldToBuildingLocal instead,
|
|
998
|
+
// otherwise the wireframe jumps to a surface-local-coordinate ghost
|
|
999
|
+
// position until the next mouse move.
|
|
1000
|
+
detachItemSurfaceToFloor(event)
|
|
555
1001
|
}
|
|
556
1002
|
|
|
557
1003
|
const onItemClick = (event: ItemEvent) => {
|
|
@@ -609,6 +1055,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
609
1055
|
return
|
|
610
1056
|
}
|
|
611
1057
|
|
|
1058
|
+
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
612
1059
|
const result = ceilingStrategy.move(getContext(), event)
|
|
613
1060
|
if (!result) return
|
|
614
1061
|
|
|
@@ -625,7 +1072,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
625
1072
|
}
|
|
626
1073
|
|
|
627
1074
|
gridPosition.current.set(...result.gridPosition)
|
|
628
|
-
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
629
1075
|
const cc = worldToBuildingLocal(...result.cursorPosition)
|
|
630
1076
|
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
|
|
631
1077
|
|
|
@@ -701,23 +1147,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
701
1147
|
|
|
702
1148
|
const ROTATION_STEP = Math.PI / 2
|
|
703
1149
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
704
|
-
// Don't intercept keys when focus is inside a text input
|
|
705
|
-
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
706
|
-
return
|
|
707
|
-
}
|
|
708
|
-
|
|
709
1150
|
if (event.key === 'Shift') {
|
|
710
1151
|
shiftFreeRef.current = true
|
|
711
1152
|
revalidate()
|
|
712
1153
|
return
|
|
713
1154
|
}
|
|
714
1155
|
|
|
1156
|
+
// Don't intercept keys when focus is inside a text input
|
|
1157
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
|
|
715
1161
|
const draft = draftNode.current
|
|
716
1162
|
if (!draft) return
|
|
717
1163
|
|
|
718
1164
|
let rotationDelta = 0
|
|
719
|
-
if (event.key === 'r' || event.key === 'R')
|
|
720
|
-
|
|
1165
|
+
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
1166
|
+
rotationDelta = ROTATION_STEP
|
|
1167
|
+
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
1168
|
+
rotationDelta = -ROTATION_STEP
|
|
721
1169
|
|
|
722
1170
|
if (rotationDelta !== 0) {
|
|
723
1171
|
event.preventDefault()
|
|
@@ -818,12 +1266,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
818
1266
|
// ---- Bounding box geometry ----
|
|
819
1267
|
|
|
820
1268
|
const draft = draftNode.current
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1269
|
+
const fallbackBounds = expandBoundsToGrid(
|
|
1270
|
+
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1271
|
+
asset.attachTo,
|
|
1272
|
+
gridSnapStep,
|
|
1273
|
+
)
|
|
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
|
+
updatePreviewGeometry(previewBounds)
|
|
1283
|
+
updateDimensionGuides(previewBounds)
|
|
1284
|
+
|
|
1285
|
+
// ---- Undo protection ----
|
|
1286
|
+
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
|
1287
|
+
// include the draft (created while temporal was paused). Re-insert it so the mesh
|
|
1288
|
+
// doesn't disappear mid-placement.
|
|
1289
|
+
// We defer via queueMicrotask to avoid nested setState during the undo callback.
|
|
1290
|
+
// Temporal is already paused during placement, so createNode won't enter the undo stack.
|
|
1291
|
+
let tearingDown = false
|
|
1292
|
+
const unsubDraftWatch = useScene.subscribe((state) => {
|
|
1293
|
+
if (tearingDown) return
|
|
1294
|
+
const draft = draftNode.current
|
|
1295
|
+
if (draft === null) return
|
|
1296
|
+
if (draft.id in state.nodes) return
|
|
1297
|
+
|
|
1298
|
+
queueMicrotask(() => {
|
|
1299
|
+
if (tearingDown) return
|
|
1300
|
+
const draft = draftNode.current
|
|
1301
|
+
if (draft === null) return
|
|
1302
|
+
if (draft.id in useScene.getState().nodes) return
|
|
1303
|
+
// Temporal is paused during placement, createNode won't be tracked
|
|
1304
|
+
useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
|
|
1305
|
+
})
|
|
1306
|
+
})
|
|
827
1307
|
|
|
828
1308
|
// ---- Subscribe ----
|
|
829
1309
|
|
|
@@ -843,6 +1323,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
843
1323
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
844
1324
|
|
|
845
1325
|
return () => {
|
|
1326
|
+
tearingDown = true
|
|
1327
|
+
meshPreviewAppliedRef.current = false
|
|
1328
|
+
unsubDraftWatch()
|
|
846
1329
|
// Clear live transform for any remaining draft
|
|
847
1330
|
if (draftNode.current) {
|
|
848
1331
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
@@ -870,7 +1353,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
870
1353
|
}
|
|
871
1354
|
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
|
872
1355
|
|
|
873
|
-
//
|
|
1356
|
+
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
|
1357
|
+
// box snaps to the new cell size right away.
|
|
1358
|
+
useEffect(() => {
|
|
1359
|
+
if (!asset) return
|
|
1360
|
+
const draft = draftNode.current
|
|
1361
|
+
const fallbackBounds = expandBoundsToGrid(
|
|
1362
|
+
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
|
1363
|
+
asset.attachTo,
|
|
1364
|
+
gridSnapStep,
|
|
1365
|
+
)
|
|
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
|
+
updatePreviewGeometry(previewBounds)
|
|
1373
|
+
updateDimensionGuides(previewBounds)
|
|
1374
|
+
}, [gridSnapStep, asset, draftNode])
|
|
874
1375
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
875
1376
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
876
1377
|
useEffect(() => {
|
|
@@ -887,6 +1388,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
887
1388
|
if (!draftNode.current) return
|
|
888
1389
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
889
1390
|
if (!mesh) return
|
|
1391
|
+
if (!meshPreviewAppliedRef.current) {
|
|
1392
|
+
const previewBounds = getPreviewBoundsFromObject(mesh)
|
|
1393
|
+
if (previewBounds) {
|
|
1394
|
+
const expandedBounds = expandBoundsToGrid(
|
|
1395
|
+
previewBounds,
|
|
1396
|
+
asset.attachTo,
|
|
1397
|
+
useEditor.getState().gridSnapStep,
|
|
1398
|
+
)
|
|
1399
|
+
updatePreviewGeometry(expandedBounds)
|
|
1400
|
+
updateDimensionGuides(expandedBounds)
|
|
1401
|
+
meshPreviewAppliedRef.current = true
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
890
1404
|
|
|
891
1405
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
892
1406
|
if (asset.attachTo && placementState.current.surface === 'floor') {
|
|
@@ -910,36 +1424,162 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
910
1424
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
|
911
1425
|
levelId,
|
|
912
1426
|
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
|
913
|
-
|
|
1427
|
+
getGridAlignedDimensions(
|
|
1428
|
+
getScaledDimensions(draftNode.current),
|
|
1429
|
+
draftNode.current.asset.attachTo,
|
|
1430
|
+
),
|
|
914
1431
|
draftNode.current.rotation,
|
|
915
1432
|
)
|
|
916
1433
|
mesh.position.y = slabElevation
|
|
917
|
-
// Cursor group is at the world root (not inside a level group), so add the
|
|
918
|
-
// level group's current world Y to convert from level-local to world space.
|
|
919
|
-
const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
|
|
920
|
-
cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
|
|
921
1434
|
}
|
|
922
1435
|
}
|
|
923
1436
|
})
|
|
924
1437
|
|
|
925
1438
|
const initialDraft = draftNode.current
|
|
926
|
-
const
|
|
1439
|
+
const initialAttachTo = config.asset?.attachTo
|
|
1440
|
+
const rawDims = initialDraft
|
|
927
1441
|
? getScaledDimensions(initialDraft)
|
|
928
1442
|
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
929
|
-
const
|
|
930
|
-
const wallSideZOffset =
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1443
|
+
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1444
|
+
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
1445
|
+
const initialDimensionBounds = expandBoundsToGrid(
|
|
1446
|
+
getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
|
|
1447
|
+
initialAttachTo,
|
|
1448
|
+
gridSnapStep,
|
|
1449
|
+
)
|
|
1450
|
+
const initialEdgeGeometry = useMemo(
|
|
1451
|
+
() => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)),
|
|
1452
|
+
[
|
|
1453
|
+
initialDimensionBounds.center[0],
|
|
1454
|
+
initialDimensionBounds.center[1],
|
|
1455
|
+
initialDimensionBounds.center[2],
|
|
1456
|
+
initialDimensionBounds.dimensions[0],
|
|
1457
|
+
initialDimensionBounds.dimensions[1],
|
|
1458
|
+
initialDimensionBounds.dimensions[2],
|
|
1459
|
+
],
|
|
1460
|
+
)
|
|
1461
|
+
const basePlaneGeometry = useMemo(() => {
|
|
1462
|
+
const geometry = new PlaneGeometry(dims[0], dims[2])
|
|
1463
|
+
geometry.rotateX(-Math.PI / 2)
|
|
1464
|
+
geometry.translate(0, 0.01, wallSideZOffset)
|
|
1465
|
+
return geometry
|
|
1466
|
+
}, [dims[0], dims[2], wallSideZOffset])
|
|
1467
|
+
const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1468
|
+
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1469
|
+
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1470
|
+
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
|
1471
|
+
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
|
|
1472
|
+
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
|
|
1473
|
+
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
|
|
1474
|
+
const widthLabelPosition: [number, number, number] = [
|
|
1475
|
+
currentDimensionBounds.center[0],
|
|
1476
|
+
0.04,
|
|
1477
|
+
currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24,
|
|
1478
|
+
]
|
|
1479
|
+
const depthLabelPosition: [number, number, number] = [
|
|
1480
|
+
currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24,
|
|
1481
|
+
0.04,
|
|
1482
|
+
currentDimensionBounds.center[2],
|
|
1483
|
+
]
|
|
1484
|
+
const heightLabelPosition: [number, number, number] = [
|
|
1485
|
+
currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24,
|
|
1486
|
+
currentDimensionBounds.dimensions[1] / 2,
|
|
1487
|
+
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
|
|
1488
|
+
]
|
|
1489
|
+
|
|
1490
|
+
const measurementContent = (
|
|
1491
|
+
<>
|
|
1492
|
+
<lineSegments
|
|
1493
|
+
layers={EDITOR_LAYER}
|
|
1494
|
+
geometry={initialWidthGuideGeometry}
|
|
1495
|
+
material={measurementMaterial}
|
|
1496
|
+
ref={measurementWidthRef}
|
|
1497
|
+
renderOrder={998}
|
|
1498
|
+
/>
|
|
1499
|
+
<lineSegments
|
|
1500
|
+
layers={EDITOR_LAYER}
|
|
1501
|
+
geometry={initialDepthGuideGeometry}
|
|
1502
|
+
material={measurementMaterial}
|
|
1503
|
+
ref={measurementDepthRef}
|
|
1504
|
+
renderOrder={998}
|
|
1505
|
+
/>
|
|
1506
|
+
<lineSegments
|
|
1507
|
+
layers={EDITOR_LAYER}
|
|
1508
|
+
geometry={initialHeightGuideGeometry}
|
|
1509
|
+
material={measurementMaterial}
|
|
1510
|
+
ref={measurementHeightRef}
|
|
1511
|
+
renderOrder={998}
|
|
1512
|
+
/>
|
|
1513
|
+
<Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
|
|
1514
|
+
<div
|
|
1515
|
+
style={{
|
|
1516
|
+
background: 'rgba(15, 23, 42, 0.86)',
|
|
1517
|
+
border: '1px solid rgba(15, 23, 42, 0.65)',
|
|
1518
|
+
borderRadius: '999px',
|
|
1519
|
+
color: '#f8fafc',
|
|
1520
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
1521
|
+
fontSize: '11px',
|
|
1522
|
+
fontWeight: 600,
|
|
1523
|
+
lineHeight: 1,
|
|
1524
|
+
padding: '4px 8px',
|
|
1525
|
+
pointerEvents: 'none',
|
|
1526
|
+
whiteSpace: 'nowrap',
|
|
1527
|
+
}}
|
|
1528
|
+
>
|
|
1529
|
+
{widthLabel}
|
|
1530
|
+
</div>
|
|
1531
|
+
</Html>
|
|
1532
|
+
<Html center position={depthLabelPosition} style={{ pointerEvents: 'none' }}>
|
|
1533
|
+
<div
|
|
1534
|
+
style={{
|
|
1535
|
+
background: 'rgba(15, 23, 42, 0.86)',
|
|
1536
|
+
border: '1px solid rgba(15, 23, 42, 0.65)',
|
|
1537
|
+
borderRadius: '999px',
|
|
1538
|
+
color: '#f8fafc',
|
|
1539
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
1540
|
+
fontSize: '11px',
|
|
1541
|
+
fontWeight: 600,
|
|
1542
|
+
lineHeight: 1,
|
|
1543
|
+
padding: '4px 8px',
|
|
1544
|
+
pointerEvents: 'none',
|
|
1545
|
+
whiteSpace: 'nowrap',
|
|
1546
|
+
}}
|
|
1547
|
+
>
|
|
1548
|
+
{depthLabel}
|
|
1549
|
+
</div>
|
|
1550
|
+
</Html>
|
|
1551
|
+
<Html center position={heightLabelPosition} style={{ pointerEvents: 'none' }}>
|
|
1552
|
+
<div
|
|
1553
|
+
style={{
|
|
1554
|
+
background: 'rgba(15, 23, 42, 0.86)',
|
|
1555
|
+
border: '1px solid rgba(15, 23, 42, 0.65)',
|
|
1556
|
+
borderRadius: '999px',
|
|
1557
|
+
color: '#f8fafc',
|
|
1558
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
1559
|
+
fontSize: '11px',
|
|
1560
|
+
fontWeight: 600,
|
|
1561
|
+
lineHeight: 1,
|
|
1562
|
+
padding: '4px 8px',
|
|
1563
|
+
pointerEvents: 'none',
|
|
1564
|
+
whiteSpace: 'nowrap',
|
|
1565
|
+
}}
|
|
1566
|
+
>
|
|
1567
|
+
{heightLabel}
|
|
1568
|
+
</div>
|
|
1569
|
+
</Html>
|
|
1570
|
+
</>
|
|
1571
|
+
)
|
|
937
1572
|
|
|
938
1573
|
return (
|
|
939
1574
|
<group ref={cursorGroupRef}>
|
|
940
|
-
<lineSegments
|
|
941
|
-
|
|
942
|
-
|
|
1575
|
+
<lineSegments
|
|
1576
|
+
geometry={initialEdgeGeometry}
|
|
1577
|
+
layers={EDITOR_LAYER}
|
|
1578
|
+
material={edgeMaterial}
|
|
1579
|
+
ref={edgesRef}
|
|
1580
|
+
renderOrder={999}
|
|
1581
|
+
/>
|
|
1582
|
+
{measurementContent}
|
|
943
1583
|
<mesh
|
|
944
1584
|
geometry={basePlaneGeometry}
|
|
945
1585
|
layers={EDITOR_LAYER}
|