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