From bde8a005a7acb85df733b6e0ba13421be323589c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 28 Feb 2026 01:28:37 -0500 Subject: [PATCH] feat(viewer): redesign viewer UI with dark toolbar and camera controls (#126) Overhaul viewer overlay with a unified bottom toolbar, dark theme support, theme toggle, camera orbit/top-view controls, and fix TypeScript array access errors for level and wall mode cycling. Co-authored-by: Claude Opus 4.6 --- .../viewer/[id]/viewer-camera-controls.tsx | 50 ++- .../app/viewer/[id]/viewer-guest-cta.tsx | 8 +- .../editor/app/viewer/[id]/viewer-overlay.tsx | 409 +++++++++++------- .../ui/action-menu/action-button.tsx | 5 +- packages/viewer/src/store/use-viewer.ts | 2 +- 5 files changed, 302 insertions(+), 172 deletions(-) diff --git a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx index 17f1d745..e90a1f81 100644 --- a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx +++ b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx @@ -1,6 +1,6 @@ 'use client' -import { sceneRegistry, useScene } from '@pascal-app/core' +import { emitter, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -105,6 +105,54 @@ export const ViewerCameraControls = () => { ) }, [targetNodeId, nodes]) + useEffect(() => { + const handleTopView = () => { + if (!controls.current) return + + const currentPolarAngle = controls.current.polarAngle + + // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° + // Otherwise, go to top view (0°) + const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 + + controls.current.rotatePolarTo(targetAngle, true) + } + + const handleOrbitCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded - Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleOrbitCCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° counter-clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded + Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + emitter.on('camera-controls:top-view', handleTopView) + emitter.on('camera-controls:orbit-cw', handleOrbitCW) + emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) + + return () => { + emitter.off('camera-controls:top-view', handleTopView) + emitter.off('camera-controls:orbit-cw', handleOrbitCW) + emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + } + }, []) + const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) }, []) diff --git a/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx b/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx index e0de0c87..8066a478 100644 --- a/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx +++ b/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx @@ -12,12 +12,12 @@ export function ViewerGuestCTA() { return ( <> -
-
-

Want to create your own 3D project?

+
+
+

Want to create your own 3D project?

diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index 49c8fd5f..b2c06bb1 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -11,17 +11,46 @@ import { import { useViewer } from '@pascal-app/viewer' import { ArrowLeft, - Box, + Camera, ChevronRight, Diamond, - Eye, - EyeOff, - Image, Layers, Layers2, + Moon, + Sun, } from 'lucide-react' import Link from 'next/link' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' import type { ProjectOwner } from '@/features/community/lib/projects/types' +import { ActionButton } from '@/components/ui/action-menu/action-button' +import { TooltipProvider } from '@/components/ui/primitives/tooltip' +import { emitter } from '@pascal-app/core' + +const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { + stacked: 'Stacked', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeConfig = { + up: { + icon: (props: any) => ( + Full Height + ), + label: 'Full Height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => Low, + label: 'Low', + }, +} const getNodeName = (node: AnyNode): string => { if ('name' in node && node.name) return node.name @@ -53,6 +82,7 @@ export const ViewerOverlay = ({ const cameraMode = useViewer((s) => s.cameraMode) const levelMode = useViewer((s) => s.levelMode) const wallMode = useViewer((s) => s.wallMode) + const theme = useViewer((s) => s.theme) const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) @@ -95,24 +125,24 @@ export const ViewerOverlay = ({ return ( <> {/* Unified top-left card */} -
-
+
+
{/* Project info + back */} -
+
- +
-
+
{projectName || 'Untitled'}
{owner?.username && ( @{owner.username} @@ -122,21 +152,21 @@ export const ViewerOverlay = ({ {/* Breadcrumb — only shown when navigated into a building */} {building && ( -
-
+
+
{building && ( <> - + @@ -145,10 +175,10 @@ export const ViewerOverlay = ({ {level && ( <> - + @@ -157,9 +187,9 @@ export const ViewerOverlay = ({ {zone && ( <> - + {zone.name} @@ -168,8 +198,8 @@ export const ViewerOverlay = ({ {selectedNode && zone && ( <> - - + + {getNodeName(selectedNode)} @@ -181,161 +211,212 @@ export const ViewerOverlay = ({ {/* Level List (only when building is selected) */} {building && levels.length > 0 && ( -
- Levels - {levels.map((lvl) => ( - - ))} +
+ Levels +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId; + return ( + + ); + })} +
)}
- {/* Controls Panel - Top Right */} -
- {/* Visibility Controls */} - {(canShowScans || canShowGuides) && ( -
- Visibility + {/* Controls Panel - Bottom Center */} +
+ +
+ {/* Theme Toggle */} + + +
+ + {/* Scans and Guides Visibility */} {canShowScans && ( - + Scans + )} + {canShowGuides && ( - + Guides + )} + + {(canShowScans || canShowGuides) &&
} + + {/* Camera Mode */} + useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')} + size="icon" + variant="ghost" + > + + + + {/* Level Mode */} + { + if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') + const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] + const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length + useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') + }} + size="icon" + variant="ghost" + > + {levelMode === 'solo' && } + {levelMode === 'exploded' && } + {(levelMode === 'stacked' || levelMode === 'manual') && } + + + {/* Wall Mode */} + { + const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length + useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') + }} + size="icon" + variant="ghost" + > + {(() => { + const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon + return + })()} + + +
+ + {/* Camera Actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + variant="ghost" + > + Orbit Left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + variant="ghost" + > + Orbit Right + + + emitter.emit('camera-controls:top-view')} + size="icon" + variant="ghost" + > + Top View +
- )} - - {/* Camera Mode */} -
- Camera - -
- - {/* Level Mode */} -
- Level Mode - - - -
- - {/* Wall Mode */} -
- Wall Mode - - - -
+
) diff --git a/apps/editor/components/ui/action-menu/action-button.tsx b/apps/editor/components/ui/action-menu/action-button.tsx index d9612d55..5ee00acb 100644 --- a/apps/editor/components/ui/action-menu/action-button.tsx +++ b/apps/editor/components/ui/action-menu/action-button.tsx @@ -12,11 +12,12 @@ interface ActionButtonProps extends React.ComponentProps { shortcut?: string; isActive?: boolean; tooltipContent?: React.ReactNode; + tooltipSide?: "top" | "right" | "bottom" | "left"; } export const ActionButton = React.forwardRef( ( - { className, children, label, shortcut, isActive, tooltipContent, ...props }, + { className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props }, ref ) => { return ( @@ -47,7 +48,7 @@ export const ActionButton = React.forwardRef - + {tooltipContent || (

{label} {shortcut && `(${shortcut})`} diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 21603975..4b504e83 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -89,7 +89,7 @@ const useViewer = create()( levelMode: "stacked", setLevelMode: (mode) => set({ levelMode: mode }), - wallMode: 'cutaway', + wallMode: 'up', setWallMode: (mode) => set({ wallMode: mode }), showScans: true,