@pascal-app/editor 0.9.2 → 1.0.0-beta.2

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -1,51 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { Icon } from '@iconify/react'
4
- import {
5
- type AnyNode,
6
- type AnyNodeId,
7
- type BuildingNode,
8
- emitter,
9
- getLevelDisplayName,
10
- type LevelNode,
11
- useScene,
12
- type ZoneNode,
13
- } from '@pascal-app/core'
14
- import {
15
- CLAY_PALETTE,
16
- type EdgeMode,
17
- getSceneTheme,
18
- SCENE_THEMES,
19
- useViewer,
20
- } from '@pascal-app/viewer'
21
- import {
22
- ArrowLeft,
23
- Box,
24
- Camera,
25
- Check,
26
- ChevronRight,
27
- Diamond,
28
- Footprints,
29
- Layers,
30
- Palette,
31
- PenLine,
32
- Sparkles,
33
- Square,
34
- } from 'lucide-react'
35
- import Link from 'next/link'
36
3
  import { flushSync } from 'react-dom'
37
- import { useShallow } from 'zustand/react/shallow'
38
- import { cn } from '../lib/utils'
39
4
  import useEditor from '../store/use-editor'
40
- import { ActionButton } from './ui/action-menu/action-button'
41
- import {
42
- DropdownMenu,
43
- DropdownMenuContent,
44
- DropdownMenuItem,
45
- DropdownMenuSeparator,
46
- DropdownMenuTrigger,
47
- } from './ui/primitives/dropdown-menu'
48
- import { TooltipProvider } from './ui/primitives/tooltip'
5
+ import { ViewerControlsBar } from './viewer/viewer-controls-bar'
6
+ import { ViewerSceneHeader } from './viewer/viewer-scene-header'
49
7
 
