@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,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
|
+
}
|