@pascal-app/editor 0.6.0 → 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 +9 -5
- 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 +20 -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 +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- 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 +267 -36
- 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/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- 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 +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- 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 +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- 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 +1116 -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 +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- 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 +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- 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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- 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/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/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getGarageVisibleOpeningRatio,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type DoorNode,
|
|
5
|
+
isOperationDoorType,
|
|
6
|
+
sceneRegistry,
|
|
7
|
+
useInteractive,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import * as THREE from 'three'
|
|
11
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
12
|
+
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
|
13
|
+
|
|
14
|
+
const COLLIDER_NODE_TYPES = [
|
|
15
|
+
'wall',
|
|
16
|
+
'fence',
|
|
17
|
+
'slab',
|
|
18
|
+
'stair',
|
|
19
|
+
'stair-segment',
|
|
20
|
+
'roof',
|
|
21
|
+
'roof-segment',
|
|
22
|
+
'door',
|
|
23
|
+
'window',
|
|
24
|
+
'item',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
|
|
28
|
+
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
|
|
29
|
+
const DOWN = new THREE.Vector3(0, -1, 0)
|
|
30
|
+
const UP = new THREE.Vector3(0, 1, 0)
|
|
31
|
+
const SPAWN_EYE_HEIGHT = 1.65
|
|
32
|
+
const RAYCAST_CLEARANCE = 25
|
|
33
|
+
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
|
|
34
|
+
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
|
|
35
|
+
|
|
36
|
+
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
|
37
|
+
|
|
38
|
+
export type FirstPersonColliderWorld = {
|
|
39
|
+
mesh: THREE.Mesh
|
|
40
|
+
bounds: THREE.Box3 | null
|
|
41
|
+
dispose: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FirstPersonSpawn = {
|
|
45
|
+
position: [number, number, number]
|
|
46
|
+
yaw: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
|
|
50
|
+
|
|
51
|
+
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
|
52
|
+
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
|
|
56
|
+
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cloneWorldGeometry(mesh: THREE.Mesh) {
|
|
60
|
+
const sourceGeometry = mesh.geometry
|
|
61
|
+
const position = sourceGeometry.getAttribute('position')
|
|
62
|
+
if (!position || position.count < 3) return null
|
|
63
|
+
|
|
64
|
+
const workingGeometry = sourceGeometry.index
|
|
65
|
+
? sourceGeometry.toNonIndexed()
|
|
66
|
+
: sourceGeometry.clone()
|
|
67
|
+
const cleanGeometry = new THREE.BufferGeometry()
|
|
68
|
+
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
|
|
69
|
+
|
|
70
|
+
const normal = workingGeometry.getAttribute('normal')
|
|
71
|
+
if (normal) {
|
|
72
|
+
cleanGeometry.setAttribute('normal', normal.clone())
|
|
73
|
+
} else {
|
|
74
|
+
cleanGeometry.computeVertexNormals()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cleanGeometry.applyMatrix4(mesh.matrixWorld)
|
|
78
|
+
workingGeometry.dispose()
|
|
79
|
+
|
|
80
|
+
const worldPosition = cleanGeometry.getAttribute('position')
|
|
81
|
+
if (!worldPosition || worldPosition.count < 3) {
|
|
82
|
+
cleanGeometry.dispose()
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return cleanGeometry
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
|
|
90
|
+
if (type === 'window') {
|
|
91
|
+
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
92
|
+
return node?.type === 'window' && node.openingKind === 'opening'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (type !== 'door') return false
|
|
96
|
+
|
|
97
|
+
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
98
|
+
if (!node || node.type !== 'door') return false
|
|
99
|
+
|
|
100
|
+
if (node.openingKind === 'opening') return true
|
|
101
|
+
|
|
102
|
+
if (!node.segments.length) return true
|
|
103
|
+
|
|
104
|
+
return node.segments.every((segment) => segment.type === 'empty')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
|
108
|
+
const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
|
|
109
|
+
if (!hasLeafContent) return null
|
|
110
|
+
|
|
111
|
+
const leafW = node.width - 2 * node.frameThickness
|
|
112
|
+
const leafH = node.height - node.frameThickness
|
|
113
|
+
if (leafW <= 0 || leafH <= 0) return null
|
|
114
|
+
|
|
115
|
+
const leafCenterY = -node.frameThickness / 2
|
|
116
|
+
const runtimeDoorState = useInteractive.getState().doors[node.id]
|
|
117
|
+
const operationState = runtimeDoorState?.operationState ?? node.operationState
|
|
118
|
+
const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle
|
|
119
|
+
|
|
120
|
+
root.updateWorldMatrix(true, false)
|
|
121
|
+
|
|
122
|
+
if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
|
|
123
|
+
const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState)
|
|
124
|
+
const visibleHeight = leafH * (1 - openAmount)
|
|
125
|
+
if (visibleHeight <= 0.12) return null
|
|
126
|
+
|
|
127
|
+
const sourceGeometry = new THREE.BoxGeometry(
|
|
128
|
+
leafW,
|
|
129
|
+
visibleHeight,
|
|
130
|
+
DOOR_LEAF_COLLIDER_DEPTH,
|
|
131
|
+
).toNonIndexed()
|
|
132
|
+
const geometry = new THREE.BufferGeometry()
|
|
133
|
+
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
|
134
|
+
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
|
135
|
+
sourceGeometry.dispose()
|
|
136
|
+
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
|
|
137
|
+
geometry.applyMatrix4(
|
|
138
|
+
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
|
|
139
|
+
)
|
|
140
|
+
return geometry
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
isOperationDoorType(node.doorType) &&
|
|
145
|
+
(operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD
|
|
146
|
+
) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
|
151
|
+
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
|
|
152
|
+
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
|
|
153
|
+
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
|
|
154
|
+
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
|
155
|
+
|
|
156
|
+
const sourceGeometry = new THREE.BoxGeometry(
|
|
157
|
+
leafW,
|
|
158
|
+
leafH,
|
|
159
|
+
DOOR_LEAF_COLLIDER_DEPTH,
|
|
160
|
+
).toNonIndexed()
|
|
161
|
+
const geometry = new THREE.BufferGeometry()
|
|
162
|
+
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
|
163
|
+
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
|
164
|
+
sourceGeometry.dispose()
|
|
165
|
+
const matrix = root.matrixWorld
|
|
166
|
+
.clone()
|
|
167
|
+
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
|
|
168
|
+
.multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
|
|
169
|
+
.multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
|
|
170
|
+
|
|
171
|
+
geometry.applyMatrix4(matrix)
|
|
172
|
+
return geometry
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildRegisteredNodeTypeLookup() {
|
|
176
|
+
const nodeTypes = new Map<string, ColliderNodeType>()
|
|
177
|
+
|
|
178
|
+
for (const type of COLLIDER_NODE_TYPES) {
|
|
179
|
+
for (const nodeId of sceneRegistry.byType[type]) {
|
|
180
|
+
nodeTypes.set(nodeId, type)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return nodeTypes
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function collectColliderGeometriesFromNode(
|
|
188
|
+
root: THREE.Object3D,
|
|
189
|
+
rootNodeId: string,
|
|
190
|
+
visitedMeshes: WeakSet<THREE.Object3D>,
|
|
191
|
+
registeredObjectIds: Map<THREE.Object3D, string>,
|
|
192
|
+
registeredNodeTypes: Map<string, ColliderNodeType>,
|
|
193
|
+
): THREE.BufferGeometry[] {
|
|
194
|
+
const geometries: THREE.BufferGeometry[] = []
|
|
195
|
+
|
|
196
|
+
const visit = (object: THREE.Object3D) => {
|
|
197
|
+
if (visitedMeshes.has(object)) return
|
|
198
|
+
visitedMeshes.add(object)
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
isMesh(object) &&
|
|
202
|
+
object.visible &&
|
|
203
|
+
isColliderMaterialVisible(object.material) &&
|
|
204
|
+
!SKIPPED_MESH_NAMES.has(object.name)
|
|
205
|
+
) {
|
|
206
|
+
const geometry = cloneWorldGeometry(object)
|
|
207
|
+
if (geometry) {
|
|
208
|
+
geometries.push(geometry)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const child of object.children) {
|
|
213
|
+
const childNodeId = registeredObjectIds.get(child)
|
|
214
|
+
if (childNodeId && childNodeId !== rootNodeId) {
|
|
215
|
+
const childType = registeredNodeTypes.get(childNodeId)
|
|
216
|
+
if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
visit(child)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
visit(root)
|
|
226
|
+
|
|
227
|
+
return geometries
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
|
|
231
|
+
const geometries: THREE.BufferGeometry[] = []
|
|
232
|
+
const visitedMeshes = new WeakSet<THREE.Object3D>()
|
|
233
|
+
const registeredNodeTypes = buildRegisteredNodeTypeLookup()
|
|
234
|
+
const registeredObjectIds = new Map<THREE.Object3D, string>()
|
|
235
|
+
|
|
236
|
+
for (const [nodeId, object] of sceneRegistry.nodes) {
|
|
237
|
+
registeredObjectIds.set(object, nodeId)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const type of COLLIDER_NODE_TYPES) {
|
|
241
|
+
for (const nodeId of sceneRegistry.byType[type]) {
|
|
242
|
+
if (shouldSkipColliderNode(nodeId, type)) continue
|
|
243
|
+
|
|
244
|
+
const root = sceneRegistry.nodes.get(nodeId)
|
|
245
|
+
if (!root) continue
|
|
246
|
+
|
|
247
|
+
if (type === 'door') {
|
|
248
|
+
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
249
|
+
if (node?.type !== 'door') continue
|
|
250
|
+
|
|
251
|
+
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
|
252
|
+
if (doorGeometry) {
|
|
253
|
+
geometries.push(doorGeometry)
|
|
254
|
+
}
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
root.updateMatrixWorld(true)
|
|
259
|
+
geometries.push(
|
|
260
|
+
...collectColliderGeometriesFromNode(
|
|
261
|
+
root,
|
|
262
|
+
nodeId,
|
|
263
|
+
visitedMeshes,
|
|
264
|
+
registeredObjectIds,
|
|
265
|
+
registeredNodeTypes,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (geometries.length === 0) {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const mergedGeometry = mergeGeometries(geometries, false)
|
|
276
|
+
geometries.forEach((geometry) => {
|
|
277
|
+
geometry.dispose()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
|
|
281
|
+
mergedGeometry?.dispose()
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const bvhGeometry = mergedGeometry as THREE.BufferGeometry & {
|
|
286
|
+
computeBoundsTree?: typeof computeBoundsTree
|
|
287
|
+
disposeBoundsTree?: typeof disposeBoundsTree
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
|
291
|
+
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
|
292
|
+
bvhGeometry.computeBoundsTree?.({
|
|
293
|
+
maxLeafTris: 12,
|
|
294
|
+
strategy: 0,
|
|
295
|
+
} as never)
|
|
296
|
+
bvhGeometry.computeBoundingBox()
|
|
297
|
+
|
|
298
|
+
const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL)
|
|
299
|
+
mesh.raycast = acceleratedRaycast
|
|
300
|
+
mesh.visible = true
|
|
301
|
+
mesh.userData = {
|
|
302
|
+
type: 'STATIC',
|
|
303
|
+
friction: 0.8,
|
|
304
|
+
restitution: 0.05,
|
|
305
|
+
excludeFloatHit: false,
|
|
306
|
+
excludeCollisionCheck: false,
|
|
307
|
+
}
|
|
308
|
+
mesh.updateMatrixWorld(true)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
mesh,
|
|
312
|
+
bounds: bvhGeometry.boundingBox?.clone() ?? null,
|
|
313
|
+
dispose: () => {
|
|
314
|
+
bvhGeometry.disposeBoundsTree?.()
|
|
315
|
+
bvhGeometry.dispose()
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function deriveFirstPersonSpawn(
|
|
321
|
+
camera: THREE.Camera,
|
|
322
|
+
world: FirstPersonColliderWorld,
|
|
323
|
+
): FirstPersonSpawn {
|
|
324
|
+
const direction = new THREE.Vector3()
|
|
325
|
+
camera.getWorldDirection(direction)
|
|
326
|
+
direction.y = 0
|
|
327
|
+
if (direction.lengthSq() < 1e-6) {
|
|
328
|
+
direction.set(0, 0, -1)
|
|
329
|
+
} else {
|
|
330
|
+
direction.normalize()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const yaw = Math.atan2(-direction.x, -direction.z)
|
|
334
|
+
const raycaster = new THREE.Raycaster()
|
|
335
|
+
const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]]
|
|
336
|
+
|
|
337
|
+
const boundsCenter = world.bounds?.getCenter(new THREE.Vector3())
|
|
338
|
+
if (boundsCenter) {
|
|
339
|
+
candidates.push([boundsCenter.x, boundsCenter.z])
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const [x, z] of candidates) {
|
|
343
|
+
const topY =
|
|
344
|
+
Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
|
|
345
|
+
raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
|
|
346
|
+
const intersections = raycaster.intersectObject(world.mesh, false)
|
|
347
|
+
const hit = intersections.find((intersection) => {
|
|
348
|
+
if (!intersection.face) return true
|
|
349
|
+
const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld)
|
|
350
|
+
return normal.dot(UP) > 0.2
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
if (hit) {
|
|
354
|
+
return {
|
|
355
|
+
position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
|
|
356
|
+
yaw,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
|
|
363
|
+
yaw,
|
|
364
|
+
}
|
|
365
|
+
}
|