import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react'; import { ModelViewer } from './ModelViewer'; import { Button } from './Button'; import { api, withStreamToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates'; // The modal shows the model only; G-code has its own full-page viewer. type ViewTab = '3d'; interface ModelViewerModalProps { archiveId?: number; libraryFileId?: number; title: string; fileType?: string; onClose: () => void; // When set and `settings.use_slicer_api` is on, the header's slicer button // becomes "Slice" and calls this instead of opening BambuStudio / Orca // externally — so the preview modal's slice action matches the file row's // Cog (in-app Bambuddy SliceModal) when the slicer API is enabled. onSliceWithBambuddy?: () => void; } interface Capabilities { has_model: boolean; has_source: boolean; build_volume: { x: number; y: number; z: number }; filament_colors: string[]; } interface SlicerSplitButtonProps { icon: ReactNode; label: string; dropdownLabel: string; onPrimary: () => void; items: Array<{ key: string; label: string; onClick: () => void }>; } // Split button: the primary part runs the default slicer action, the chevron // opens a dropdown with the other slicer options. Outside click or Escape // (non-propagating) closes the dropdown. The split only renders when the // action is already possible, so there is no disabled state to express. function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); useEffect(() => { if (!open) return; const handlePointerDown = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false); } }; document.addEventListener('mousedown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [open]); return (
{open && (
{items.map((item) => ( ))}
)}
); } export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings }); // Desktop "Open in Slicer" target — falls back to preferred_slicer when the // user hasn't explicitly chosen a different desktop slicer (#1329). This // variable is only used for URI-handoff; sidecar slicing keeps using // preferred_slicer directly. const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer); const isLibrary = libraryFileId != null; const [activeTab, setActiveTab] = useState(null); const [capabilities, setCapabilities] = useState(null); const [loading, setLoading] = useState(true); const [platesData, setPlatesData] = useState(null); const [platesLoading, setPlatesLoading] = useState(false); const [selectedPlateId, setSelectedPlateId] = useState(null); const [platePage, setPlatePage] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); const [platePanelHeight, setPlatePanelHeight] = useState(null); const [isDraggingDivider, setIsDraggingDivider] = useState(false); const [hasCustomSplit, setHasCustomSplit] = useState(false); const splitContainerRef = useRef(null); const platesPanelRef = useRef(null); const dividerHeight = 10; const minPlateHeight = 160; const minViewerPx = 240; const minViewerRatio = 0.35; // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); useEffect(() => { setLoading(true); if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); // A `.gcode.3mf` file is the slicer's sliced output — it carries // both the per-plate model (in `3D/3dmodel.model`) and the g-code // for the active plate (in `Metadata/plate_*.gcode`). The backend // library scan path (library.py) tags it `gcode.3mf` while the // upload path tags it `3mf`, so we accept both shapes here for // the 3D-tab + g-code-tab gating (#1543). const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'; const hasModel = isThreeMfFamily || normalizedType === 'stl'; setCapabilities({ has_model: hasModel, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [], }); setActiveTab(hasModel ? '3d' : null); setLoading(false); return; } if (!archiveId) { setCapabilities(null); setActiveTab(null); setLoading(false); return; } api.getArchiveCapabilities(archiveId) .then(caps => { setCapabilities(caps); // Auto-select the first available tab if (caps.has_model) { setActiveTab('3d'); } setLoading(false); }) .catch(() => { // Fallback to 3D model tab if capabilities check fails setCapabilities({ has_model: true, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] }); setActiveTab('3d'); setLoading(false); }); }, [archiveId, fileType, isLibrary]); useEffect(() => { setPlatesLoading(true); setSelectedPlateId(null); setPlatePage(0); if (isLibrary) { const normalizedType = (fileType || '').toLowerCase(); // Same 3mf-family gate as the capabilities branch above — sliced // `.gcode.3mf` files have plate metadata too (#1543). const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'; if (!libraryFileId || !isThreeMfFamily) { setPlatesData(null); setPlatesLoading(false); return; } api.getLibraryFilePlates(libraryFileId) .then((data) => setPlatesData(data)) .catch(() => setPlatesData(null)) .finally(() => setPlatesLoading(false)); return; } if (!archiveId) { setPlatesData(null); setPlatesLoading(false); return; } api.getArchivePlates(archiveId) .then((data) => setPlatesData(data)) .catch(() => setPlatesData(null)) .finally(() => setPlatesLoading(false)); }, [archiveId, fileType, isLibrary, libraryFileId]); const plates = useMemo(() => platesData?.plates ?? [], [platesData]); const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1; const splitFullscreen = isFullscreen && hasMultiplePlates; const selectedPlate: PlateMetadata | null = selectedPlateId == null ? null : plates.find((plate) => plate.index === selectedPlateId) ?? null; const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0; const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0); const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount; const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates'); const hasObjectCount = plates.length > 0; const platesGridRef = useRef(null); const platesViewportRef = useRef(null); const [platesPerPage, setPlatesPerPage] = useState(10); const [plateColumns, setPlateColumns] = useState(3); const shouldPaginatePlates = plates.length > platesPerPage; const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage)); const pagedPlates = shouldPaginatePlates ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage) : plates; useEffect(() => { if (!splitFullscreen) { setPlatesPerPage(10); setPlateColumns(3); return; } const grid = platesGridRef.current; const viewport = platesViewportRef.current; if (!grid || !viewport) return; let rafId = 0; const updateLayout = () => { const availableWidth = viewport.clientWidth; const minButtonWidth = 210; const computedCols = Math.floor(availableWidth / minButtonWidth); const nextCols = Math.max(3, Math.min(5, computedCols || 3)); setPlateColumns((prev) => (prev === nextCols ? prev : nextCols)); const computed = window.getComputedStyle(grid); const rowGap = Number.parseFloat(computed.rowGap || '0'); const firstItem = grid.querySelector('button'); const rowHeight = firstItem?.getBoundingClientRect().height ?? 44; const availableHeight = viewport.clientHeight; const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap))); const maxSlots = rows * nextCols; const nextPerPage = Math.max(1, maxSlots - 1); setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage)); }; const scheduleUpdate = () => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(updateLayout); }; scheduleUpdate(); const resizeObserver = new ResizeObserver(scheduleUpdate); resizeObserver.observe(viewport); resizeObserver.observe(grid); return () => { if (rafId) cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; }, [splitFullscreen, plates.length]); useEffect(() => { if (!shouldPaginatePlates) { setPlatePage(0); return; } setPlatePage((prev) => Math.min(prev, totalPlatePages - 1)); }, [plates.length, shouldPaginatePlates, totalPlatePages]); useEffect(() => { if (!shouldPaginatePlates || selectedPlateId == null) return; const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId); if (selectedIndex < 0) return; const nextPage = Math.floor(selectedIndex / platesPerPage); setPlatePage((prev) => (prev === nextPage ? prev : nextPage)); }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]); useEffect(() => { if (!splitFullscreen) { setPlatePanelHeight(null); setHasCustomSplit(false); return; } if (hasCustomSplit) return; const container = splitContainerRef.current; const panel = platesPanelRef.current; if (!container || !panel) return; const containerHeight = container.clientHeight; if (!containerHeight) return; const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio); const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight); const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight); setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight)); }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]); useEffect(() => { if (!isDraggingDivider) return; const handleMouseMove = (event: MouseEvent) => { const container = splitContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const containerHeight = rect.height; if (!containerHeight) return; const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio); const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight); const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top)); setPlatePanelHeight(nextHeight); }; const handleMouseUp = () => { setIsDraggingDivider(false); setHasCustomSplit(true); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]); // Which file types can be handed to a desktop slicer via the URL protocol // handler. Shares its list with `isSliceableFilename()`, which the File // Manager's card menu and list row use, so a file's "Slice" action and its // 3D-preview slicer button can no longer disagree about the same file. const slicerReadyType = isSliceableFileType(fileType); const canOpenInSlicer = isLibrary ? slicerReadyType : true; // The sidecar's list is narrower: its CLI cannot load STEP even though the // desktop GUI opens one fine, so in-app slicing is gated separately. const apiSlicerReadyType = isApiSliceableFileType(fileType); // When the user has the in-app Slicer API enabled (Settings → Workflow → // Slicer → Use Slicer API), library-mode previews route the header's slicer // button into Bambuddy's own SliceModal — same behaviour as the Cog button // in the file-row actions. Falls back to the external-slicer launcher when // the API is off, when no in-app handler is wired (e.g. archive preview), // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.). const useBambuddySlicer = Boolean( isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && apiSlicerReadyType, ); const handleOpenInSlicer = async (slicer: SlicerType) => { if (!canOpenInSlicer) return; const filename = title || 'model'; try { if (isLibrary) { const { token } = await api.createLibrarySlicerToken(libraryFileId!); const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename); openInSlicer(`${window.location.origin}${path}`, slicer); } else { const { token } = await api.createArchiveSlicerToken(archiveId!); const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename); openInSlicer(`${window.location.origin}${path}`, slicer); } } catch { // Fallback to direct URL (works when auth is disabled). With auth on the // slicer may then hit a 401, so surface the failure instead of making a // permission denial look identical to "no slicer installed". showToast(t('modelViewer.openInSlicerFailed'), 'error'); if (isLibrary) { const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`; openInSlicer(downloadUrl, slicer); } else { const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`; openInSlicer(downloadUrl, slicer); } } }; const slicerDropdownTypes: SlicerType[] = useBambuddySlicer ? ['bambu_studio', 'orcaslicer'] : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer']; const slicerName = (slicer: SlicerType) => slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio'); const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({ key: slicer, label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }), onClick: () => handleOpenInSlicer(slicer), })); return (
e.stopPropagation()} > {/* Header */}

