diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx
index 0428bbe4..e3b9e95e 100644
--- a/apps/editor/components/viewer-toolbar.tsx
+++ b/apps/editor/components/viewer-toolbar.tsx
@@ -5,6 +5,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
useEditor,
useSidebarStore,
@@ -29,6 +33,7 @@ import {
Footprints,
Grid2X2,
PenLine,
+ SlidersHorizontal,
Sparkles,
SwatchBook,
} from 'lucide-react'
@@ -239,94 +244,8 @@ function WallModeToggle() {
)
}
-function RenderModeMenu() {
- const shading = useViewer((state) => state.shading)
- const setShading = useViewer((state) => state.setShading)
- const active = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
- const ActiveIcon = active.icon
-
- return (
-
-
-
-
-
-
-
- {SHADING_OPTIONS.map((option) => {
- const OptionIcon = option.icon
- return (
- setShading(option.id)}>
-
-
- {option.name}
- {option.detail}
-
- {shading === option.id ? : null}
-
- )
- })}
-
-
- )
-}
-
-function SceneThemeMenu() {
- const sceneTheme = useViewer((state) => state.sceneTheme)
- const setSceneTheme = useViewer((state) => state.setSceneTheme)
- const active = getSceneTheme(sceneTheme)
-
- return (
-
-
-
-
-
-
-
- {SCENE_THEMES.map((theme) => {
- const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
- (role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
- )
- return (
- setSceneTheme(theme.id)}>
-
- {swatches.map((color, index) => (
-
- ))}
-
- {theme.name}
- {sceneTheme === theme.id ? (
-
- ) : null}
-
- )
- })}
-
-
- )
-}
+// One dropdown that gathers every "how the scene looks" control: grid, shadows,
+// camera projection, units, render mode, edges and scene theme.
const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' },
@@ -334,127 +253,178 @@ const EDGE_OPTIONS = [
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
-function EdgesMenu() {
- const edges = useViewer((state) => state.edges)
- const setEdges = useViewer((state) => state.setEdges)
- const active = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
+const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
- return (
-
-
-
-
-
-
-
- {EDGE_OPTIONS.map((option) => (
- setEdges(option.id)}>
-
- {option.name}
- {option.detail}
-
- {edges === option.id ? : null}
-
- ))}
-
-
- )
-}
-
-function GridVisibilityToggle() {
+function DisplayMenu() {
const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid)
-
- return (
-
-
-
- )
-}
-
-function ShadowsToggle() {
+ const unit = useViewer((state) => state.unit)
+ const setUnit = useViewer((state) => state.setUnit)
+ const cameraMode = useViewer((state) => state.cameraMode)
+ const setCameraMode = useViewer((state) => state.setCameraMode)
+ const shading = useViewer((state) => state.shading)
+ const setShading = useViewer((state) => state.setShading)
+ const sceneTheme = useViewer((state) => state.sceneTheme)
+ const setSceneTheme = useViewer((state) => state.setSceneTheme)
+ const edges = useViewer((state) => state.edges)
+ const setEdges = useViewer((state) => state.setEdges)
const shadows = useViewer((state) => state.shadows)
const setShadows = useViewer((state) => state.setShadows)
- return (
-
-
-
- )
-}
+ const activeShading =
+ SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
+ const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
+ const activeTheme = getSceneTheme(sceneTheme)
-function UnitToggle() {
- const unit = useViewer((state) => state.unit)
- const setUnit = useViewer((state) => state.setUnit)
+ // Keep the menu open when flipping a toggle.
+ const keepOpen = (event: Event, fn: () => void) => {
+ event.preventDefault()
+ fn()
+ }
return (
-
-
-
- )
-}
+ keepOpen(e, () => setShowGrid(!showGrid))}>
+
+ Grid
+ {showGrid ? (
+
+ ) : (
+
+ )}
+
+ keepOpen(e, () => setShadows(!shadows))}>
+
+ Shadows
+ {shadows ? 'On' : 'Off'}
+
+
+ keepOpen(e, () =>
+ setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
+ )
+ }
+ >
+
+ Camera
+
+ {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
+
+
+ keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
+ >
+
+ {unit === 'metric' ? 'm' : 'ft'}
+
+ Units
+
+ {unit === 'metric' ? 'Metric' : 'Imperial'}
+
+
-function CameraModeToggle() {
- const cameraMode = useViewer((state) => state.cameraMode)
- const setCameraMode = useViewer((state) => state.setCameraMode)
+
- return (
-
- setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
- type="button"
- >
- {cameraMode === 'perspective' ? (
-
- ) : (
-
- )}
-
-
+
+
+
+ Render
+ {activeShading.name}
+
+
+ {SHADING_OPTIONS.map((option) => {
+ const OptionIcon = option.icon
+ return (
+ setShading(option.id)}>
+
+
+ {option.name}
+ {option.detail}
+
+ {shading === option.id ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+
+
+
+
+ Edges
+ {activeEdges.name}
+
+
+ {EDGE_OPTIONS.map((option) => (
+ setEdges(option.id)}>
+
+ {option.name}
+ {option.detail}
+
+ {edges === option.id ? : null}
+
+ ))}
+
+
+
+
+
+
+ Theme
+
+ {activeTheme.name}
+
+
+
+ {SCENE_THEMES.map((theme) => {
+ const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
+ (role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
+ )
+ return (
+ setSceneTheme(theme.id)}>
+
+ {swatches.map((color, index) => (
+
+ ))}
+
+ {theme.name}
+ {sceneTheme === theme.id ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+
+
)
}
@@ -507,14 +477,8 @@ export function CommunityViewerToolbarRight() {
-
-
-
-
-
-
-
+
diff --git a/packages/editor/src/components/ui/primitives/dropdown-menu.tsx b/packages/editor/src/components/ui/primitives/dropdown-menu.tsx
index 3c42767e..2de3cd85 100644
--- a/packages/editor/src/components/ui/primitives/dropdown-menu.tsx
+++ b/packages/editor/src/components/ui/primitives/dropdown-menu.tsx
@@ -197,15 +197,19 @@ function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps
) {
+ // Portalled so the side-opening submenu escapes the parent content's
+ // `overflow-x-hidden` clip (otherwise it renders but is invisible).
return (
-
+
+
+
)
}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 2e546ac6..19049e8a 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -138,6 +138,10 @@ export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './components/ui/primitives/dropdown-menu'
export { useSidebarStore } from './components/ui/primitives/sidebar'