@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,59 @@
1
+ import * as React from 'react'
2
+ import { Button } from './../../../components/ui/primitives/button'
3
+ import {
4
+ Tooltip,
5
+ TooltipContent,
6
+ TooltipTrigger,
7
+ } from './../../../components/ui/primitives/tooltip'
8
+ import { cn } from './../../../lib/utils'
9
+
10
+ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
11
+ label: string
12
+ shortcut?: string
13
+ isActive?: boolean
14
+ tooltipContent?: React.ReactNode
15
+ tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
16
+ }
17
+
18
+ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
19
+ (
20
+ { className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
21
+ ref,
22
+ ) => {
23
+ return (
24
+ <Tooltip>
25
+ <TooltipTrigger asChild>
26
+ <Button
27
+ className={cn('relative h-11 w-11 transition-all', className)}
28
+ ref={ref}
29
+ {...props}
30
+ >
31
+ <div
32
+ className={cn(
33
+ 'flex h-full w-full items-center justify-center transition-transform',
34
+ shortcut && '-translate-x-0.5 -translate-y-0.5',
35
+ )}
36
+ >
37
+ {children}
38
+ </div>
39
+ {shortcut && (
40
+ <div className="absolute right-1 bottom-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
41
+ <span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
42
+ {shortcut}
43
+ </span>
44
+ </div>
45
+ )}
46
+ </Button>
47
+ </TooltipTrigger>
48
+ <TooltipContent side={tooltipSide}>
49
+ {tooltipContent || (
50
+ <p>
51
+ {label} {shortcut && `(${shortcut})`}
52
+ </p>
53
+ )}
54
+ </TooltipContent>
55
+ </Tooltip>
56
+ )
57
+ },
58
+ )
59
+ ActionButton.displayName = 'ActionButton'
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { emitter } from '@pascal-app/core'
4
+ import Image from 'next/image'
5
+ import { ActionButton } from './action-button'
6
+
7
+ export function CameraActions() {
8
+ const goToTopView = () => {
9
+ emitter.emit('camera-controls:top-view')
10
+ }
11
+
12
+ const orbitCW = () => {
13
+ emitter.emit('camera-controls:orbit-cw')
14
+ }
15
+
16
+ const orbitCCW = () => {
17
+ emitter.emit('camera-controls:orbit-ccw')
18
+ }
19
+
20
+ return (
21
+ <div className="flex items-center gap-1">
22
+ {/* Orbit CCW */}
23
+ <ActionButton
24
+ className="group hover:bg-white/5"
25
+ label="Orbit Left"
26
+ onClick={orbitCCW}
27
+ size="icon"
28
+ variant="ghost"
29
+ >
30
+ <Image
31
+ alt="Orbit Left"
32
+ className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
33
+ height={28}
34
+ src="/icons/rotate.png"
35
+ width={28}
36
+ />
37
+ </ActionButton>
38
+
39
+ {/* Orbit CW */}
40
+ <ActionButton
41
+ className="group hover:bg-white/5"
42
+ label="Orbit Right"
43
+ onClick={orbitCW}
44
+ size="icon"
45
+ variant="ghost"
46
+ >
47
+ <Image
48
+ alt="Orbit Right"
49
+ className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
50
+ height={28}
51
+ src="/icons/rotate.png"
52
+ width={28}
53
+ />
54
+ </ActionButton>
55
+
56
+ {/* Top View */}
57
+ <ActionButton
58
+ className="group hover:bg-white/5"
59
+ label="Top View"
60
+ onClick={goToTopView}
61
+ size="icon"
62
+ variant="ghost"
63
+ >
64
+ <Image
65
+ alt="Top View"
66
+ className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
67
+ height={28}
68
+ src="/icons/topview.png"
69
+ width={28}
70
+ />
71
+ </ActionButton>
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,240 @@
1
+ 'use client'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import { type LevelNode, useScene } from '@pascal-app/core'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+ import { type LucideIcon, Trash2 } from 'lucide-react'
7
+ import Image from 'next/image'
8
+ import { cn } from './../../../lib/utils'
9
+ import useEditor from './../../../store/use-editor'
10
+ import { ActionButton } from './action-button'
11
+
12
+ type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
13
+
14
+ type ControlConfig = {
15
+ id: ControlId
16
+ icon?: LucideIcon
17
+ iconifyIcon?: string
18
+ imageSrc?: string
19
+ label: string
20
+ shortcut?: string
21
+ color: string
22
+ activeColor: string
23
+ }
24
+
25
+ // Fixed set of controls — always visible, never morphs
26
+ const controls: ControlConfig[] = [
27
+ {
28
+ id: 'select',
29
+ imageSrc: '/icons/select.png',
30
+ label: 'Select',
31
+ shortcut: 'V',
32
+ color: 'hover:bg-blue-500/20 hover:text-blue-400',
33
+ activeColor: 'bg-blue-500/20 text-blue-400',
34
+ },
35
+ {
36
+ id: 'box-select',
37
+ iconifyIcon: 'mdi:select-drag',
38
+ label: 'Box select',
39
+ color: 'hover:bg-white/5',
40
+ activeColor: 'bg-white/10 hover:bg-white/10',
41
+ },
42
+ {
43
+ id: 'site-edit',
44
+ imageSrc: '/icons/site.png',
45
+ label: 'Edit site',
46
+ color: 'hover:bg-white/5',
47
+ activeColor: 'bg-white/10 hover:bg-white/10',
48
+ },
49
+ {
50
+ id: 'build',
51
+ imageSrc: '/icons/build.png',
52
+ label: 'Build',
53
+ shortcut: 'B',
54
+ color: 'hover:bg-green-500/20 hover:text-green-400',
55
+ activeColor: 'bg-green-500/20 text-green-400',
56
+ },
57
+ {
58
+ id: 'furnish',
59
+ imageSrc: '/icons/couch.png',
60
+ label: 'Furnish',
61
+ shortcut: 'F',
62
+ color: 'hover:bg-green-500/20 hover:text-green-400',
63
+ activeColor: 'bg-green-500/20 text-green-400',
64
+ },
65
+ {
66
+ id: 'zone',
67
+ imageSrc: '/icons/zone.png',
68
+ label: 'Zone',
69
+ shortcut: 'Z',
70
+ color: 'hover:bg-green-500/20 hover:text-green-400',
71
+ activeColor: 'bg-green-500/20 text-green-400',
72
+ },
73
+ {
74
+ id: 'delete',
75
+ icon: Trash2,
76
+ label: 'Delete',
77
+ shortcut: 'D',
78
+ color: 'hover:bg-red-500/20 hover:text-red-400',
79
+ activeColor: 'bg-red-500/20 text-red-400',
80
+ },
81
+ ]
82
+
83
+ export function ControlModes() {
84
+ const mode = useEditor((state) => state.mode)
85
+ const phase = useEditor((state) => state.phase)
86
+ const selectionTool = useEditor((state) => state.floorplanSelectionTool)
87
+ const setMode = useEditor((state) => state.setMode)
88
+ const setPhase = useEditor((state) => state.setPhase)
89
+ const setStructureLayer = useEditor((state) => state.setStructureLayer)
90
+ const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
91
+ const levelId = useViewer((s) => s.selection.levelId)
92
+
93
+ const levelNode = useScene((state) =>
94
+ levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
95
+ )
96
+
97
+ const isSiteEditing = phase === 'site'
98
+ const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
99
+ const canEnterSiteEdit = isGroundFloor || isSiteEditing
100
+
101
+ const structureLayer = useEditor((state) => state.structureLayer)
102
+
103
+ const getIsActive = (id: ControlId): boolean => {
104
+ if (isSiteEditing) return id === 'site-edit'
105
+ if (id === 'select') return mode === 'select' && selectionTool === 'click'
106
+ if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
107
+ if (id === 'site-edit') return false
108
+ if (id === 'build')
109
+ return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
110
+ if (id === 'furnish') return mode === 'build' && phase === 'furnish'
111
+ if (id === 'zone')
112
+ return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
113
+ return mode === id
114
+ }
115
+
116
+ const handleClick = (id: ControlId) => {
117
+ if (id === 'site-edit') {
118
+ if (isSiteEditing) {
119
+ // Toggle off → back to structure/select
120
+ setPhase('structure')
121
+ setMode('select')
122
+ setStructureLayer('elements')
123
+ } else if (isGroundFloor) {
124
+ // Enter site editing — set state directly to preserve level selection.
125
+ // setPhase('site') calls viewer.resetSelection() which clears levelId,
126
+ // breaking the 2D floorplan (it needs a level to render the SVG).
127
+ useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
128
+ }
129
+ return
130
+ }
131
+
132
+ // Exit site editing first if needed
133
+ if (isSiteEditing) {
134
+ setPhase('structure')
135
+ setStructureLayer('elements')
136
+ }
137
+
138
+ if (id === 'select') {
139
+ setMode('select')
140
+ setSelectionTool('click')
141
+ } else if (id === 'box-select') {
142
+ setMode('select')
143
+ setSelectionTool('marquee')
144
+ } else if (id === 'build') {
145
+ // Toggle: if already in structure build, go back to select
146
+ if (getIsActive('build')) {
147
+ setMode('select')
148
+ } else {
149
+ setPhase('structure')
150
+ setStructureLayer('elements')
151
+ setMode('build')
152
+ }
153
+ } else if (id === 'furnish') {
154
+ if (getIsActive('furnish')) {
155
+ setMode('select')
156
+ } else {
157
+ setPhase('furnish')
158
+ setMode('build')
159
+ }
160
+ } else if (id === 'zone') {
161
+ if (getIsActive('zone')) {
162
+ setMode('select')
163
+ } else {
164
+ setPhase('structure')
165
+ setStructureLayer('zones')
166
+ setMode('build')
167
+ }
168
+ } else {
169
+ setMode(id)
170
+ }
171
+ }
172
+
173
+ return (
174
+ <div className="flex items-center gap-1">
175
+ {controls.map((c) => {
176
+ const ModeIcon = c.icon
177
+ const isImageMode = Boolean(c.imageSrc)
178
+ const isSiteButton = c.id === 'site-edit'
179
+ const isActive = getIsActive(c.id)
180
+ const isDisabled = isSiteButton && !canEnterSiteEdit
181
+
182
+ return (
183
+ <ActionButton
184
+ className={cn(
185
+ 'group text-muted-foreground',
186
+ isSiteButton
187
+ ? isActive
188
+ ? c.activeColor
189
+ : canEnterSiteEdit
190
+ ? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
191
+ : 'cursor-not-allowed opacity-35 grayscale'
192
+ : !(isImageMode || isActive) && c.color,
193
+ !(isSiteButton || isImageMode) && isActive && c.activeColor,
194
+ !isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
195
+ !isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',
196
+ )}
197
+ disabled={isDisabled}
198
+ key={c.id}
199
+ label={
200
+ isSiteButton
201
+ ? isActive
202
+ ? 'Exit site editing'
203
+ : canEnterSiteEdit
204
+ ? 'Edit site'
205
+ : 'Site editing (ground level only)'
206
+ : c.label
207
+ }
208
+ onClick={() => handleClick(c.id)}
209
+ shortcut={c.shortcut}
210
+ size="icon"
211
+ variant="ghost"
212
+ >
213
+ {c.imageSrc ? (
214
+ <Image
215
+ alt={c.label}
216
+ className={cn(
217
+ 'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
218
+ isSiteButton
219
+ ? isActive
220
+ ? 'opacity-100 grayscale-0'
221
+ : ''
222
+ : isActive
223
+ ? 'opacity-100 grayscale-0'
224
+ : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
225
+ )}
226
+ height={28}
227
+ src={c.imageSrc}
228
+ width={28}
229
+ />
230
+ ) : c.iconifyIcon ? (
231
+ <Icon color="currentColor" height={18} icon={c.iconifyIcon} width={18} />
232
+ ) : (
233
+ ModeIcon && <ModeIcon className="h-5 w-5" />
234
+ )}
235
+ </ActionButton>
236
+ )
237
+ })}
238
+ </div>
239
+ )
240
+ }
@@ -0,0 +1,102 @@
1
+ 'use client'
2
+
3
+ import NextImage from 'next/image'
4
+ import { cn } from './../../../lib/utils'
5
+ import useEditor, { type CatalogCategory } from './../../../store/use-editor'
6
+ import { ActionButton } from './action-button'
7
+
8
+ export type FurnishToolConfig = {
9
+ id: 'item'
10
+ iconSrc: string
11
+ label: string
12
+ catalogCategory: CatalogCategory
13
+ }
14
+
15
+ // Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
16
+ export const furnishTools: FurnishToolConfig[] = [
17
+ {
18
+ id: 'item',
19
+ iconSrc: '/icons/couch.png',
20
+ label: 'Furniture',
21
+ catalogCategory: 'furniture',
22
+ },
23
+ {
24
+ id: 'item',
25
+ iconSrc: '/icons/appliance.png',
26
+ label: 'Appliance',
27
+ catalogCategory: 'appliance',
28
+ },
29
+ {
30
+ id: 'item',
31
+ iconSrc: '/icons/kitchen.png',
32
+ label: 'Kitchen',
33
+ catalogCategory: 'kitchen',
34
+ },
35
+ {
36
+ id: 'item',
37
+ iconSrc: '/icons/bathroom.png',
38
+ label: 'Bathroom',
39
+ catalogCategory: 'bathroom',
40
+ },
41
+ {
42
+ id: 'item',
43
+ iconSrc: '/icons/tree.png',
44
+ label: 'Outdoor',
45
+ catalogCategory: 'outdoor',
46
+ },
47
+ ]
48
+
49
+ export function FurnishTools() {
50
+ const mode = useEditor((state) => state.mode)
51
+ const activeTool = useEditor((state) => state.tool)
52
+ const setActiveTool = useEditor((state) => state.setTool)
53
+ const setMode = useEditor((state) => state.setMode)
54
+ const catalogCategory = useEditor((state) => state.catalogCategory)
55
+ const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
56
+
57
+ const hasActiveTool = furnishTools.some(
58
+ (tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
59
+ )
60
+
61
+ return (
62
+ <div className="flex items-center gap-1.5 px-1">
63
+ {furnishTools.map((tool, index) => {
64
+ // For item tools with catalog category, check both tool and category match
65
+ const isActive =
66
+ mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
67
+
68
+ return (
69
+ <ActionButton
70
+ className={cn(
71
+ 'rounded-lg duration-300',
72
+ isActive
73
+ ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
74
+ : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
75
+ )}
76
+ key={`${tool.id}-${tool.catalogCategory ?? index}`}
77
+ label={tool.label}
78
+ onClick={() => {
79
+ if (!isActive) {
80
+ setCatalogCategory(tool.catalogCategory)
81
+ setActiveTool('item')
82
+ if (mode !== 'build') {
83
+ setMode('build')
84
+ }
85
+ }
86
+ }}
87
+ size="icon"
88
+ variant="ghost"
89
+ >
90
+ <NextImage
91
+ alt={tool.label}
92
+ className="size-full object-contain"
93
+ height={28}
94
+ src={tool.iconSrc}
95
+ width={28}
96
+ />
97
+ </ActionButton>
98
+ )
99
+ })}
100
+ </div>
101
+ )
102
+ }
@@ -0,0 +1,152 @@
1
+ 'use client'
2
+
3
+ import { AnimatePresence, motion } from 'motion/react'
4
+ import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
5
+ import { useReducedMotion } from './../../../hooks/use-reduced-motion'
6
+ import { cn } from './../../../lib/utils'
7
+ import useEditor from './../../../store/use-editor'
8
+ import { ItemCatalog } from '../item-catalog/item-catalog'
9
+ import { CameraActions } from './camera-actions'
10
+ import { ControlModes } from './control-modes'
11
+ import { FurnishTools } from './furnish-tools'
12
+ import { StructureTools } from './structure-tools'
13
+ import { ViewToggles } from './view-toggles'
14
+
15
+ export function ActionMenu({ className }: { className?: string }) {
16
+ const phase = useEditor((state) => state.phase)
17
+ const mode = useEditor((state) => state.mode)
18
+ const tool = useEditor((state) => state.tool)
19
+ const catalogCategory = useEditor((state) => state.catalogCategory)
20
+ const reducedMotion = useReducedMotion()
21
+ const transition = reducedMotion
22
+ ? { duration: 0 }
23
+ : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
24
+
25
+ return (
26
+ <TooltipProvider>
27
+ <motion.div
28
+ className={cn(
29
+ 'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
30
+ 'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
31
+ 'transition-colors duration-200 ease-out',
32
+ className,
33
+ )}
34
+ layout
35
+ transition={transition}
36
+ >
37
+ {/* Item Catalog Row - Only show when in build mode with item tool */}
38
+ <AnimatePresence>
39
+ {mode === 'build' && tool === 'item' && catalogCategory && (
40
+ <motion.div
41
+ animate={{
42
+ opacity: 1,
43
+ maxHeight: 160,
44
+ paddingTop: 8,
45
+ paddingBottom: 8,
46
+ borderBottomWidth: 1,
47
+ }}
48
+ className={cn('overflow-hidden border-border border-b px-2 py-2')}
49
+ exit={{
50
+ opacity: 0,
51
+ maxHeight: 0,
52
+ paddingTop: 0,
53
+ paddingBottom: 0,
54
+ borderBottomWidth: 0,
55
+ }}
56
+ initial={{
57
+ opacity: 0,
58
+ maxHeight: 0,
59
+ paddingTop: 0,
60
+ paddingBottom: 0,
61
+ borderBottomWidth: 0,
62
+ }}
63
+ transition={transition}
64
+ >
65
+ <ItemCatalog category={catalogCategory} key={catalogCategory} />
66
+ </motion.div>
67
+ )}
68
+ </AnimatePresence>
69
+
70
+ <AnimatePresence>
71
+ {phase === 'furnish' && mode === 'build' && (
72
+ <motion.div
73
+ animate={{
74
+ opacity: 1,
75
+ maxHeight: 80,
76
+ paddingTop: 8,
77
+ paddingBottom: 8,
78
+ borderBottomWidth: 1,
79
+ }}
80
+ className={cn(
81
+ 'overflow-hidden border-border',
82
+ 'max-h-20 border-b px-2 py-2 opacity-100',
83
+ )}
84
+ exit={{
85
+ opacity: 0,
86
+ maxHeight: 0,
87
+ paddingTop: 0,
88
+ paddingBottom: 0,
89
+ borderBottomWidth: 0,
90
+ }}
91
+ initial={{
92
+ opacity: 0,
93
+ maxHeight: 0,
94
+ paddingTop: 0,
95
+ paddingBottom: 0,
96
+ borderBottomWidth: 0,
97
+ }}
98
+ transition={transition}
99
+ >
100
+ <div className="mx-auto w-max">
101
+ <FurnishTools />
102
+ </div>
103
+ </motion.div>
104
+ )}
105
+ </AnimatePresence>
106
+
107
+ {/* Structure Tools Row - Animated */}
108
+ <AnimatePresence>
109
+ {phase === 'structure' && mode === 'build' && (
110
+ <motion.div
111
+ animate={{
112
+ opacity: 1,
113
+ maxHeight: 80,
114
+ paddingTop: 8,
115
+ paddingBottom: 8,
116
+ borderBottomWidth: 1,
117
+ }}
118
+ className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
119
+ exit={{
120
+ opacity: 0,
121
+ maxHeight: 0,
122
+ paddingTop: 0,
123
+ paddingBottom: 0,
124
+ borderBottomWidth: 0,
125
+ }}
126
+ initial={{
127
+ opacity: 0,
128
+ maxHeight: 0,
129
+ paddingTop: 0,
130
+ paddingBottom: 0,
131
+ borderBottomWidth: 0,
132
+ }}
133
+ transition={transition}
134
+ >
135
+ <div className="w-max">
136
+ <StructureTools />
137
+ </div>
138
+ </motion.div>
139
+ )}
140
+ </AnimatePresence>
141
+ {/* Control Mode Row - Always visible, centered */}
142
+ <div className="flex items-center justify-center gap-1 px-2 py-1.5">
143
+ <ControlModes />
144
+ <div className="mx-1 h-5 w-px bg-border" />
145
+ <ViewToggles />
146
+ <div className="mx-1 h-5 w-px bg-border" />
147
+ <CameraActions />
148
+ </div>
149
+ </motion.div>
150
+ </TooltipProvider>
151
+ )
152
+ }