@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,499 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import {
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
type BuildingNode,
|
|
8
|
+
emitter,
|
|
9
|
+
type LevelNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type ZoneNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
|
15
|
+
import { motion } from 'motion/react'
|
|
16
|
+
import Link from 'next/link'
|
|
17
|
+
import { cn } from '../lib/utils'
|
|
18
|
+
import { ActionButton } from './ui/action-menu/action-button'
|
|
19
|
+
import { TooltipProvider } from './ui/primitives/tooltip'
|
|
20
|
+
|
|
21
|
+
type ProjectOwner = {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
username: string | null
|
|
25
|
+
image: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
|
29
|
+
stacked: 'Stacked',
|
|
30
|
+
exploded: 'Exploded',
|
|
31
|
+
solo: 'Solo',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
|
35
|
+
manual: 'Stack',
|
|
36
|
+
stacked: 'Stack',
|
|
37
|
+
exploded: 'Exploded',
|
|
38
|
+
solo: 'Solo',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const wallModeConfig = {
|
|
42
|
+
up: {
|
|
43
|
+
icon: (props: any) => (
|
|
44
|
+
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
|
|
45
|
+
),
|
|
46
|
+
label: 'Full Height',
|
|
47
|
+
},
|
|
48
|
+
cutaway: {
|
|
49
|
+
icon: (props: any) => (
|
|
50
|
+
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
|
|
51
|
+
),
|
|
52
|
+
label: 'Cutaway',
|
|
53
|
+
},
|
|
54
|
+
down: {
|
|
55
|
+
icon: (props: any) => (
|
|
56
|
+
<img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />
|
|
57
|
+
),
|
|
58
|
+
label: 'Low',
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const getNodeName = (node: AnyNode): string => {
|
|
63
|
+
if ('name' in node && node.name) return node.name
|
|
64
|
+
if (node.type === 'wall') return 'Wall'
|
|
65
|
+
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
|
66
|
+
if (node.type === 'slab') return 'Slab'
|
|
67
|
+
if (node.type === 'ceiling') return 'Ceiling'
|
|
68
|
+
if (node.type === 'roof') return 'Roof'
|
|
69
|
+
if (node.type === 'roof-segment') return 'Roof Segment'
|
|
70
|
+
return node.type
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ViewerOverlayProps {
|
|
74
|
+
projectName?: string | null
|
|
75
|
+
owner?: ProjectOwner | null
|
|
76
|
+
canShowScans?: boolean
|
|
77
|
+
canShowGuides?: boolean
|
|
78
|
+
onBack?: () => void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const ViewerOverlay = ({
|
|
82
|
+
projectName,
|
|
83
|
+
owner,
|
|
84
|
+
canShowScans = true,
|
|
85
|
+
canShowGuides = true,
|
|
86
|
+
onBack,
|
|
87
|
+
}: ViewerOverlayProps) => {
|
|
88
|
+
const selection = useViewer((s) => s.selection)
|
|
89
|
+
const nodes = useScene((s) => s.nodes)
|
|
90
|
+
const showScans = useViewer((s) => s.showScans)
|
|
91
|
+
const showGuides = useViewer((s) => s.showGuides)
|
|
92
|
+
const cameraMode = useViewer((s) => s.cameraMode)
|
|
93
|
+
const levelMode = useViewer((s) => s.levelMode)
|
|
94
|
+
const wallMode = useViewer((s) => s.wallMode)
|
|
95
|
+
const theme = useViewer((s) => s.theme)
|
|
96
|
+
|
|
97
|
+
const building = selection.buildingId
|
|
98
|
+
? (nodes[selection.buildingId] as BuildingNode | undefined)
|
|
99
|
+
: null
|
|
100
|
+
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
|
101
|
+
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
|
102
|
+
|
|
103
|
+
// Get the first selected item (if any)
|
|
104
|
+
const selectedNode =
|
|
105
|
+
selection.selectedIds.length > 0
|
|
106
|
+
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
|
|
107
|
+
: null
|
|
108
|
+
|
|
109
|
+
// Get all levels for the selected building
|
|
110
|
+
const levels =
|
|
111
|
+
building?.children
|
|
112
|
+
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
|
|
113
|
+
.filter((n): n is LevelNode => n?.type === 'level')
|
|
114
|
+
.sort((a, b) => a.level - b.level) ?? []
|
|
115
|
+
|
|
116
|
+
const handleLevelClick = (levelId: LevelNode['id']) => {
|
|
117
|
+
// When switching levels, deselect zone and items
|
|
118
|
+
useViewer.getState().setSelection({ levelId })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
|
|
122
|
+
switch (depth) {
|
|
123
|
+
case 'root':
|
|
124
|
+
useViewer.getState().resetSelection()
|
|
125
|
+
break
|
|
126
|
+
case 'building':
|
|
127
|
+
useViewer.getState().setSelection({ levelId: null })
|
|
128
|
+
break
|
|
129
|
+
case 'level':
|
|
130
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
{/* Unified top-left card */}
|
|
138
|
+
<div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
|
|
139
|
+
<div className="pointer-events-auto flex min-w-[200px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
|
140
|
+
{/* Project info + back */}
|
|
141
|
+
<div className="flex items-center gap-3 px-3 py-2.5">
|
|
142
|
+
{onBack ? (
|
|
143
|
+
<button
|
|
144
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
|
145
|
+
onClick={onBack}
|
|
146
|
+
>
|
|
147
|
+
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
|
148
|
+
</button>
|
|
149
|
+
) : (
|
|
150
|
+
<Link
|
|
151
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
|
152
|
+
href="/"
|
|
153
|
+
>
|
|
154
|
+
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
|
155
|
+
</Link>
|
|
156
|
+
)}
|
|
157
|
+
<div className="min-w-0">
|
|
158
|
+
<div className="truncate font-medium text-foreground text-sm">
|
|
159
|
+
{projectName || 'Untitled'}
|
|
160
|
+
</div>
|
|
161
|
+
{owner?.username && (
|
|
162
|
+
<Link
|
|
163
|
+
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
|
164
|
+
href={`/u/${owner.username}`}
|
|
165
|
+
>
|
|
166
|
+
@{owner.username}
|
|
167
|
+
</Link>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Breadcrumb — only shown when navigated into a building */}
|
|
173
|
+
{building && (
|
|
174
|
+
<div className="border-border/40 border-t px-3 py-2">
|
|
175
|
+
<div className="flex items-center gap-1.5 text-xs">
|
|
176
|
+
<button
|
|
177
|
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
178
|
+
onClick={() => handleBreadcrumbClick('root')}
|
|
179
|
+
>
|
|
180
|
+
Site
|
|
181
|
+
</button>
|
|
182
|
+
|
|
183
|
+
{building && (
|
|
184
|
+
<>
|
|
185
|
+
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
|
186
|
+
<button
|
|
187
|
+
className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
|
188
|
+
onClick={() => handleBreadcrumbClick('building')}
|
|
189
|
+
>
|
|
190
|
+
{building.name || 'Building'}
|
|
191
|
+
</button>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{level && (
|
|
196
|
+
<>
|
|
197
|
+
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
|
198
|
+
<button
|
|
199
|
+
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
|
200
|
+
onClick={() => handleBreadcrumbClick('level')}
|
|
201
|
+
>
|
|
202
|
+
{level.name || `Level ${level.level}`}
|
|
203
|
+
</button>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{zone && (
|
|
208
|
+
<>
|
|
209
|
+
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
|
210
|
+
<span
|
|
211
|
+
className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
|
|
212
|
+
>
|
|
213
|
+
{zone.name}
|
|
214
|
+
</span>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{selectedNode && zone && (
|
|
219
|
+
<>
|
|
220
|
+
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
|
221
|
+
<span className="truncate font-medium text-foreground">
|
|
222
|
+
{getNodeName(selectedNode)}
|
|
223
|
+
</span>
|
|
224
|
+
</>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Level List (only when building is selected) */}
|
|
232
|
+
{building && levels.length > 0 && (
|
|
233
|
+
<div className="pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
|
234
|
+
<span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
235
|
+
Levels
|
|
236
|
+
</span>
|
|
237
|
+
<div className="flex flex-col">
|
|
238
|
+
{levels.map((lvl) => {
|
|
239
|
+
const isSelected = lvl.id === selection.levelId
|
|
240
|
+
return (
|
|
241
|
+
<button
|
|
242
|
+
className={cn(
|
|
243
|
+
'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
|
|
244
|
+
isSelected
|
|
245
|
+
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
|
246
|
+
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
|
247
|
+
)}
|
|
248
|
+
key={lvl.id}
|
|
249
|
+
onClick={() => handleLevelClick(lvl.id)}
|
|
250
|
+
>
|
|
251
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
252
|
+
<span
|
|
253
|
+
className={cn(
|
|
254
|
+
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
|
255
|
+
!isSelected && 'opacity-60 grayscale',
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
<Layers className="h-3.5 w-3.5" />
|
|
259
|
+
</span>
|
|
260
|
+
<div className="min-w-0 flex-1 truncate text-left">
|
|
261
|
+
{lvl.name || `Level ${lvl.level}`}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</button>
|
|
265
|
+
)
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Controls Panel - Bottom Center */}
|
|
273
|
+
<div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
|
|
274
|
+
<TooltipProvider delayDuration={0}>
|
|
275
|
+
<div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
|
276
|
+
{/* Theme Toggle */}
|
|
277
|
+
<button
|
|
278
|
+
aria-label="Toggle theme"
|
|
279
|
+
className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
|
|
280
|
+
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
281
|
+
type="button"
|
|
282
|
+
>
|
|
283
|
+
<div className="relative flex">
|
|
284
|
+
{/* Sliding Background */}
|
|
285
|
+
<motion.div
|
|
286
|
+
animate={{
|
|
287
|
+
x: theme === 'light' ? '100%' : '0%',
|
|
288
|
+
}}
|
|
289
|
+
className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
|
|
290
|
+
initial={false}
|
|
291
|
+
style={{ width: '50%' }}
|
|
292
|
+
transition={{
|
|
293
|
+
type: 'spring',
|
|
294
|
+
stiffness: 500,
|
|
295
|
+
damping: 35,
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
|
|
299
|
+
{/* Dark Mode Icon */}
|
|
300
|
+
<div
|
|
301
|
+
className={cn(
|
|
302
|
+
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
|
303
|
+
theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
|
|
304
|
+
)}
|
|
305
|
+
>
|
|
306
|
+
<Moon className="h-4 w-4" />
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* Light Mode Icon */}
|
|
310
|
+
<div
|
|
311
|
+
className={cn(
|
|
312
|
+
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
|
313
|
+
theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
|
|
314
|
+
)}
|
|
315
|
+
>
|
|
316
|
+
<Sun className="h-4 w-4" />
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</button>
|
|
320
|
+
|
|
321
|
+
<div className="mx-1 h-5 w-px bg-border/40" />
|
|
322
|
+
|
|
323
|
+
{/* Scans and Guides Visibility */}
|
|
324
|
+
{canShowScans && (
|
|
325
|
+
<ActionButton
|
|
326
|
+
className={
|
|
327
|
+
showScans
|
|
328
|
+
? 'bg-white/10'
|
|
329
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
|
330
|
+
}
|
|
331
|
+
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
|
332
|
+
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
|
333
|
+
size="icon"
|
|
334
|
+
tooltipSide="top"
|
|
335
|
+
variant="ghost"
|
|
336
|
+
>
|
|
337
|
+
<img
|
|
338
|
+
alt="Scans"
|
|
339
|
+
className="h-[28px] w-[28px] object-contain"
|
|
340
|
+
src="/icons/mesh.png"
|
|
341
|
+
/>
|
|
342
|
+
</ActionButton>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{canShowGuides && (
|
|
346
|
+
<ActionButton
|
|
347
|
+
className={
|
|
348
|
+
showGuides
|
|
349
|
+
? 'bg-white/10'
|
|
350
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
|
351
|
+
}
|
|
352
|
+
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
|
353
|
+
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
|
354
|
+
size="icon"
|
|
355
|
+
tooltipSide="top"
|
|
356
|
+
variant="ghost"
|
|
357
|
+
>
|
|
358
|
+
<img
|
|
359
|
+
alt="Guides"
|
|
360
|
+
className="h-[28px] w-[28px] object-contain"
|
|
361
|
+
src="/icons/floorplan.png"
|
|
362
|
+
/>
|
|
363
|
+
</ActionButton>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
|
|
367
|
+
|
|
368
|
+
{/* Camera Mode */}
|
|
369
|
+
<ActionButton
|
|
370
|
+
className={
|
|
371
|
+
cameraMode === 'orthographic'
|
|
372
|
+
? 'bg-violet-500/20 text-violet-400'
|
|
373
|
+
: 'hover:bg-white/5 hover:text-violet-400'
|
|
374
|
+
}
|
|
375
|
+
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
|
376
|
+
onClick={() =>
|
|
377
|
+
useViewer
|
|
378
|
+
.getState()
|
|
379
|
+
.setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
|
380
|
+
}
|
|
381
|
+
size="icon"
|
|
382
|
+
tooltipSide="top"
|
|
383
|
+
variant="ghost"
|
|
384
|
+
>
|
|
385
|
+
<Camera className="h-6 w-6" />
|
|
386
|
+
</ActionButton>
|
|
387
|
+
|
|
388
|
+
{/* Level Mode */}
|
|
389
|
+
<ActionButton
|
|
390
|
+
className={cn(
|
|
391
|
+
'p-0',
|
|
392
|
+
levelMode === 'stacked' || levelMode === 'manual'
|
|
393
|
+
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
|
394
|
+
: 'bg-white/10 text-foreground',
|
|
395
|
+
)}
|
|
396
|
+
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
|
397
|
+
onClick={() => {
|
|
398
|
+
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
|
399
|
+
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
|
400
|
+
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
|
|
401
|
+
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
|
|
402
|
+
}}
|
|
403
|
+
size="icon"
|
|
404
|
+
tooltipSide="top"
|
|
405
|
+
variant="ghost"
|
|
406
|
+
>
|
|
407
|
+
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
|
408
|
+
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
|
409
|
+
{levelMode === 'exploded' && (
|
|
410
|
+
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
|
|
411
|
+
)}
|
|
412
|
+
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
|
413
|
+
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
|
|
414
|
+
)}
|
|
415
|
+
<span
|
|
416
|
+
aria-hidden="true"
|
|
417
|
+
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
|
418
|
+
>
|
|
419
|
+
{levelModeBadgeLabels[levelMode]}
|
|
420
|
+
</span>
|
|
421
|
+
</span>
|
|
422
|
+
</ActionButton>
|
|
423
|
+
|
|
424
|
+
{/* Wall Mode */}
|
|
425
|
+
<ActionButton
|
|
426
|
+
className={
|
|
427
|
+
wallMode !== 'cutaway'
|
|
428
|
+
? 'bg-white/10'
|
|
429
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
|
|
430
|
+
}
|
|
431
|
+
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
|
432
|
+
onClick={() => {
|
|
433
|
+
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
|
|
434
|
+
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
|
435
|
+
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
|
436
|
+
}}
|
|
437
|
+
size="icon"
|
|
438
|
+
tooltipSide="top"
|
|
439
|
+
variant="ghost"
|
|
440
|
+
>
|
|
441
|
+
{(() => {
|
|
442
|
+
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
|
|
443
|
+
return <Icon className="h-[28px] w-[28px]" />
|
|
444
|
+
})()}
|
|
445
|
+
</ActionButton>
|
|
446
|
+
|
|
447
|
+
<div className="mx-1 h-5 w-px bg-border/40" />
|
|
448
|
+
|
|
449
|
+
{/* Camera Actions */}
|
|
450
|
+
<ActionButton
|
|
451
|
+
className="group hidden hover:bg-white/5 sm:inline-flex"
|
|
452
|
+
label="Orbit Left"
|
|
453
|
+
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
|
|
454
|
+
size="icon"
|
|
455
|
+
tooltipSide="top"
|
|
456
|
+
variant="ghost"
|
|
457
|
+
>
|
|
458
|
+
<img
|
|
459
|
+
alt="Orbit Left"
|
|
460
|
+
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
|
461
|
+
src="/icons/rotate.png"
|
|
462
|
+
/>
|
|
463
|
+
</ActionButton>
|
|
464
|
+
|
|
465
|
+
<ActionButton
|
|
466
|
+
className="group hidden hover:bg-white/5 sm:inline-flex"
|
|
467
|
+
label="Orbit Right"
|
|
468
|
+
onClick={() => emitter.emit('camera-controls:orbit-cw')}
|
|
469
|
+
size="icon"
|
|
470
|
+
tooltipSide="top"
|
|
471
|
+
variant="ghost"
|
|
472
|
+
>
|
|
473
|
+
<img
|
|
474
|
+
alt="Orbit Right"
|
|
475
|
+
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
|
476
|
+
src="/icons/rotate.png"
|
|
477
|
+
/>
|
|
478
|
+
</ActionButton>
|
|
479
|
+
|
|
480
|
+
<ActionButton
|
|
481
|
+
className="group hover:bg-white/5"
|
|
482
|
+
label="Top View"
|
|
483
|
+
onClick={() => emitter.emit('camera-controls:top-view')}
|
|
484
|
+
size="icon"
|
|
485
|
+
tooltipSide="top"
|
|
486
|
+
variant="ghost"
|
|
487
|
+
>
|
|
488
|
+
<img
|
|
489
|
+
alt="Top View"
|
|
490
|
+
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
|
491
|
+
src="/icons/topview.png"
|
|
492
|
+
/>
|
|
493
|
+
</ActionButton>
|
|
494
|
+
</div>
|
|
495
|
+
</TooltipProvider>
|
|
496
|
+
</div>
|
|
497
|
+
</>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useFrame } from '@react-three/fiber'
|
|
6
|
+
import type { Mesh } from 'three'
|
|
7
|
+
import useEditor from '../store/use-editor'
|
|
8
|
+
|
|
9
|
+
export const ViewerZoneSystem = () => {
|
|
10
|
+
useFrame(() => {
|
|
11
|
+
const { levelId, zoneId } = useViewer.getState().selection
|
|
12
|
+
const structureLayer = useEditor.getState().structureLayer
|
|
13
|
+
const nodes = useScene.getState().nodes
|
|
14
|
+
|
|
15
|
+
sceneRegistry.byType.zone.forEach((id) => {
|
|
16
|
+
const obj = sceneRegistry.nodes.get(id)
|
|
17
|
+
if (!obj) return
|
|
18
|
+
|
|
19
|
+
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
|
|
20
|
+
if (!zone) return
|
|
21
|
+
|
|
22
|
+
const isOnSelectedLevel = zone.parentId === levelId
|
|
23
|
+
|
|
24
|
+
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
|
|
25
|
+
// Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
|
|
26
|
+
// The editor ZoneSystem handles the selected zone's opacity animation.
|
|
27
|
+
const isSelected = id === zoneId
|
|
28
|
+
const shouldShowGeometry =
|
|
29
|
+
(structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
|
|
30
|
+
if (!obj.visible) obj.visible = true
|
|
31
|
+
obj.traverse((child) => {
|
|
32
|
+
if ((child as Mesh).isMesh) {
|
|
33
|
+
child.visible = shouldShowGeometry
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Labels: always visible on the current level (regardless of mode or zone selection)
|
|
38
|
+
const showLabel = !!levelId && isOnSelectedLevel
|
|
39
|
+
const targetOpacity = showLabel ? '1' : '0'
|
|
40
|
+
const labelEl = document.getElementById(`${id}-label`)
|
|
41
|
+
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
|
42
|
+
labelEl.style.opacity = targetOpacity
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from 'react'
|
|
4
|
+
import type { PresetData, PresetType } from '../components/ui/panels/presets/presets-popover'
|
|
5
|
+
|
|
6
|
+
export type { PresetData, PresetType }
|
|
7
|
+
|
|
8
|
+
export type PresetsTab = 'community' | 'mine'
|
|
9
|
+
|
|
10
|
+
export interface PresetsAdapter {
|
|
11
|
+
/** Tabs to show. Default: both. Standalone passes ['mine']. */
|
|
12
|
+
tabs?: PresetsTab[]
|
|
13
|
+
isAuthenticated?: boolean
|
|
14
|
+
fetchPresets: (type: PresetType, tab: PresetsTab) => Promise<PresetData[]>
|
|
15
|
+
savePreset: (
|
|
16
|
+
type: PresetType,
|
|
17
|
+
name: string,
|
|
18
|
+
data: Record<string, unknown>,
|
|
19
|
+
) => Promise<string | null>
|
|
20
|
+
overwritePreset: (type: PresetType, id: string, data: Record<string, unknown>) => Promise<void>
|
|
21
|
+
renamePreset: (id: string, name: string) => Promise<void>
|
|
22
|
+
deletePreset: (id: string) => Promise<void>
|
|
23
|
+
togglePresetCommunity?: (id: string, current: boolean) => Promise<void>
|
|
24
|
+
uploadPresetThumbnail?: (presetId: string, blob: Blob) => Promise<string | null>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PRESETS_KEY = (type: string) => `pascal-presets-${type}`
|
|
28
|
+
|
|
29
|
+
export const localStoragePresetsAdapter: PresetsAdapter = {
|
|
30
|
+
tabs: ['mine'],
|
|
31
|
+
isAuthenticated: true,
|
|
32
|
+
|
|
33
|
+
fetchPresets: async (type, tab) => {
|
|
34
|
+
if (tab === 'community') return []
|
|
35
|
+
try {
|
|
36
|
+
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
37
|
+
return raw ? (JSON.parse(raw) as PresetData[]) : []
|
|
38
|
+
} catch {
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
savePreset: async (type, name, data) => {
|
|
44
|
+
try {
|
|
45
|
+
const id = Math.random().toString(36).slice(2, 10)
|
|
46
|
+
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
47
|
+
const presets: PresetData[] = raw ? JSON.parse(raw) : []
|
|
48
|
+
presets.push({
|
|
49
|
+
id,
|
|
50
|
+
type,
|
|
51
|
+
name,
|
|
52
|
+
data,
|
|
53
|
+
thumbnail_url: null,
|
|
54
|
+
user_id: null,
|
|
55
|
+
is_community: false,
|
|
56
|
+
created_at: new Date().toISOString(),
|
|
57
|
+
})
|
|
58
|
+
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets))
|
|
59
|
+
return id
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
overwritePreset: async (type, id, data) => {
|
|
66
|
+
try {
|
|
67
|
+
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
68
|
+
if (!raw) return
|
|
69
|
+
const presets: PresetData[] = JSON.parse(raw)
|
|
70
|
+
localStorage.setItem(
|
|
71
|
+
PRESETS_KEY(type),
|
|
72
|
+
JSON.stringify(presets.map((p) => (p.id === id ? { ...p, data } : p))),
|
|
73
|
+
)
|
|
74
|
+
} catch {}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
renamePreset: async (id, name) => {
|
|
78
|
+
for (const type of ['door', 'window']) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
81
|
+
if (!raw) continue
|
|
82
|
+
const presets: PresetData[] = JSON.parse(raw)
|
|
83
|
+
localStorage.setItem(
|
|
84
|
+
PRESETS_KEY(type),
|
|
85
|
+
JSON.stringify(presets.map((p) => (p.id === id ? { ...p, name } : p))),
|
|
86
|
+
)
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
deletePreset: async (id) => {
|
|
92
|
+
for (const type of ['door', 'window']) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
95
|
+
if (!raw) continue
|
|
96
|
+
const presets: PresetData[] = JSON.parse(raw)
|
|
97
|
+
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.filter((p) => p.id !== id)))
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PresetsContext = createContext<PresetsAdapter>(localStoragePresetsAdapter)
|
|
104
|
+
|
|
105
|
+
export function PresetsProvider({
|
|
106
|
+
adapter,
|
|
107
|
+
children,
|
|
108
|
+
}: {
|
|
109
|
+
adapter?: PresetsAdapter
|
|
110
|
+
children: React.ReactNode
|
|
111
|
+
}) {
|
|
112
|
+
return (
|
|
113
|
+
<PresetsContext.Provider value={adapter ?? localStoragePresetsAdapter}>
|
|
114
|
+
{children}
|
|
115
|
+
</PresetsContext.Provider>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usePresetsAdapter(): PresetsAdapter {
|
|
120
|
+
return useContext(PresetsContext)
|
|
121
|
+
}
|