@pascal-app/editor 0.4.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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { useThree } from '@react-three/fiber'
|
|
5
|
+
import { useEffect } from 'react'
|
|
6
|
+
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
7
|
+
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
|
8
|
+
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
|
9
|
+
|
|
10
|
+
export function ExportManager() {
|
|
11
|
+
const scene = useThree((state) => state.scene)
|
|
12
|
+
const setExportScene = useViewer((state) => state.setExportScene)
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
|
16
|
+
// Find the scene renderer group by name
|
|
17
|
+
const sceneGroup = scene.getObjectByName('scene-renderer')
|
|
18
|
+
if (!sceneGroup) {
|
|
19
|
+
console.error('scene-renderer group not found')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const date = new Date().toISOString().split('T')[0]
|
|
24
|
+
|
|
25
|
+
if (format === 'stl') {
|
|
26
|
+
const exporter = new STLExporter()
|
|
27
|
+
const result = exporter.parse(sceneGroup, { binary: true })
|
|
28
|
+
const blob = new Blob([result], { type: 'model/stl' })
|
|
29
|
+
downloadBlob(blob, `model_${date}.stl`)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (format === 'obj') {
|
|
34
|
+
const exporter = new OBJExporter()
|
|
35
|
+
const result = exporter.parse(sceneGroup)
|
|
36
|
+
const blob = new Blob([result], { type: 'model/obj' })
|
|
37
|
+
downloadBlob(blob, `model_${date}.obj`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default: GLB export (existing behavior)
|
|
42
|
+
const exporter = new GLTFExporter()
|
|
43
|
+
|
|
44
|
+
return new Promise<void>((resolve, reject) => {
|
|
45
|
+
exporter.parse(
|
|
46
|
+
sceneGroup,
|
|
47
|
+
(gltf) => {
|
|
48
|
+
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
|
49
|
+
downloadBlob(blob, `model_${date}.glb`)
|
|
50
|
+
resolve()
|
|
51
|
+
},
|
|
52
|
+
(error) => {
|
|
53
|
+
console.error('Export error:', error)
|
|
54
|
+
reject(error)
|
|
55
|
+
},
|
|
56
|
+
{ binary: true },
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setExportScene(exportFn)
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
setExportScene(null)
|
|
65
|
+
}
|
|
66
|
+
}, [scene, setExportScene])
|
|
67
|
+
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function downloadBlob(blob: Blob, filename: string) {
|
|
72
|
+
const url = URL.createObjectURL(blob)
|
|
73
|
+
const link = document.createElement('a')
|
|
74
|
+
link.href = url
|
|
75
|
+
link.download = filename
|
|
76
|
+
link.click()
|
|
77
|
+
URL.revokeObjectURL(url)
|
|
78
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
4
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
5
|
+
import { Euler, Vector3 } from 'three'
|
|
6
|
+
import useEditor from '../../store/use-editor'
|
|
7
|
+
|
|
8
|
+
// Average human eye height in meters
|
|
9
|
+
const EYE_HEIGHT = 1.65
|
|
10
|
+
// Movement speed in meters per second
|
|
11
|
+
const MOVE_SPEED = 5
|
|
12
|
+
// Sprint multiplier when holding Shift
|
|
13
|
+
const SPRINT_MULTIPLIER = 2
|
|
14
|
+
// Vertical float speed in meters per second
|
|
15
|
+
const VERTICAL_SPEED = 3
|
|
16
|
+
// Mouse look sensitivity
|
|
17
|
+
const MOUSE_SENSITIVITY = 0.002
|
|
18
|
+
// Min Y position (eye height above ground)
|
|
19
|
+
const MIN_Y = EYE_HEIGHT
|
|
20
|
+
|
|
21
|
+
// Reusable vectors to avoid allocations in the render loop
|
|
22
|
+
const _forward = new Vector3()
|
|
23
|
+
const _right = new Vector3()
|
|
24
|
+
const _moveVector = new Vector3()
|
|
25
|
+
const _euler = new Euler(0, 0, 0, 'YXZ')
|
|
26
|
+
|
|
27
|
+
export const FirstPersonControls = () => {
|
|
28
|
+
const { camera, gl } = useThree()
|
|
29
|
+
const keysRef = useRef<Set<string>>(new Set())
|
|
30
|
+
const yawRef = useRef(0)
|
|
31
|
+
const pitchRef = useRef(0)
|
|
32
|
+
const isLockedRef = useRef(false)
|
|
33
|
+
const initializedRef = useRef(false)
|
|
34
|
+
|
|
35
|
+
// Initialize camera for first-person view: start at center of scene, on the ground
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (initializedRef.current) return
|
|
38
|
+
initializedRef.current = true
|
|
39
|
+
|
|
40
|
+
// Place camera at the origin (center of grid) at eye height, looking along +X
|
|
41
|
+
camera.position.set(0, EYE_HEIGHT, 0)
|
|
42
|
+
yawRef.current = 0
|
|
43
|
+
pitchRef.current = 0
|
|
44
|
+
}, [camera])
|
|
45
|
+
|
|
46
|
+
// Pointer lock and event handlers
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const canvas = gl.domElement
|
|
49
|
+
|
|
50
|
+
const requestLock = () => {
|
|
51
|
+
if (!isLockedRef.current) {
|
|
52
|
+
canvas.requestPointerLock()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const handlePointerLockChange = () => {
|
|
57
|
+
isLockedRef.current = document.pointerLockElement === canvas
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
61
|
+
if (!isLockedRef.current) return
|
|
62
|
+
|
|
63
|
+
yawRef.current -= e.movementX * MOUSE_SENSITIVITY
|
|
64
|
+
pitchRef.current -= e.movementY * MOUSE_SENSITIVITY
|
|
65
|
+
// Clamp pitch to prevent flipping (almost straight up/down)
|
|
66
|
+
pitchRef.current = Math.max(
|
|
67
|
+
-Math.PI / 2 + 0.05,
|
|
68
|
+
Math.min(Math.PI / 2 - 0.05, pitchRef.current),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
73
|
+
// Skip if user is typing in an input
|
|
74
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const code = e.code
|
|
79
|
+
|
|
80
|
+
// Movement keys
|
|
81
|
+
if (
|
|
82
|
+
code === 'KeyW' ||
|
|
83
|
+
code === 'KeyA' ||
|
|
84
|
+
code === 'KeyS' ||
|
|
85
|
+
code === 'KeyD' ||
|
|
86
|
+
code === 'KeyQ' ||
|
|
87
|
+
code === 'KeyE' ||
|
|
88
|
+
code === 'ShiftLeft' ||
|
|
89
|
+
code === 'ShiftRight'
|
|
90
|
+
) {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
e.stopPropagation()
|
|
93
|
+
keysRef.current.add(code)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ESC exits first-person mode
|
|
97
|
+
if (code === 'Escape') {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
e.stopPropagation()
|
|
100
|
+
if (document.pointerLockElement === canvas) {
|
|
101
|
+
document.exitPointerLock()
|
|
102
|
+
}
|
|
103
|
+
useEditor.getState().setFirstPersonMode(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
108
|
+
keysRef.current.delete(e.code)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
canvas.addEventListener('click', requestLock)
|
|
112
|
+
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
113
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
114
|
+
// Use capture phase so we intercept movement keys before the global keyboard handler
|
|
115
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
116
|
+
document.addEventListener('keyup', handleKeyUp)
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
canvas.removeEventListener('click', requestLock)
|
|
120
|
+
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
121
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
122
|
+
document.removeEventListener('keydown', handleKeyDown, true)
|
|
123
|
+
document.removeEventListener('keyup', handleKeyUp)
|
|
124
|
+
if (document.pointerLockElement === canvas) {
|
|
125
|
+
document.exitPointerLock()
|
|
126
|
+
}
|
|
127
|
+
keysRef.current.clear()
|
|
128
|
+
}
|
|
129
|
+
}, [gl])
|
|
130
|
+
|
|
131
|
+
// Per-frame movement and camera rotation
|
|
132
|
+
useFrame((_, delta) => {
|
|
133
|
+
// Clamp delta to avoid huge jumps (e.g. tab switching)
|
|
134
|
+
const dt = Math.min(delta, 0.1)
|
|
135
|
+
const keys = keysRef.current
|
|
136
|
+
|
|
137
|
+
const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
|
|
138
|
+
const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
|
|
139
|
+
|
|
140
|
+
// Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
|
|
141
|
+
_forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
|
|
142
|
+
_right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
|
|
143
|
+
|
|
144
|
+
_moveVector.set(0, 0, 0)
|
|
145
|
+
|
|
146
|
+
if (keys.has('KeyW')) _moveVector.add(_forward)
|
|
147
|
+
if (keys.has('KeyS')) _moveVector.sub(_forward)
|
|
148
|
+
if (keys.has('KeyA')) _moveVector.sub(_right)
|
|
149
|
+
if (keys.has('KeyD')) _moveVector.add(_right)
|
|
150
|
+
|
|
151
|
+
// Normalize diagonal movement so it's not faster
|
|
152
|
+
if (_moveVector.lengthSq() > 0) {
|
|
153
|
+
_moveVector.normalize().multiplyScalar(speed * dt)
|
|
154
|
+
camera.position.add(_moveVector)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Vertical movement (Q = up, E = down)
|
|
158
|
+
if (keys.has('KeyQ')) {
|
|
159
|
+
camera.position.y += VERTICAL_SPEED * dt
|
|
160
|
+
}
|
|
161
|
+
if (keys.has('KeyE')) {
|
|
162
|
+
camera.position.y -= VERTICAL_SPEED * dt
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Clamp Y so camera never goes below ground level + eye height
|
|
166
|
+
if (camera.position.y < MIN_Y) {
|
|
167
|
+
camera.position.y = MIN_Y
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Apply look rotation
|
|
171
|
+
_euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
|
172
|
+
camera.quaternion.setFromEuler(_euler)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
|
|
180
|
+
* Rendered as a regular DOM overlay (not inside the Canvas).
|
|
181
|
+
*/
|
|
182
|
+
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
183
|
+
const handleExit = useCallback(() => {
|
|
184
|
+
if (document.pointerLockElement) {
|
|
185
|
+
document.exitPointerLock()
|
|
186
|
+
}
|
|
187
|
+
onExit()
|
|
188
|
+
}, [onExit])
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<>
|
|
192
|
+
{/* Crosshair */}
|
|
193
|
+
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
|
|
194
|
+
<div className="relative h-6 w-6">
|
|
195
|
+
<div className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60" />
|
|
196
|
+
<div className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Exit button — top-right */}
|
|
201
|
+
<div className="fixed top-4 right-4 z-50">
|
|
202
|
+
<button
|
|
203
|
+
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
|
|
204
|
+
onClick={handleExit}
|
|
205
|
+
type="button"
|
|
206
|
+
>
|
|
207
|
+
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
208
|
+
ESC
|
|
209
|
+
</kbd>
|
|
210
|
+
Exit Street View
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Controls hint — bottom-center */}
|
|
215
|
+
<div className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2">
|
|
216
|
+
<div className="flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 shadow-lg backdrop-blur-xl">
|
|
217
|
+
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
|
|
218
|
+
<div className="h-5 w-px bg-border/30" />
|
|
219
|
+
<ControlHint label="Up" keys={['Q']} />
|
|
220
|
+
<ControlHint label="Down" keys={['E']} />
|
|
221
|
+
<div className="h-5 w-px bg-border/30" />
|
|
222
|
+
<ControlHint label="Sprint" keys={['Shift']} />
|
|
223
|
+
<div className="h-5 w-px bg-border/30" />
|
|
224
|
+
<span className="text-muted-foreground/60 text-xs">Click to look around</span>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex flex-col items-center gap-1.5">
|
|
234
|
+
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
|
235
|
+
{label}
|
|
236
|
+
</span>
|
|
237
|
+
<div className="flex items-center gap-1">
|
|
238
|
+
{keys.map((key) => (
|
|
239
|
+
<kbd
|
|
240
|
+
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
|
|
241
|
+
key={key}
|
|
242
|
+
>
|
|
243
|
+
{key}
|
|
244
|
+
</kbd>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
DoorNode,
|
|
7
|
+
ItemNode,
|
|
8
|
+
RoofNode,
|
|
9
|
+
RoofSegmentNode,
|
|
10
|
+
StairNode,
|
|
11
|
+
StairSegmentNode,
|
|
12
|
+
sceneRegistry,
|
|
13
|
+
useScene,
|
|
14
|
+
WindowNode,
|
|
15
|
+
} from '@pascal-app/core'
|
|
16
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
17
|
+
import { Html } from '@react-three/drei'
|
|
18
|
+
import { useFrame } from '@react-three/fiber'
|
|
19
|
+
import { useCallback, useRef } from 'react'
|
|
20
|
+
import * as THREE from 'three'
|
|
21
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
22
|
+
import useEditor from '../../store/use-editor'
|
|
23
|
+
import { NodeActionMenu } from './node-action-menu'
|
|
24
|
+
|
|
25
|
+
const ALLOWED_TYPES = [
|
|
26
|
+
'item',
|
|
27
|
+
'door',
|
|
28
|
+
'window',
|
|
29
|
+
'roof',
|
|
30
|
+
'roof-segment',
|
|
31
|
+
'stair',
|
|
32
|
+
'stair-segment',
|
|
33
|
+
'wall',
|
|
34
|
+
'slab',
|
|
35
|
+
]
|
|
36
|
+
const DELETE_ONLY_TYPES = ['wall', 'slab']
|
|
37
|
+
|
|
38
|
+
export function FloatingActionMenu() {
|
|
39
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
40
|
+
const nodes = useScene((s) => s.nodes)
|
|
41
|
+
const mode = useEditor((s) => s.mode)
|
|
42
|
+
const setMode = useEditor((s) => s.setMode)
|
|
43
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
44
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
45
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
46
|
+
|
|
47
|
+
const groupRef = useRef<THREE.Group>(null)
|
|
48
|
+
|
|
49
|
+
// Only show for single selection of specific types
|
|
50
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
51
|
+
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
|
|
52
|
+
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
|
53
|
+
|
|
54
|
+
useFrame(() => {
|
|
55
|
+
if (!(selectedId && isValidType && groupRef.current)) return
|
|
56
|
+
|
|
57
|
+
const obj = sceneRegistry.nodes.get(selectedId)
|
|
58
|
+
if (obj) {
|
|
59
|
+
// Calculate bounding box in world space
|
|
60
|
+
const box = new THREE.Box3().setFromObject(obj)
|
|
61
|
+
if (!box.isEmpty()) {
|
|
62
|
+
const center = box.getCenter(new THREE.Vector3())
|
|
63
|
+
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
|
|
64
|
+
const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
|
|
65
|
+
const yOffset = isDeleteOnly ? 0.8 : 0.3
|
|
66
|
+
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const handleMove = useCallback(
|
|
72
|
+
(e: React.MouseEvent) => {
|
|
73
|
+
e.stopPropagation()
|
|
74
|
+
if (!node) return
|
|
75
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
76
|
+
if (
|
|
77
|
+
node.type === 'item' ||
|
|
78
|
+
node.type === 'window' ||
|
|
79
|
+
node.type === 'door' ||
|
|
80
|
+
node.type === 'roof' ||
|
|
81
|
+
node.type === 'roof-segment' ||
|
|
82
|
+
node.type === 'stair' ||
|
|
83
|
+
node.type === 'stair-segment'
|
|
84
|
+
) {
|
|
85
|
+
setMovingNode(node as any)
|
|
86
|
+
}
|
|
87
|
+
setSelection({ selectedIds: [] })
|
|
88
|
+
},
|
|
89
|
+
[node, setMovingNode, setSelection],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const handleDuplicate = useCallback(
|
|
93
|
+
(e: React.MouseEvent) => {
|
|
94
|
+
e.stopPropagation()
|
|
95
|
+
if (!node?.parentId) return
|
|
96
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
97
|
+
useScene.temporal.getState().pause()
|
|
98
|
+
|
|
99
|
+
let duplicateInfo = structuredClone(node) as any
|
|
100
|
+
delete duplicateInfo.id
|
|
101
|
+
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
102
|
+
|
|
103
|
+
let duplicate: AnyNode | null = null
|
|
104
|
+
try {
|
|
105
|
+
if (node.type === 'door') {
|
|
106
|
+
duplicate = DoorNode.parse(duplicateInfo)
|
|
107
|
+
} else if (node.type === 'window') {
|
|
108
|
+
duplicate = WindowNode.parse(duplicateInfo)
|
|
109
|
+
} else if (node.type === 'item') {
|
|
110
|
+
duplicate = ItemNode.parse(duplicateInfo)
|
|
111
|
+
} else if (node.type === 'roof') {
|
|
112
|
+
duplicate = RoofNode.parse(duplicateInfo)
|
|
113
|
+
} else if (node.type === 'roof-segment') {
|
|
114
|
+
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
|
115
|
+
} else if (node.type === 'stair') {
|
|
116
|
+
duplicate = StairNode.parse(duplicateInfo)
|
|
117
|
+
} else if (node.type === 'stair-segment') {
|
|
118
|
+
duplicate = StairSegmentNode.parse(duplicateInfo)
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('Failed to parse duplicate', error)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (duplicate) {
|
|
126
|
+
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
|
127
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
128
|
+
} else if (
|
|
129
|
+
duplicate.type === 'roof' ||
|
|
130
|
+
duplicate.type === 'roof-segment' ||
|
|
131
|
+
duplicate.type === 'stair' ||
|
|
132
|
+
duplicate.type === 'stair-segment'
|
|
133
|
+
) {
|
|
134
|
+
// Add small offset to make it visible
|
|
135
|
+
if ('position' in duplicate) {
|
|
136
|
+
duplicate.position = [
|
|
137
|
+
duplicate.position[0] + 1,
|
|
138
|
+
duplicate.position[1],
|
|
139
|
+
duplicate.position[2] + 1,
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
143
|
+
|
|
144
|
+
// Duplicate children for roof nodes
|
|
145
|
+
if (node.type === 'roof' && node.children) {
|
|
146
|
+
const nodesState = useScene.getState().nodes
|
|
147
|
+
for (const childId of node.children) {
|
|
148
|
+
const childNode = nodesState[childId]
|
|
149
|
+
if (childNode && childNode.type === 'roof-segment') {
|
|
150
|
+
let childDuplicateInfo = structuredClone(childNode) as any
|
|
151
|
+
delete childDuplicateInfo.id
|
|
152
|
+
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
|
153
|
+
try {
|
|
154
|
+
const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)
|
|
155
|
+
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error('Failed to duplicate roof segment', e)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Duplicate children for stair nodes
|
|
164
|
+
if (node.type === 'stair' && node.children) {
|
|
165
|
+
const nodesState = useScene.getState().nodes
|
|
166
|
+
for (const childId of node.children) {
|
|
167
|
+
const childNode = nodesState[childId]
|
|
168
|
+
if (childNode && childNode.type === 'stair-segment') {
|
|
169
|
+
let childDuplicateInfo = structuredClone(childNode) as any
|
|
170
|
+
delete childDuplicateInfo.id
|
|
171
|
+
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
|
172
|
+
try {
|
|
173
|
+
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
|
|
174
|
+
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error('Failed to duplicate stair segment', e)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
duplicate.type === 'item' ||
|
|
184
|
+
duplicate.type === 'window' ||
|
|
185
|
+
duplicate.type === 'door' ||
|
|
186
|
+
duplicate.type === 'roof' ||
|
|
187
|
+
duplicate.type === 'roof-segment' ||
|
|
188
|
+
duplicate.type === 'stair' ||
|
|
189
|
+
duplicate.type === 'stair-segment'
|
|
190
|
+
) {
|
|
191
|
+
setMovingNode(duplicate as any)
|
|
192
|
+
}
|
|
193
|
+
setSelection({ selectedIds: [] })
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[node, setMovingNode, setSelection],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const handleDelete = useCallback(
|
|
200
|
+
(e: React.MouseEvent) => {
|
|
201
|
+
e.stopPropagation()
|
|
202
|
+
// Activate delete mode (sledgehammer tool) instead of deleting directly
|
|
203
|
+
setSelection({ selectedIds: [] })
|
|
204
|
+
setMode('delete')
|
|
205
|
+
},
|
|
206
|
+
[setSelection, setMode],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<group ref={groupRef}>
|
|
213
|
+
<Html
|
|
214
|
+
center
|
|
215
|
+
style={{
|
|
216
|
+
pointerEvents: 'auto',
|
|
217
|
+
touchAction: 'none',
|
|
218
|
+
}}
|
|
219
|
+
zIndexRange={[100, 0]}
|
|
220
|
+
>
|
|
221
|
+
<NodeActionMenu
|
|
222
|
+
onDelete={handleDelete}
|
|
223
|
+
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
|
|
224
|
+
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
|
225
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
226
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
227
|
+
/>
|
|
228
|
+
</Html>
|
|
229
|
+
</group>
|
|
230
|
+
)
|
|
231
|
+
}
|