@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,39 @@
1
+ 'use client'
2
+
3
+ import { cn } from './../../../lib/utils'
4
+
5
+ export type SidebarTab = {
6
+ id: string
7
+ label: string
8
+ }
9
+
10
+ interface TabBarProps {
11
+ tabs: SidebarTab[]
12
+ activeTab: string
13
+ onTabChange: (id: string) => void
14
+ }
15
+
16
+ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
17
+ return (
18
+ <div className="flex h-10 shrink-0 items-center gap-0.5 border-border/50 border-b px-2">
19
+ {tabs.map((tab) => {
20
+ const isActive = activeTab === tab.id
21
+ return (
22
+ <button
23
+ className={cn(
24
+ 'relative h-7 rounded-md px-3 font-medium text-sm transition-colors',
25
+ isActive
26
+ ? 'bg-accent text-foreground'
27
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
28
+ )}
29
+ key={tab.id}
30
+ onClick={() => onTabChange(tab.id)}
31
+ type="button"
32
+ >
33
+ {tab.label}
34
+ </button>
35
+ )
36
+ })}
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,36 @@
1
+ import NumberFlow from '@number-flow/react'
2
+ import { useState } from 'react'
3
+
4
+ import { Slider } from './../../components/ui/slider'
5
+
6
+ export function SliderDemo() {
7
+ const [value, setValue] = useState<number[]>([28.1])
8
+
9
+ return (
10
+ <div className="flex min-h-screen items-center justify-center bg-[#ededed] px-8">
11
+ <section className="w-full max-w-lg">
12
+ <div className="mb-2 flex items-end justify-between">
13
+ <h2 className="font-semibold text-black text-xl tracking-tight">Temperature</h2>
14
+ <NumberFlow
15
+ className="font-medium text-black/45 text-xl"
16
+ format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}
17
+ suffix="%"
18
+ value={value[0] ?? 50}
19
+ />
20
+ </div>
21
+
22
+ <Slider
23
+ aria-label="Temperature"
24
+ max={100}
25
+ min={0}
26
+ onValueChange={setValue}
27
+ step={0.1}
28
+ value={value}
29
+ variant="temperature"
30
+ />
31
+ </section>
32
+ </div>
33
+ )
34
+ }
35
+
36
+ export default SliderDemo
@@ -0,0 +1,81 @@
1
+ import * as SliderPrimitive from '@radix-ui/react-slider'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import type * as React from 'react'
4
+
5
+ import { cn } from './../../lib/utils'
6
+
7
+ const sliderVariants = cva(
8
+ 'relative flex w-full touch-none select-none items-center overflow-hidden',
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: '',
13
+ temperature: `
14
+ h-16
15
+ [&_[data-slot=slider-track]]:h-14
16
+ [&_[data-slot=slider-track]]:rounded-xl
17
+ [&_[data-slot=slider-track]]:border
18
+ [&_[data-slot=slider-track]]:border-neutral-300
19
+ [&_[data-slot=slider-track]]:bg-white/50
20
+ [&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]
21
+ [&_[data-slot=slider-track]]:ring-1
22
+ [&_[data-slot=slider-track]]:ring-white
23
+ [&_[data-slot=slider-track]]:ring-inset
24
+ [&_[data-slot=slider-range]]:inset-y-0.5
25
+ [&_[data-slot=slider-range]]:h-auto
26
+ [&_[data-slot=slider-range]]:ml-0.5
27
+ [&_[data-slot=slider-range]]:mr-0.5
28
+ [&_[data-slot=slider-range]]:overflow-hidden
29
+ [&_[data-slot=slider-range]]:rounded-lg
30
+ [&_[data-slot=slider-range]]:border
31
+ [&_[data-slot=slider-range]]:border-neutral-300
32
+ [&_[data-slot=slider-range]]:bg-white
33
+ [&_[data-slot=slider-range]]:shadow-xs
34
+ [&_[data-slot=slider-thumb]]:h-7
35
+ [&_[data-slot=slider-thumb]]:w-[3px]
36
+ [&_[data-slot=slider-thumb]]:rounded-xl
37
+ [&_[data-slot=slider-thumb]]:border-0
38
+ [&_[data-slot=slider-thumb]]:bg-neutral-100
39
+ [&_[data-slot=slider-thumb]]:shadow-none
40
+ [&_[data-slot=slider-thumb]]:cursor-ew-resize
41
+ [&_[data-slot=slider-thumb]]:[transform:translateX(-8px)]
42
+ [&_[data-slot=slider-thumb]]:ring-0
43
+ [&_[data-slot=slider-thumb]]:hover:ring-0
44
+ [&_[data-slot=slider-thumb]]:focus-visible:ring-0
45
+ `,
46
+ },
47
+ },
48
+ defaultVariants: {
49
+ variant: 'default',
50
+ },
51
+ },
52
+ )
53
+
54
+ type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &
55
+ VariantProps<typeof sliderVariants>
56
+
57
+ function Slider({ variant, className, ...props }: SliderProps) {
58
+ return (
59
+ <SliderPrimitive.Root
60
+ className={cn(sliderVariants({ variant }), className)}
61
+ data-slot="slider"
62
+ {...props}
63
+ >
64
+ <SliderPrimitive.Track
65
+ className="relative h-3 w-full grow overflow-hidden rounded-full bg-muted"
66
+ data-slot="slider-track"
67
+ >
68
+ <SliderPrimitive.Range className="absolute h-full bg-primary" data-slot="slider-range" />
69
+ </SliderPrimitive.Track>
70
+ <SliderPrimitive.Thumb
71
+ className={cn(
72
+ 'block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50',
73
+ 'transition-[color,box-shadow] hover:ring-4 focus-visible:outline-none focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50',
74
+ )}
75
+ data-slot="slider-thumb"
76
+ />
77
+ </SliderPrimitive.Root>
78
+ )
79
+ }
80
+
81
+ export { Slider }
@@ -0,0 +1,342 @@
1
+ 'use client'
2
+
3
+ import { Icon as IconifyIcon } from '@iconify/react'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
6
+ import { useCallback } from 'react'
7
+ import { cn } from '../../lib/utils'
8
+ import useEditor from '../../store/use-editor'
9
+ import type { ViewMode } from '../../store/use-editor'
10
+ import { useSidebarStore } from './primitives/sidebar'
11
+ import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
12
+
13
+ // ── Shared styles ───────────────────────────────────────────────────────────
14
+
15
+ /** Container for a group of buttons — no padding, overflow-hidden clips children flush. */
16
+ const TOOLBAR_CONTAINER =
17
+ 'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
18
+
19
+ /** Ghost button inside a container — flush edges, no individual border/radius. */
20
+ const TOOLBAR_BTN =
21
+ 'flex items-center justify-center w-8 text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
22
+
23
+ // ── View mode segmented control ─────────────────────────────────────────────
24
+
25
+ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
26
+ {
27
+ id: '3d',
28
+ label: '3D',
29
+ icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/building.png" />,
30
+ },
31
+ {
32
+ id: '2d',
33
+ label: '2D',
34
+ icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/blueprint.png" />,
35
+ },
36
+ {
37
+ id: 'split',
38
+ label: 'Split',
39
+ icon: <Columns2 className="h-3 w-3" />,
40
+ },
41
+ ]
42
+
43
+ function ViewModeControl() {
44
+ const viewMode = useEditor((s) => s.viewMode)
45
+ const setViewMode = useEditor((s) => s.setViewMode)
46
+
47
+ return (
48
+ <div className={TOOLBAR_CONTAINER}>
49
+ {VIEW_MODES.map((mode) => {
50
+ const isActive = viewMode === mode.id
51
+ return (
52
+ <button
53
+ className={cn(
54
+ 'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
55
+ isActive
56
+ ? 'bg-white/10 text-foreground'
57
+ : 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
58
+ )}
59
+ key={mode.id}
60
+ onClick={() => setViewMode(mode.id)}
61
+ type="button"
62
+ >
63
+ {mode.icon}
64
+ <span>{mode.label}</span>
65
+ </button>
66
+ )
67
+ })}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ // ── Collapse sidebar button ─────────────────────────────────────────────────
73
+
74
+ function CollapseSidebarButton() {
75
+ const isCollapsed = useSidebarStore((s) => s.isCollapsed)
76
+ const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
77
+
78
+ const toggle = useCallback(() => {
79
+ setIsCollapsed(!isCollapsed)
80
+ }, [isCollapsed, setIsCollapsed])
81
+
82
+ return (
83
+ <div className={TOOLBAR_CONTAINER}>
84
+ <button
85
+ className={TOOLBAR_BTN}
86
+ onClick={toggle}
87
+ title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
88
+ type="button"
89
+ >
90
+ {isCollapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
91
+ </button>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ // ── Right toolbar buttons ───────────────────────────────────────────────────
97
+
98
+ function WalkthroughButton() {
99
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
100
+ const setFirstPersonMode = useEditor((s) => s.setFirstPersonMode)
101
+
102
+ const toggle = () => {
103
+ setFirstPersonMode(!isFirstPersonMode)
104
+ }
105
+
106
+ return (
107
+ <Tooltip>
108
+ <TooltipTrigger asChild>
109
+ <button
110
+ className={cn(
111
+ TOOLBAR_BTN,
112
+ isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
113
+ )}
114
+ onClick={toggle}
115
+ type="button"
116
+ >
117
+ <Footprints className="h-4 w-4" />
118
+ </button>
119
+ </TooltipTrigger>
120
+ <TooltipContent side="bottom">Walkthrough</TooltipContent>
121
+ </Tooltip>
122
+ )
123
+ }
124
+
125
+ function UnitToggle() {
126
+ const unit = useViewer((s) => s.unit)
127
+ const setUnit = useViewer((s) => s.setUnit)
128
+
129
+ return (
130
+ <Tooltip>
131
+ <TooltipTrigger asChild>
132
+ <button
133
+ className={TOOLBAR_BTN}
134
+ onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
135
+ type="button"
136
+ >
137
+ <span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span>
138
+ </button>
139
+ </TooltipTrigger>
140
+ <TooltipContent side="bottom">
141
+ {unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}
142
+ </TooltipContent>
143
+ </Tooltip>
144
+ )
145
+ }
146
+
147
+ function ThemeToggle() {
148
+ const theme = useViewer((s) => s.theme)
149
+ const setTheme = useViewer((s) => s.setTheme)
150
+
151
+ return (
152
+ <Tooltip>
153
+ <TooltipTrigger asChild>
154
+ <button
155
+ className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/60' : 'text-amber-400/60')}
156
+ onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
157
+ type="button"
158
+ >
159
+ {theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
160
+ </button>
161
+ </TooltipTrigger>
162
+ <TooltipContent side="bottom">{theme === 'dark' ? 'Dark' : 'Light'}</TooltipContent>
163
+ </Tooltip>
164
+ )
165
+ }
166
+
167
+ // ── Level mode toggle ───────────────────────────────────────────────────────
168
+
169
+ const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
170
+ const levelModeLabels: Record<string, string> = {
171
+ manual: 'Stack',
172
+ stacked: 'Stack',
173
+ exploded: 'Exploded',
174
+ solo: 'Solo',
175
+ }
176
+
177
+ function LevelModeToggle() {
178
+ const levelMode = useViewer((s) => s.levelMode)
179
+ const setLevelMode = useViewer((s) => s.setLevelMode)
180
+
181
+ const cycle = () => {
182
+ if (levelMode === 'manual') {
183
+ setLevelMode('stacked')
184
+ return
185
+ }
186
+ const idx = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
187
+ const next = levelModeOrder[(idx + 1) % levelModeOrder.length]
188
+ if (next) setLevelMode(next)
189
+ }
190
+
191
+ const isDefault = levelMode === 'stacked' || levelMode === 'manual'
192
+
193
+ return (
194
+ <Tooltip>
195
+ <TooltipTrigger asChild>
196
+ <button
197
+ className={cn(
198
+ TOOLBAR_BTN,
199
+ 'w-auto gap-1.5 px-2.5',
200
+ !isDefault && 'bg-white/10 text-foreground/90',
201
+ )}
202
+ onClick={cycle}
203
+ type="button"
204
+ >
205
+ {levelMode === 'solo' ? (
206
+ <IconifyIcon height={14} icon="lucide:diamond" width={14} />
207
+ ) : levelMode === 'exploded' ? (
208
+ <IconifyIcon height={14} icon="charm:stack-pop" width={14} />
209
+ ) : (
210
+ <IconifyIcon height={14} icon="charm:stack-push" width={14} />
211
+ )}
212
+ <span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
213
+ </button>
214
+ </TooltipTrigger>
215
+ <TooltipContent side="bottom">
216
+ Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode]}
217
+ </TooltipContent>
218
+ </Tooltip>
219
+ )
220
+ }
221
+
222
+ // ── Wall mode toggle ────────────────────────────────────────────────────────
223
+
224
+ const wallModeOrder = ['cutaway', 'up', 'down'] as const
225
+ const wallModeConfig: Record<string, { icon: string; label: string }> = {
226
+ up: { icon: '/icons/room.png', label: 'Full height' },
227
+ cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },
228
+ down: { icon: '/icons/walllow.png', label: 'Low' },
229
+ }
230
+
231
+ function WallModeToggle() {
232
+ const wallMode = useViewer((s) => s.wallMode)
233
+ const setWallMode = useViewer((s) => s.setWallMode)
234
+
235
+ const cycle = () => {
236
+ const idx = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
237
+ const next = wallModeOrder[(idx + 1) % wallModeOrder.length]
238
+ if (next) setWallMode(next)
239
+ }
240
+
241
+ const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
242
+
243
+ return (
244
+ <Tooltip>
245
+ <TooltipTrigger asChild>
246
+ <button
247
+ className={cn(
248
+ TOOLBAR_BTN,
249
+ 'w-auto gap-1.5 px-2.5',
250
+ wallMode !== 'cutaway'
251
+ ? 'bg-white/10'
252
+ : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
253
+ )}
254
+ onClick={cycle}
255
+ type="button"
256
+ >
257
+ <img alt={config.label} className="h-4 w-4 object-contain" src={config.icon} />
258
+ <span className="font-medium text-xs">{config.label}</span>
259
+ </button>
260
+ </TooltipTrigger>
261
+ <TooltipContent side="bottom">Walls: {config.label}</TooltipContent>
262
+ </Tooltip>
263
+ )
264
+ }
265
+
266
+ // ── Camera mode toggle ──────────────────────────────────────────────────────
267
+
268
+ function CameraModeToggle() {
269
+ const cameraMode = useViewer((s) => s.cameraMode)
270
+ const setCameraMode = useViewer((s) => s.setCameraMode)
271
+
272
+ return (
273
+ <Tooltip>
274
+ <TooltipTrigger asChild>
275
+ <button
276
+ className={cn(
277
+ TOOLBAR_BTN,
278
+ cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
279
+ )}
280
+ onClick={() =>
281
+ setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
282
+ }
283
+ type="button"
284
+ >
285
+ {cameraMode === 'perspective' ? (
286
+ <IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
287
+ ) : (
288
+ <IconifyIcon height={16} icon="vaadin:grid" width={16} />
289
+ )}
290
+ </button>
291
+ </TooltipTrigger>
292
+ <TooltipContent side="bottom">
293
+ {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
294
+ </TooltipContent>
295
+ </Tooltip>
296
+ )
297
+ }
298
+
299
+ function PreviewButton() {
300
+ return (
301
+ <Tooltip>
302
+ <TooltipTrigger asChild>
303
+ <button
304
+ className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
305
+ onClick={() => useEditor.getState().setPreviewMode(true)}
306
+ type="button"
307
+ >
308
+ <Eye className="h-3.5 w-3.5 shrink-0" />
309
+ <span>Preview</span>
310
+ </button>
311
+ </TooltipTrigger>
312
+ <TooltipContent side="bottom">Preview mode</TooltipContent>
313
+ </Tooltip>
314
+ )
315
+ }
316
+
317
+ // ── Composed toolbar sections ───────────────────────────────────────────────
318
+
319
+ export function ViewerToolbarLeft() {
320
+ return (
321
+ <>
322
+ <CollapseSidebarButton />
323
+ <ViewModeControl />
324
+ </>
325
+ )
326
+ }
327
+
328
+ export function ViewerToolbarRight() {
329
+ return (
330
+ <div className={TOOLBAR_CONTAINER}>
331
+ <LevelModeToggle />
332
+ <WallModeToggle />
333
+ <div className="my-1.5 w-px bg-border/50" />
334
+ <UnitToggle />
335
+ <ThemeToggle />
336
+ <CameraModeToggle />
337
+ <div className="my-1.5 w-px bg-border/50" />
338
+ <WalkthroughButton />
339
+ <PreviewButton />
340
+ </div>
341
+ )
342
+ }