@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,438 @@
1
+ import { emitter, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { TreeView, VisualJson } from '@visual-json/react'
4
+ import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
5
+ import {
6
+ type KeyboardEvent,
7
+ type SyntheticEvent,
8
+ useCallback,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
13
+ import { Button } from './../../../../../components/ui/primitives/button'
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogTitle,
18
+ DialogTrigger,
19
+ } from './../../../../../components/ui/primitives/dialog'
20
+ import { Switch } from './../../../../../components/ui/primitives/switch'
21
+ import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
22
+ import { AudioSettingsDialog } from './audio-settings-dialog'
23
+ import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
24
+
25
+ type SceneNode = Record<string, unknown> & {
26
+ id?: unknown
27
+ type?: unknown
28
+ name?: unknown
29
+ parentId?: unknown
30
+ children?: unknown
31
+ }
32
+
33
+ type SceneGraphNode = {
34
+ id: string
35
+ type: string
36
+ name: string | null
37
+ parentId: string | null
38
+ children: SceneGraphNode[]
39
+ missing?: true
40
+ cycle?: true
41
+ }
42
+
43
+ type SceneGraphValue = {
44
+ roots: SceneGraphNode[]
45
+ detachedNodes?: SceneGraphNode[]
46
+ }
47
+
48
+ const isSceneNode = (value: unknown): value is SceneNode => {
49
+ return (
50
+ typeof value === 'object' &&
51
+ value !== null &&
52
+ 'id' in value &&
53
+ typeof (value as { id: unknown }).id === 'string'
54
+ )
55
+ }
56
+
57
+ const getChildIdsFromNode = (node: SceneNode): string[] => {
58
+ if (!Array.isArray(node.children)) {
59
+ return []
60
+ }
61
+
62
+ const childIds = new Set<string>()
63
+
64
+ for (const child of node.children) {
65
+ if (typeof child === 'string') {
66
+ childIds.add(child)
67
+ continue
68
+ }
69
+
70
+ if (isSceneNode(child)) {
71
+ childIds.add(child.id as string)
72
+ }
73
+ }
74
+
75
+ return Array.from(childIds)
76
+ }
77
+
78
+ const buildSceneGraphValue = (
79
+ nodes: Record<string, SceneNode>,
80
+ rootNodeIds: string[],
81
+ ): SceneGraphValue => {
82
+ const childIdsByParent = new Map<string, Set<string>>()
83
+
84
+ for (const [id, node] of Object.entries(nodes)) {
85
+ const childIds = getChildIdsFromNode(node)
86
+ if (childIds.length > 0) {
87
+ childIdsByParent.set(id, new Set(childIds))
88
+ }
89
+ }
90
+
91
+ for (const [id, node] of Object.entries(nodes)) {
92
+ if (typeof node.parentId !== 'string') {
93
+ continue
94
+ }
95
+
96
+ const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>()
97
+ siblings.add(id)
98
+ childIdsByParent.set(node.parentId, siblings)
99
+ }
100
+
101
+ const visited = new Set<string>()
102
+
103
+ const buildNode = (id: string, path: Set<string>): SceneGraphNode => {
104
+ const node = nodes[id]
105
+ if (!node) {
106
+ return {
107
+ id,
108
+ type: 'missing',
109
+ name: null,
110
+ parentId: null,
111
+ missing: true,
112
+ children: [],
113
+ }
114
+ }
115
+
116
+ const nodeType = typeof node.type === 'string' ? node.type : 'unknown'
117
+ const nodeName = typeof node.name === 'string' ? node.name : null
118
+ const parentId = typeof node.parentId === 'string' ? node.parentId : null
119
+
120
+ if (path.has(id)) {
121
+ return {
122
+ id,
123
+ type: nodeType,
124
+ name: nodeName,
125
+ parentId,
126
+ cycle: true,
127
+ children: [],
128
+ }
129
+ }
130
+
131
+ visited.add(id)
132
+ const nextPath = new Set(path)
133
+ nextPath.add(id)
134
+
135
+ const childIds = Array.from(childIdsByParent.get(id) ?? [])
136
+ return {
137
+ id,
138
+ type: nodeType,
139
+ name: nodeName,
140
+ parentId,
141
+ children: childIds.map((childId) => buildNode(childId, nextPath)),
142
+ }
143
+ }
144
+
145
+ const roots = rootNodeIds.map((id) => buildNode(id, new Set()))
146
+ const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id))
147
+
148
+ if (detachedNodeIds.length === 0) {
149
+ return { roots }
150
+ }
151
+
152
+ return {
153
+ roots,
154
+ detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())),
155
+ }
156
+ }
157
+
158
+ export interface ProjectVisibility {
159
+ isPrivate: boolean
160
+ showScansPublic: boolean
161
+ showGuidesPublic: boolean
162
+ }
163
+
164
+ export interface SettingsPanelProps {
165
+ projectId?: string
166
+ projectVisibility?: ProjectVisibility
167
+ onVisibilityChange?: (
168
+ field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
169
+ value: boolean,
170
+ ) => Promise<void>
171
+ }
172
+
173
+ export function SettingsPanel({
174
+ projectId,
175
+ projectVisibility,
176
+ onVisibilityChange,
177
+ }: SettingsPanelProps = {}) {
178
+ const fileInputRef = useRef<HTMLInputElement>(null)
179
+ const nodes = useScene((state) => state.nodes)
180
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
181
+ const setScene = useScene((state) => state.setScene)
182
+ const clearScene = useScene((state) => state.clearScene)
183
+ const resetSelection = useViewer((state) => state.resetSelection)
184
+ const exportScene = useViewer((state) => state.exportScene)
185
+ const showGrid = useViewer((state) => state.showGrid)
186
+ const setPhase = useEditor((state) => state.setPhase)
187
+ const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
188
+ const sceneGraphValue = useMemo(
189
+ () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
190
+ [nodes, rootNodeIds],
191
+ )
192
+ const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => {
193
+ event.preventDefault()
194
+ event.stopPropagation()
195
+ }, [])
196
+ const blockSceneGraphDeletion = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
197
+ if (event.key === 'Delete' || event.key === 'Backspace') {
198
+ event.preventDefault()
199
+ event.stopPropagation()
200
+ }
201
+ }, [])
202
+
203
+ const isLocalProject = false // Props-based; only show cloud sections when projectId provided
204
+
205
+ const handleSaveBuild = () => {
206
+ const sceneData = { nodes, rootNodeIds }
207
+ const json = JSON.stringify(sceneData, null, 2)
208
+ const blob = new Blob([json], { type: 'application/json' })
209
+ const url = URL.createObjectURL(blob)
210
+ const link = document.createElement('a')
211
+ link.href = url
212
+ const date = new Date().toISOString().split('T')[0]
213
+ link.download = `layout_${date}.json`
214
+ link.click()
215
+ URL.revokeObjectURL(url)
216
+ }
217
+
218
+ const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
219
+ const file = e.target.files?.[0]
220
+ if (!file) return
221
+
222
+ const reader = new FileReader()
223
+ reader.onload = (event) => {
224
+ try {
225
+ const data = JSON.parse(event.target?.result as string)
226
+ if (data.nodes && data.rootNodeIds) {
227
+ setScene(data.nodes, data.rootNodeIds)
228
+ resetSelection()
229
+ setPhase('site')
230
+ }
231
+ } catch (err) {
232
+ console.error('Failed to load build:', err)
233
+ }
234
+ }
235
+ reader.readAsText(file)
236
+
237
+ // Reset input so the same file can be loaded again
238
+ e.target.value = ''
239
+ }
240
+
241
+ const handleResetToDefault = () => {
242
+ clearScene()
243
+ resetSelection()
244
+ setPhase('structure')
245
+ selectDefaultBuildingAndLevel()
246
+ }
247
+
248
+ const handleGenerateThumbnail = () => {
249
+ if (!projectId) return
250
+ setIsGeneratingThumbnail(true)
251
+ emitter.emit('camera-controls:generate-thumbnail', { projectId })
252
+ setTimeout(() => setIsGeneratingThumbnail(false), 3000)
253
+ }
254
+
255
+ const handleVisibilityChange = async (
256
+ field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
257
+ value: boolean,
258
+ ) => {
259
+ await onVisibilityChange?.(field, value)
260
+ }
261
+
262
+ return (
263
+ <div className="flex flex-col gap-6 p-3">
264
+ {/* Visibility Section (only for cloud projects) */}
265
+ {projectId && !isLocalProject && (
266
+ <div className="space-y-3">
267
+ <label className="font-medium text-muted-foreground text-xs uppercase">Visibility</label>
268
+ <div className="flex items-center justify-between">
269
+ <div>
270
+ <div className="font-medium text-sm">Public</div>
271
+ <div className="text-muted-foreground text-xs">
272
+ {projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
273
+ </div>
274
+ </div>
275
+ <Switch
276
+ checked={!(projectVisibility?.isPrivate ?? false)}
277
+ onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
278
+ />
279
+ </div>
280
+ <div className="flex items-center justify-between">
281
+ <div>
282
+ <div className="font-medium text-sm">Show 3D Scans</div>
283
+ <div className="text-muted-foreground text-xs">Visible to public viewers</div>
284
+ </div>
285
+ <Switch
286
+ checked={projectVisibility?.showScansPublic ?? true}
287
+ onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
288
+ />
289
+ </div>
290
+ <div className="flex items-center justify-between">
291
+ <div>
292
+ <div className="font-medium text-sm">Show Floorplans</div>
293
+ <div className="text-muted-foreground text-xs">Visible to public viewers</div>
294
+ </div>
295
+ <Switch
296
+ checked={projectVisibility?.showGuidesPublic ?? true}
297
+ onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
298
+ />
299
+ </div>
300
+ <div className="flex items-center justify-between">
301
+ <div>
302
+ <div className="font-medium text-sm">Show Grid</div>
303
+ <div className="text-muted-foreground text-xs">Visible only in the editor</div>
304
+ </div>
305
+ <Switch
306
+ checked={showGrid}
307
+ onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
308
+ />
309
+ </div>
310
+ </div>
311
+ )}
312
+
313
+ {/* Export Section */}
314
+ <div className="space-y-2">
315
+ <label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
316
+ <Button
317
+ className="w-full justify-start gap-2"
318
+ onClick={() => exportScene?.('glb')}
319
+ variant="outline"
320
+ >
321
+ <Download className="size-4" />
322
+ Export GLB
323
+ </Button>
324
+ <Button
325
+ className="w-full justify-start gap-2"
326
+ onClick={() => exportScene?.('stl')}
327
+ variant="outline"
328
+ >
329
+ <Download className="size-4" />
330
+ Export STL
331
+ </Button>
332
+ <Button
333
+ className="w-full justify-start gap-2"
334
+ onClick={() => exportScene?.('obj')}
335
+ variant="outline"
336
+ >
337
+ <Download className="size-4" />
338
+ Export OBJ
339
+ </Button>
340
+ </div>
341
+
342
+ {/* Thumbnail Section (only for cloud projects) */}
343
+ {projectId && !isLocalProject && (
344
+ <div className="space-y-2">
345
+ <label className="font-medium text-muted-foreground text-xs uppercase">Thumbnail</label>
346
+ <Button
347
+ className="w-full justify-start gap-2"
348
+ disabled={isGeneratingThumbnail}
349
+ onClick={handleGenerateThumbnail}
350
+ variant="outline"
351
+ >
352
+ <Camera className="size-4" />
353
+ {isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
354
+ </Button>
355
+ </div>
356
+ )}
357
+
358
+ {/* Save/Load Section */}
359
+ <div className="space-y-2">
360
+ <label className="font-medium text-muted-foreground text-xs uppercase">Save & Load</label>
361
+
362
+ <Button className="w-full justify-start gap-2" onClick={handleSaveBuild} variant="outline">
363
+ <Save className="size-4" />
364
+ Save Build
365
+ </Button>
366
+
367
+ <Button
368
+ className="w-full justify-start gap-2"
369
+ onClick={() => fileInputRef.current?.click()}
370
+ variant="outline"
371
+ >
372
+ <Upload className="size-4" />
373
+ Load Build
374
+ </Button>
375
+
376
+ <input
377
+ accept="application/json"
378
+ className="hidden"
379
+ onChange={handleFileLoad}
380
+ ref={fileInputRef}
381
+ type="file"
382
+ />
383
+ </div>
384
+
385
+ {/* Audio Section */}
386
+ <div className="space-y-2">
387
+ <label className="font-medium text-muted-foreground text-xs uppercase">Audio</label>
388
+ <AudioSettingsDialog />
389
+ </div>
390
+
391
+ {/* Keyboard Section */}
392
+ <div className="space-y-2">
393
+ <label className="font-medium text-muted-foreground text-xs uppercase">Keyboard</label>
394
+ <KeyboardShortcutsDialog />
395
+ </div>
396
+
397
+ {/* Scene Graph */}
398
+ <div className="space-y-1">
399
+ <label className="font-medium text-muted-foreground text-xs uppercase">Scene Graph</label>
400
+ <Dialog>
401
+ <DialogTrigger asChild>
402
+ <Button className="h-auto justify-start p-0 text-sm" variant="link">
403
+ Explore scene graph
404
+ </Button>
405
+ </DialogTrigger>
406
+ <DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
407
+ <DialogTitle className="sr-only">Scene Graph</DialogTitle>
408
+ <div
409
+ className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
410
+ onContextMenuCapture={blockSceneGraphMutations}
411
+ onDragStartCapture={blockSceneGraphMutations}
412
+ onDropCapture={blockSceneGraphMutations}
413
+ onKeyDownCapture={blockSceneGraphDeletion}
414
+ >
415
+ <VisualJson value={sceneGraphValue}>
416
+ <TreeView showCounts />
417
+ </VisualJson>
418
+ </div>
419
+ </DialogContent>
420
+ </Dialog>
421
+ </div>
422
+
423
+ {/* Danger Zone */}
424
+ <div className="space-y-2">
425
+ <label className="font-medium text-destructive text-xs uppercase">Danger Zone</label>
426
+
427
+ <Button
428
+ className="w-full justify-start gap-2"
429
+ onClick={handleResetToDefault}
430
+ variant="destructive"
431
+ >
432
+ <Trash2 className="size-4" />
433
+ Clear & Start New
434
+ </Button>
435
+ </div>
436
+ </div>
437
+ )
438
+ }
@@ -0,0 +1,188 @@
1
+ import { Keyboard } from 'lucide-react'
2
+ import { useEffect, useState } from 'react'
3
+ import { Button } from './../../../../../components/ui/primitives/button'
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogTrigger,
11
+ } from './../../../../../components/ui/primitives/dialog'
12
+ import { ShortcutToken } from './../../../../../components/ui/primitives/shortcut-token'
13
+
14
+ type Shortcut = {
15
+ keys: string[]
16
+ action: string
17
+ note?: string
18
+ }
19
+
20
+ type ShortcutCategory = {
21
+ title: string
22
+ shortcuts: Shortcut[]
23
+ }
24
+
25
+ const KEY_DISPLAY_MAP: Record<string, string> = {
26
+ 'Arrow Up': '↑',
27
+ 'Arrow Down': '↓',
28
+ Esc: '⎋',
29
+ Shift: '⇧',
30
+ Space: '␣',
31
+ }
32
+
33
+ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
34
+ {
35
+ title: 'Editor Navigation',
36
+ shortcuts: [
37
+ { keys: ['1'], action: 'Switch to Site phase' },
38
+ { keys: ['2'], action: 'Switch to Structure phase' },
39
+ { keys: ['3'], action: 'Switch to Furnish phase' },
40
+ { keys: ['S'], action: 'Switch to Structure layer' },
41
+ { keys: ['F'], action: 'Switch to Furnish layer' },
42
+ { keys: ['Z'], action: 'Switch to Zones layer' },
43
+ {
44
+ keys: ['Cmd/Ctrl', 'Arrow Up'],
45
+ action: 'Select next level in the active building',
46
+ },
47
+ {
48
+ keys: ['Cmd/Ctrl', 'Arrow Down'],
49
+ action: 'Select previous level in the active building',
50
+ },
51
+ { keys: ['Cmd/Ctrl', 'B'], action: 'Toggle sidebar' },
52
+ ],
53
+ },
54
+ {
55
+ title: 'Modes & History',
56
+ shortcuts: [
57
+ { keys: ['V'], action: 'Switch to Select mode' },
58
+ { keys: ['B'], action: 'Switch to Build mode' },
59
+ {
60
+ keys: ['Esc'],
61
+ action: 'Cancel the active tool and return to Select mode',
62
+ },
63
+ { keys: ['Delete / Backspace'], action: 'Delete selected objects' },
64
+ { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },
65
+ { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' },
66
+ ],
67
+ },
68
+ {
69
+ title: 'Selection',
70
+ shortcuts: [
71
+ {
72
+ keys: ['Cmd/Ctrl', 'Left click'],
73
+ action: 'Add or remove an object from multi-selection',
74
+ note: 'Works while in Select mode.',
75
+ },
76
+ ],
77
+ },
78
+ {
79
+ title: 'Drawing Tools',
80
+ shortcuts: [
81
+ {
82
+ keys: ['Shift'],
83
+ action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',
84
+ note: 'Hold while drawing.',
85
+ },
86
+ ],
87
+ },
88
+ {
89
+ title: 'Item Placement',
90
+ shortcuts: [
91
+ { keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },
92
+ { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
93
+ {
94
+ keys: ['Shift'],
95
+ action: 'Temporarily bypass placement validation constraints',
96
+ note: 'Hold while placing.',
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ title: 'Camera',
102
+ shortcuts: [
103
+ {
104
+ keys: ['Middle click'],
105
+ action: 'Pan camera',
106
+ note: 'Drag with the middle mouse button, or hold Space while dragging with the left mouse button.',
107
+ },
108
+ {
109
+ keys: ['Right click'],
110
+ action: 'Orbit camera',
111
+ note: 'Drag with the right mouse button.',
112
+ },
113
+ ],
114
+ },
115
+ ]
116
+
117
+ function getDisplayKey(key: string, isMac: boolean): string {
118
+ if (key === 'Cmd/Ctrl') return isMac ? '⌘' : 'Ctrl'
119
+ if (key === 'Delete / Backspace') return isMac ? '⌫' : 'Backspace'
120
+ return KEY_DISPLAY_MAP[key] ?? key
121
+ }
122
+
123
+ function ShortcutKeys({ keys }: { keys: string[] }) {
124
+ const [isMac, setIsMac] = useState(true)
125
+
126
+ useEffect(() => {
127
+ setIsMac(navigator.platform.toUpperCase().indexOf('MAC') >= 0)
128
+ }, [])
129
+
130
+ return (
131
+ <div className="flex flex-wrap items-center gap-1">
132
+ {keys.map((key, index) => (
133
+ <div className="flex items-center gap-1" key={`${key}-${index}`}>
134
+ {index > 0 ? <span className="text-[10px] text-muted-foreground">+</span> : null}
135
+ <ShortcutToken displayValue={getDisplayKey(key, isMac)} value={key} />
136
+ </div>
137
+ ))}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ export function KeyboardShortcutsDialog() {
143
+ return (
144
+ <Dialog>
145
+ <DialogTrigger asChild>
146
+ <Button className="w-full justify-start gap-2" variant="outline">
147
+ <Keyboard className="size-4" />
148
+ Keyboard Shortcuts
149
+ </Button>
150
+ </DialogTrigger>
151
+ <DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
152
+ <DialogHeader className="shrink-0 border-b px-6 py-4">
153
+ <DialogTitle>Keyboard Shortcuts</DialogTitle>
154
+ <DialogDescription>
155
+ Shortcuts are context-aware and depend on the current phase or tool.
156
+ </DialogDescription>
157
+ </DialogHeader>
158
+
159
+ <div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
160
+ {SHORTCUT_CATEGORIES.map((category) => (
161
+ <section className="space-y-2" key={category.title}>
162
+ <h3 className="font-medium text-sm">{category.title}</h3>
163
+ <div className="overflow-hidden rounded-md border border-border/80">
164
+ {category.shortcuts.map((shortcut, index) => (
165
+ <div
166
+ className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
167
+ key={`${category.title}-${shortcut.action}`}
168
+ >
169
+ <ShortcutKeys keys={shortcut.keys} />
170
+ <div>
171
+ <p className="text-sm">{shortcut.action}</p>
172
+ {shortcut.note ? (
173
+ <p className="text-muted-foreground text-xs">{shortcut.note}</p>
174
+ ) : null}
175
+ </div>
176
+ {index < category.shortcuts.length - 1 ? (
177
+ <div className="col-span-2 border-border/60 border-b" />
178
+ ) : null}
179
+ </div>
180
+ ))}
181
+ </div>
182
+ </section>
183
+ ))}
184
+ </div>
185
+ </DialogContent>
186
+ </Dialog>
187
+ )
188
+ }
@@ -0,0 +1,80 @@
1
+ import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { Building2, Plus } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from './../../../../../components/ui/primitives/tooltip'
10
+ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
11
+ import { TreeNodeActions } from './tree-node-actions'
12
+
13
+ interface BuildingTreeNodeProps {
14
+ node: BuildingNode
15
+ depth: number
16
+ isLast?: boolean
17
+ }
18
+
19
+ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
20
+ const [expanded, setExpanded] = useState(true)
21
+ const createNode = useScene((state) => state.createNode)
22
+ const isSelected = useViewer((state) => state.selection.buildingId === node.id)
23
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
24
+ const setSelection = useViewer((state) => state.setSelection)
25
+
26
+ const handleClick = () => {
27
+ setSelection({ buildingId: node.id })
28
+ }
29
+
30
+ const handleAddLevel = (e: React.MouseEvent) => {
31
+ e.stopPropagation()
32
+ const newLevel = LevelNode.parse({
33
+ level: node.children.length,
34
+ children: [],
35
+ parentId: node.id,
36
+ })
37
+ createNode(newLevel, node.id)
38
+ }
39
+
40
+ return (
41
+ <TreeNodeWrapper
42
+ actions={
43
+ <div className="flex items-center gap-0.5">
44
+ <TreeNodeActions node={node} />
45
+ <Tooltip>
46
+ <TooltipTrigger asChild>
47
+ <button
48
+ className="flex h-5 w-5 items-center justify-center rounded hover:bg-primary-foreground/20"
49
+ onClick={handleAddLevel}
50
+ >
51
+ <Plus className="h-3 w-3" />
52
+ </button>
53
+ </TooltipTrigger>
54
+ <TooltipContent side="right">Add new level</TooltipContent>
55
+ </Tooltip>
56
+ </div>
57
+ }
58
+ depth={depth}
59
+ expanded={expanded}
60
+ hasChildren={node.children.length > 0}
61
+ icon={<Building2 className="h-3.5 w-3.5" />}
62
+ isHovered={isHovered}
63
+ isLast={isLast}
64
+ isSelected={isSelected}
65
+ label={node.name || 'Building'}
66
+ onClick={handleClick}
67
+ onDoubleClick={() => focusTreeNode(node.id)}
68
+ onToggle={() => setExpanded(!expanded)}
69
+ >
70
+ {node.children.map((childId, index) => (
71
+ <TreeNode
72
+ depth={depth + 1}
73
+ isLast={index === node.children.length - 1}
74
+ key={childId}
75
+ nodeId={childId}
76
+ />
77
+ ))}
78
+ </TreeNodeWrapper>
79
+ )
80
+ }