50
8
  type ProjectOwner = {
51
9
  id: string
@@ -66,224 +24,16 @@ function requestWalkthroughPointerLock() {
66
24
  if (document.pointerLockElement === canvas) return
67
25
 
68
26
  try {
69
- canvas.requestPointerLock?.()
27
+ // The request can also reject ASYNC (browser cooldown after a recent
28
+ // unlock) — swallow it like the P-resume path; clicking the canvas
29
+ // re-requests once the cooldown passes.
30
+ const result = canvas.requestPointerLock?.() as Promise<void> | undefined
31
+ if (result && typeof result.catch === 'function') result.catch(() => {})
70
32
  } catch {
71
33
  return
72
34
  }
73
35
  }
74
36
 
75
- const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
76
- stacked: 'Stacked',
77
- exploded: 'Exploded',
78
- solo: 'Solo',
79
- }
80
-
81
- const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
82
- manual: 'Stack',
83
- stacked: 'Stack',
84
- exploded: 'Exploded',
85
- solo: 'Solo',
86
- }
87
-
88
- const wallModeConfig = {
89
- up: {
90
- icon: (props: any) => (
91
- <img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
92
- ),
93
- label: 'Full Height',
94
- },
95
- cutaway: {
96
- icon: (props: any) => (
97
- <img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
98
- ),
99
- label: 'Cutaway',
100
- },
101
- down: {
102
- icon: (props: any) => (
103
- <img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
104
- ),
105
- label: 'Low',
106
- },
107
- translucent: {
108
- icon: (props: any) => (
109
- <img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
110
- ),
111
- label: 'Translucent',
112
- },
113
- }
114
-
115
- const SHADING_OPTIONS = [
116
- { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
117
- { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
118
- ] as const
119
-
120
- const TEXTURE_OPTIONS = [
121
- { id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette },
122
- { id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square },
123
- ] as const
124
-
125
- function RenderModeMenu() {
126
- const shading = useViewer((s) => s.shading)
127
- const textures = useViewer((s) => s.textures)
128
- const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
129
- const ActiveIcon = active.icon
130
- return (
131
- <DropdownMenu>
132
- <DropdownMenuTrigger asChild>
133
- <ActionButton
134
- className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
135
- label={`Render: ${active.name}`}
136
- size="icon"
137
- tooltipSide="top"
138
- variant="ghost"
139
- >
140
- <ActiveIcon className="h-6 w-6" />
141
- </ActionButton>
142
- </DropdownMenuTrigger>
143
- <DropdownMenuContent align="center" className="min-w-56" side="top">
144
- {SHADING_OPTIONS.map((option) => {
145
- const OptionIcon = option.icon
146
- return (
147
- <DropdownMenuItem
148
- key={option.id}
149
- onSelect={() => useViewer.getState().setShading(option.id)}
150
- >
151
- <OptionIcon />
152
- <div className="flex flex-col">
153
- <span className="text-foreground">{option.name}</span>
154
- <span className="text-muted-foreground text-xs">{option.detail}</span>
155
- </div>
156
- {shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
157
- </DropdownMenuItem>
158
- )
159
- })}
160
- <DropdownMenuSeparator />
161
- {TEXTURE_OPTIONS.map((option) => {
162
- const OptionIcon = option.icon
163
- return (
164
- <DropdownMenuItem
165
- key={option.name}
166
- onSelect={() => useViewer.getState().setTextures(option.id)}
167
- >
168
- <OptionIcon />
169
- <div className="flex flex-col">
170
- <span className="text-foreground">{option.name}</span>
171
- <span className="text-muted-foreground text-xs">{option.detail}</span>
172
- </div>
173
- {textures === option.id ? <Check className="ml-auto text-foreground" /> : null}
174
- </DropdownMenuItem>
175
- )
176
- })}
177
- </DropdownMenuContent>
178
- </DropdownMenu>
179
- )
180
- }
181
-
182
- function SceneThemeMenu() {
183
- const sceneTheme = useViewer((s) => s.sceneTheme)
184
- const active = getSceneTheme(sceneTheme)
185
- return (
186
- <DropdownMenu>
187
- <DropdownMenuTrigger asChild>
188
- <ActionButton
189
- className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
190
- label={`Theme: ${active.name}`}
191
- size="icon"
192
- tooltipSide="top"
193
- variant="ghost"
194
- >
195
- <Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
196
- </ActionButton>
197
- </DropdownMenuTrigger>
198
- <DropdownMenuContent align="center" className="min-w-48" side="top">
199
- {SCENE_THEMES.map((sceneThemeOption) => {
200
- const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
201
- (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
202
- )
203
- return (
204
- <DropdownMenuItem
205
- key={sceneThemeOption.id}
206
- onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
207
- >
208
- <span
209
- className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
210
- style={{ backgroundColor: sceneThemeOption.background }}
211
- >
212
- {swatches.map((color, index) => (
213
- <span
214
- key={`${sceneThemeOption.id}-${index}`}
215
- style={{ backgroundColor: color }}
216
- />
217
- ))}
218
- </span>
219
- <span className="text-foreground">{sceneThemeOption.name}</span>
220
- {sceneTheme === sceneThemeOption.id ? (
221
- <Check className="ml-auto text-foreground" />
222
- ) : null}
223
- </DropdownMenuItem>
224
- )
225
- })}
226
- </DropdownMenuContent>
227
- </DropdownMenu>
228
- )
229
- }
230
-
231
- const EDGE_OPTIONS = [
232
- { id: 'off', name: 'Off', detail: 'No edge lines' },
233
- { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
234
- { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
235
- ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
236
-
237
- function EdgesMenu() {
238
- const edges = useViewer((s) => s.edges)
239
- const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
240
- return (
241
- <DropdownMenu>
242
- <DropdownMenuTrigger asChild>
243
- <ActionButton
244
- className={
245
- edges === 'off'
246
- ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
247
- : 'bg-white/10 text-foreground'
248
- }
249
- label={`Edges: ${active.name}`}
250
- size="icon"
251
- tooltipSide="top"
252
- variant="ghost"
253
- >
254
- <PenLine className="h-6 w-6" />
255
- </ActionButton>
256
- </DropdownMenuTrigger>
257
- <DropdownMenuContent align="center" className="min-w-56" side="top">
258
- {EDGE_OPTIONS.map((option) => (
259
- <DropdownMenuItem
260
- key={option.id}
261
- onSelect={() => useViewer.getState().setEdges(option.id)}
262
- >
263
- <div className="flex flex-col">
264
- <span className="text-foreground">{option.name}</span>
265
- <span className="text-muted-foreground text-xs">{option.detail}</span>
266
- </div>
267
- {edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
268
- </DropdownMenuItem>
269
- ))}
270
- </DropdownMenuContent>
271
- </DropdownMenu>
272
- )
273
- }
274
-
275
- const getNodeName = (node: AnyNode): string => {
276
- if ('name' in node && node.name) return node.name
277
- if (node.type === 'wall') return 'Wall'
278
- if (node.type === 'fence') return 'Fence'
279
- if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
280
- if (node.type === 'slab') return 'Slab'
281
- if (node.type === 'ceiling') return 'Ceiling'
282
- if (node.type === 'roof') return 'Roof'
283
- if (node.type === 'roof-segment') return 'Roof Segment'
284
- return node.type
285
- }
286
-
287
37
  interface ViewerOverlayProps {
288
38
  projectName?: string | null
289
39
  owner?: ProjectOwner | null
@@ -298,401 +48,16 @@ export const ViewerOverlay = ({
298
48
  canShowScans = true,
299
49
  canShowGuides = true,
300
50
  onBack,
301
- }: ViewerOverlayProps) => {
302
- const selection = useViewer((s) => s.selection)
303
- const showScans = useViewer((s) => s.showScans)
304
- const showGuides = useViewer((s) => s.showGuides)
305
- const cameraMode = useViewer((s) => s.cameraMode)
306
- const levelMode = useViewer((s) => s.levelMode)
307
- const wallMode = useViewer((s) => s.wallMode)
308
-
309
- // Subscribe only to the specific nodes we read so that creating an unrelated
310
- // node elsewhere in the scene doesn't re-render this overlay.
311
- const firstSelectedId = selection.selectedIds[0] ?? null
312
- const building = useScene((s) =>
313
- selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
314
- )
315
- const level = useScene((s) =>
316
- selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
317
- )
318
- const zone = useScene((s) =>
319
- selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
320
- )
321
- const selectedNode = useScene((s) =>
322
- firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
323
- )
324
- const levels = useScene(
325
- useShallow((s) => {
326
- if (!building) return []
327
- return building.children
328
- .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
329
- .filter((n): n is LevelNode => n?.type === 'level')
330
- .sort((a, b) => a.level - b.level)
331
- }),
332
- )
333
-
334
- const handleLevelClick = (levelId: LevelNode['id']) => {
335
- // When switching levels, deselect zone and items
336
- useViewer.getState().setSelection({ levelId })
337
- }
338
-
339
- const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
340
- switch (depth) {
341
- case 'root':
342
- useViewer.getState().resetSelection()
343
- break
344
- case 'building':
345
- useViewer.getState().setSelection({ levelId: null })
346
- break
347
- case 'level':
348
- useViewer.getState().setSelection({ zoneId: null })
349
- break
350
- }
351
- }
352
-
353
- return (
354
- <>
355
- {/* Unified top-left card */}
356
- <div className="dark absolute top-4 left-4 z-20 flex flex-col gap-3 text-foreground">
357
- <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">
358
- {/* Project info + back */}
359
- <div className="flex items-center gap-3 px-3 py-2.5">
360
- {onBack ? (
361
- <button
362
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
363
- onClick={onBack}
364
- >
365
- <ArrowLeft className="h-4 w-4 text-muted-foreground" />
366
- </button>
367
- ) : (
368
- <Link
369
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
370
- href="/"
371
- >
372
- <ArrowLeft className="h-4 w-4 text-muted-foreground" />
373
- </Link>
374
- )}
375
- <div className="min-w-0">
376
- <div className="truncate font-medium text-foreground text-sm">
377
- {projectName || 'Untitled'}
378
- </div>
379
- {owner?.username && (
380
- <Link
381
- className="text-muted-foreground text-xs transition-colors hover:text-foreground"
382
- href={`/u/${owner.username}`}
383
- >
384
- @{owner.username}
385
- </Link>
386
- )}
387
- </div>
388
- </div>
389
-
390
- {/* Breadcrumb — only shown when navigated into a building */}
391
- {building && (
392
- <div className="border-border/40 border-t px-3 py-2">
393
- <div className="flex items-center gap-1.5 text-xs">
394
- <button
395
- className="text-muted-foreground transition-colors hover:text-foreground"
396
- onClick={() => handleBreadcrumbClick('root')}
397
- >
398
- Site
399
- </button>
400
-
401
- {building && (
402
- <>
403
- <ChevronRight className="h-3 w-3 text-muted-foreground/50" />
404
- <button
405
- className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
406
- onClick={() => handleBreadcrumbClick('building')}
407
- >
408
- {building.name || 'Building'}
409
- </button>
410
- </>
411
- )}
412
-
413
- {level && (
414
- <>
415
- <ChevronRight className="h-3 w-3 text-muted-foreground/50" />
416
- <button
417
- className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
418
- onClick={() => handleBreadcrumbClick('level')}
419
- >
420
- {getLevelDisplayName(level)}
421
- </button>
422
- </>
423
- )}
424
-
425
- {zone && (
426
- <>
427
- <ChevronRight className="h-3 w-3 text-muted-foreground/50" />
428
- <span
429
- className={`truncate transition-colors ${selectedNode ? 'text-muted-foreground' : 'font-medium text-foreground'}`}
430
- >
431
- {zone.name}
432
- </span>
433
- </>
434
- )}
435
-
436
- {selectedNode && zone && (
437
- <>
438
- <ChevronRight className="h-3 w-3 text-muted-foreground/50" />
439
- <span className="truncate font-medium text-foreground">
440
- {getNodeName(selectedNode)}
441
- </span>
442
- </>
443
- )}
444
- </div>
445
- </div>
446
- )}
447
- </div>
448
-
449
- {/* Level List (only when building is selected) */}
450
- {building && levels.length > 0 && (
451
- <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">
452
- <span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
453
- Levels
454
- </span>
455
- <div className="flex flex-col">
456
- {levels.map((lvl) => {
457
- const isSelected = lvl.id === selection.levelId
458
- return (
459
- <button
460
- className={cn(
461
- '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',
462
- isSelected
463
- ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
464
- : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
465
- )}
466
- key={lvl.id}
467
- onClick={() => handleLevelClick(lvl.id)}
468
- >
469
- <div className="flex min-w-0 flex-1 items-center gap-2">
470
- <span
471
- className={cn(
472
- 'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
473
- !isSelected && 'opacity-60 grayscale',
474
- )}
475
- >
476
- <Layers className="h-3.5 w-3.5" />
477
- </span>
478
- <div className="min-w-0 flex-1 truncate text-left">
479
- {lvl.name || `Level ${lvl.level}`}
480
- </div>
481
- </div>
482
- </button>
483
- )
484
- })}
485
- </div>
486
- </div>
487
- )}
488
- </div>
489
-
490
- {/* Controls Panel - Bottom Center */}
491
- <div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
492
- <TooltipProvider delayDuration={0}>
493
- <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">
494
- {/* Scans and Guides Visibility */}
495
- {canShowScans && (
496
- <ActionButton
497
- className={
498
- showScans
499
- ? 'bg-white/10'
500
- : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
501
- }
502
- label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
503
- onClick={() => useViewer.getState().setShowScans(!showScans)}
504
- size="icon"
505
- tooltipSide="top"
506
- variant="ghost"
507
- >
508
- <img
509
- alt="Scans"
510
- className="h-[28px] w-[28px] object-contain"
511
- src="/icons/mesh.webp"
512
- />
513
- </ActionButton>
514
- )}
515
-
516
- {canShowGuides && (
517
- <ActionButton
518
- className={
519
- showGuides
520
- ? 'bg-white/10'
521
- : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
522
- }
523
- label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
524
- onClick={() => useViewer.getState().setShowGuides(!showGuides)}
525
- size="icon"
526
- tooltipSide="top"
527
- variant="ghost"
528
- >
529
- <img
530
- alt="Guides"
531
- className="h-[28px] w-[28px] object-contain"
532
- src="/icons/floorplan.webp"
533
- />
534
- </ActionButton>
535
- )}
536
-
537
- {(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
538
-
539
- {/* Camera Mode */}
540
- <ActionButton
541
- className={
542
- cameraMode === 'orthographic'
543
- ? 'bg-violet-500/20 text-violet-400'
544
- : 'hover:bg-white/5 hover:text-violet-400'
545
- }
546
- label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
547
- onClick={() =>
548
- useViewer
549
- .getState()
550
- .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
551
- }
552
- size="icon"
553
- tooltipSide="top"
554
- variant="ghost"
555
- >
556
- <Camera className="h-6 w-6" />
557
- </ActionButton>
558
-
559
- <RenderModeMenu />
560
-
561
- <SceneThemeMenu />
562
-
563
- <EdgesMenu />
564
-
565
- {/* Level Mode */}
566
- <ActionButton
567
- className={cn(
568
- 'p-0',
569
- levelMode === 'stacked' || levelMode === 'manual'
570
- ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
571
- : 'bg-white/10 text-foreground',
572
- )}
573
- label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
574
- onClick={() => {
575
- if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
576
- const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
577
- const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
578
- useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
579
- }}
580
- size="icon"
581
- tooltipSide="top"
582
- variant="ghost"
583
- >
584
- <span className="relative flex h-full w-full items-center justify-center pb-1">
585
- {levelMode === 'solo' && <Diamond className="h-6 w-6" />}
586
- {levelMode === 'exploded' && (
587
- <Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
588
- )}
589
- {(levelMode === 'stacked' || levelMode === 'manual') && (
590
- <Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
591
- )}
592
- <span
593
- aria-hidden="true"
594
- 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"
595
- >
596
- {levelModeBadgeLabels[levelMode]}
597
- </span>
598
- </span>
599
- </ActionButton>
600
-
601
- {/* Wall Mode */}
602
- <ActionButton
603
- className={
604
- wallMode !== 'cutaway'
605
- ? 'bg-white/10'
606
- : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
607
- }
608
- label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
609
- onClick={() => {
610
- const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
611
- 'cutaway',
612
- 'up',
613
- 'down',
614
- 'translucent',
615
- ]
616
- const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
617
- useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
618
- }}
619
- size="icon"
620
- tooltipSide="top"
621
- variant="ghost"
622
- >
623
- {(() => {
624
- const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
625
- return <Icon className="h-[28px] w-[28px]" />
626
- })()}
627
- </ActionButton>
628
-
629
- <div className="mx-1 h-5 w-px bg-border/40" />
630
-
631
- {/* Camera Actions */}
632
- <ActionButton
633
- className="group hidden hover:bg-white/5 sm:inline-flex"
634
- label="Orbit Left"
635
- onClick={() => emitter.emit('camera-controls:orbit-ccw')}
636
- size="icon"
637
- tooltipSide="top"
638
- variant="ghost"
639
- >
640
- <img
641
- alt="Orbit Left"
642
- className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
643
- src="/icons/rotate.webp"
644
- />
645
- </ActionButton>
646
-
647
- <ActionButton
648
- className="group hidden hover:bg-white/5 sm:inline-flex"
649
- label="Orbit Right"
650
- onClick={() => emitter.emit('camera-controls:orbit-cw')}
651
- size="icon"
652
- tooltipSide="top"
653
- variant="ghost"
654
- >
655
- <img
656
- alt="Orbit Right"
657
- className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
658
- src="/icons/rotate.webp"
659
- />
660
- </ActionButton>
661
-
662
- <ActionButton
663
- className="group hover:bg-white/5"
664
- label="Top View"
665
- onClick={() => emitter.emit('camera-controls:top-view')}
666
- size="icon"
667
- tooltipSide="top"
668
- variant="ghost"
669
- >
670
- <img
671
- alt="Top View"
672
- className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
673
- src="/icons/topview.webp"
674
- />
675
- </ActionButton>
676
-
677
- <div className="mx-1 h-5 w-px bg-border/40" />
678
-
679
- {/* First-person walkthrough */}
680
- <ActionButton
681
- className="hover:bg-white/5 hover:text-emerald-400"
682
- label="Walkthrough"
683
- onClick={() => {
684
- flushSync(() => useEditor.getState().setFirstPersonMode(true))
685
- requestWalkthroughPointerLock()
686
- }}
687
- size="icon"
688
- tooltipSide="top"
689
- variant="ghost"
690
- >
691
- <Footprints className="h-6 w-6" />
692
- </ActionButton>
693
- </div>
694
- </TooltipProvider>
695
- </div>
696
- </>
697
- )
698
- }
51
+ }: ViewerOverlayProps) => (
52
+ <>
53
+ <ViewerSceneHeader onBack={onBack} owner={owner} projectName={projectName} />
54
+ <ViewerControlsBar
55
+ canShowGuides={canShowGuides}
56
+ canShowScans={canShowScans}
57
+ onWalkthroughToggle={() => {
58
+ flushSync(() => useEditor.getState().setFirstPersonMode(true))
59
+ requestWalkthroughPointerLock()
60
+ }}
61
+ />
62
+ </>
63
+ )