@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.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. 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
+ }