{title}

{hasObjectCount && ( {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })} )}
{useBambuddySlicer ? ( } label={t('slice.action')} dropdownLabel={t('modelViewer.moreSlicerOptions')} onPrimary={() => onSliceWithBambuddy?.()} items={slicerDropdownItems} /> ) : canOpenInSlicer ? ( } label={t('modelViewer.openInSlicer')} dropdownLabel={t('modelViewer.moreSlicerOptions')} onPrimary={() => handleOpenInSlicer(preferredSlicer)} items={slicerDropdownItems} /> ) : ( )}
{/* Tabs - only show if we have capabilities */} {capabilities && (
)} {/* Viewer */}
{loading ? (
) : activeTab === '3d' && capabilities ? (
{hasMultiplePlates && (
{t('modelViewer.plates')} {platesLoading && }
{pagedPlates.map((plate) => ( ))}
{(selectedPlate || shouldPaginatePlates) && (
{selectedPlate && (
{t('modelViewer.plateNumber', { number: selectedPlate.index })} {selectedPlate.print_time_seconds != null && ( {t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })} )} {selectedPlate.filament_used_grams != null && ( {selectedPlate.filament_used_grams.toFixed(1)} g )} {selectedPlate.filaments.length > 0 && ( {t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })} )}
)} {shouldPaginatePlates && (
{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}
{(() => { const maxVisible = 5; let start = Math.max(0, platePage - Math.floor(maxVisible / 2)); const end = Math.min(totalPlatePages, start + maxVisible); if (end - start < maxVisible) { start = Math.max(0, end - maxVisible); } const pages = Array.from({ length: end - start }, (_, i) => start + i); return ( <> {start > 0 && ( )} {start > 1 && } {pages.map((pageNumber) => ( ))} {end < totalPlatePages - 1 && } {end < totalPlatePages && ( )} ); })()}
)}
)}
)} {splitFullscreen && (
{ event.preventDefault(); setIsDraggingDivider(true); setHasCustomSplit(true); }} className={`h-2 cursor-row-resize flex items-center justify-center ${ isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary' }`} >
)}
) : (
{t('modelViewer.noPreview')}
)}
); }