/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Mobile-optimized toolbar for the 3D viewport. * Compact, touch-friendly layout with essential actions visible * and secondary actions in an overflow menu. */ import React, { useRef, useCallback, useMemo } from 'react'; import { FolderOpen, MousePointer2, Ruler, Scissors, Eye, EyeOff, Home, Maximize2, Crosshair, Loader2, MoreHorizontal, Plus, Download, Orbit, Sun, Moon, PersonStanding, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuCheckboxItem, } from '@/components/ui/dropdown-menu'; import { Progress } from '@/components/ui/progress'; import { useViewerStore } from '@/store'; import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView'; import { executeBasketIsolate } from '@/store/basket/basketCommands'; import { useIfc } from '@/hooks/useIfc'; import { cn } from '@/lib/utils'; import { exportGlbFromGeometry } from '@/lib/export/glb'; import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files'; import { toast } from '@/components/ui/toast'; type Tool = 'select' | 'walk' | 'measure' | 'section'; export function MobileToolbar() { const fileInputRef = useRef(null); const addModelInputRef = useRef(null); const { loadFile, loading, progress, geometryProgress, metadataProgress, geometryResult, models, loadFilesSequentially, addModel, } = useIfc(); const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0); const activeTool = useViewerStore((state) => state.activeTool); const setActiveTool = useViewerStore((state) => state.setActiveTool); const selectedEntityId = useViewerStore((state) => state.selectedEntityId); const hideEntities = useViewerStore((state) => state.hideEntities); const error = useViewerStore((state) => state.error); const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks); const resetViewerState = useViewerStore((state) => state.resetViewerState); const clearAllModels = useViewerStore((state) => state.clearAllModels); const projectionMode = useViewerStore((state) => state.projectionMode); const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode); const theme = useViewerStore((state) => state.theme); const toggleTheme = useViewerStore((state) => state.toggleTheme); const hasSelection = selectedEntityId !== null; const handleFileSelect = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; const supportedFiles = Array.from(files).filter( f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') ); if (supportedFiles.length === 0) return; recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size }))); void cacheFileBlobs(supportedFiles); if (supportedFiles.length === 1) { loadFile(supportedFiles[0]); } else { resetViewerState(); clearAllModels(); loadFilesSequentially(supportedFiles); } e.target.value = ''; }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels]); const handleAddModelSelect = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; const supportedFiles = Array.from(files).filter( f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') ); if (supportedFiles.length === 0) return; recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size }))); void cacheFileBlobs(supportedFiles); loadFilesSequentially(supportedFiles); e.target.value = ''; }, [loadFilesSequentially]); const handleIsolate = useCallback(() => { executeBasketIsolate(); }, []); const handleShowAll = useCallback(() => { resetVisibilityForHomeFromStore(); }, []); const handleHide = useCallback(() => { if (selectedEntityId !== null) { hideEntities([selectedEntityId]); } }, [selectedEntityId, hideEntities]); const handleHome = useCallback(() => { goHomeFromStore(); }, []); const handleExportGLB = useCallback(async () => { if (!geometryResult) return; try { const glb = await exportGlbFromGeometry(geometryResult, { includeMetadata: true }); const blob = new Blob([new Uint8Array(glb)], { type: 'model/gltf-binary' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'model.glb'; a.click(); URL.revokeObjectURL(url); toast.success(`Exported GLB (${(blob.size / 1024).toFixed(0)} KB)`); } catch (err) { toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } }, [geometryResult]); const toolButtons: { tool: Tool; icon: React.ElementType; label: string }[] = [ { tool: 'select', icon: MousePointer2, label: 'Select' }, { tool: 'measure', icon: Ruler, label: 'Measure' }, { tool: 'section', icon: Scissors, label: 'Section' }, ]; return (
{/* Hidden file inputs */} {/* Open File */} {/* Add Model */} {hasModelsLoaded && ( )} {/* Divider */}
{/* Tool buttons */} {toolButtons.map(({ tool, icon: Icon, label }) => ( ))} {/* Divider */}
{/* Quick actions: Home, Fit, Show All */} {/* Spacer */}
{/* Loading progress (compact) */} {loading && (geometryProgress || metadataProgress || progress) && (
{Math.round((geometryProgress ?? metadataProgress ?? progress)?.percent ?? 0)}%
)} {/* Error */} {error && ( {error} )} {/* Overflow menu */} {/* Walk Mode */} setActiveTool(activeTool === 'walk' ? 'select' : 'walk')} > Walk Mode {/* Visibility */} Isolate Selection Hide Selection {hasSelection && ( cameraCallbacks.frameSelection?.()}> Frame Selection )} {/* Camera */} toggleProjectionMode()}> {projectionMode === 'orthographic' ? 'Perspective' : 'Orthographic'} {/* Export */} {geometryResult && ( void handleExportGLB()}> Export GLB )} {/* Theme */} toggleTheme()}> {theme === 'dark' ? : } {theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
); }