mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
The Slice action appeared on .step / .stp and the endpoint accepted the job, but neither slicer can load one from its command line -- both answer "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension." So the file was read, converted and uploaded before failing as "The input model file to the slicer can not be parsed", which reads as a corrupt model rather than an unsupported format. The endpoint refuses a STEP up front with a message saying to export it as STL or 3MF, and the Slice and pipeline buttons no longer appear on one. Open in Slicer is unchanged and still hands STEP to the desktop application, which opens it fine -- that was always the working path. isSliceableFilename (desktop) and isApiSliceableFilename (sidecar) are now separate predicates so the two cannot drift back together.
756 lines
35 KiB
TypeScript
756 lines
35 KiB
TypeScript
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<HTMLDivElement>(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 (
|
|
<div className="relative inline-flex" ref={containerRef}>
|
|
<div className="flex relative z-50">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onPrimary();
|
|
}}
|
|
className="rounded-r-none"
|
|
>
|
|
{icon}
|
|
{label}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setOpen((prev) => !prev)}
|
|
aria-label={dropdownLabel}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
className="rounded-l-none border-l border-bambu-dark px-2"
|
|
>
|
|
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</Button>
|
|
</div>
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
item.onClick();
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
|
|
>
|
|
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<ViewTab | null>(null);
|
|
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
|
|
const [platesLoading, setPlatesLoading] = useState(false);
|
|
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
|
|
const [platePage, setPlatePage] = useState(0);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
|
|
const [isDraggingDivider, setIsDraggingDivider] = useState(false);
|
|
const [hasCustomSplit, setHasCustomSplit] = useState(false);
|
|
const splitContainerRef = useRef<HTMLDivElement>(null);
|
|
const platesPanelRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
|
|
const platesViewportRef = useRef<HTMLDivElement>(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<HTMLElement>('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 (
|
|
<div
|
|
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
|
|
isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
|
|
}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
|
|
<h2 className="text-lg font-semibold text-white truncate">{title}</h2>
|
|
{hasObjectCount && (
|
|
<span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
|
|
{objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{useBambuddySlicer ? (
|
|
<SlicerSplitButton
|
|
icon={<Cog className="w-4 h-4" />}
|
|
label={t('slice.action')}
|
|
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
|
onPrimary={() => onSliceWithBambuddy?.()}
|
|
items={slicerDropdownItems}
|
|
/>
|
|
) : canOpenInSlicer ? (
|
|
<SlicerSplitButton
|
|
icon={<ExternalLink className="w-4 h-4" />}
|
|
label={t('modelViewer.openInSlicer')}
|
|
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
|
onPrimary={() => handleOpenInSlicer(preferredSlicer)}
|
|
items={slicerDropdownItems}
|
|
/>
|
|
) : (
|
|
<Button variant="secondary" size="sm" disabled>
|
|
<ExternalLink className="w-4 h-4" />
|
|
{t('modelViewer.openInSlicer')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setIsFullscreen((prev) => !prev)}
|
|
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
>
|
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
<X className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs - only show if we have capabilities */}
|
|
{capabilities && (
|
|
<div className="flex border-b border-bambu-dark-tertiary">
|
|
<button
|
|
onClick={() => capabilities.has_model && setActiveTab('3d')}
|
|
disabled={!capabilities.has_model}
|
|
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === '3d'
|
|
? 'text-bambu-green border-b-2 border-bambu-green'
|
|
: capabilities.has_model
|
|
? 'text-bambu-gray hover:text-white'
|
|
: 'text-bambu-gray/30 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<Box className="w-4 h-4" />
|
|
{t('modelViewer.tabs.model')}
|
|
{!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Viewer */}
|
|
<div className="flex-1 overflow-hidden p-4">
|
|
{loading ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
|
</div>
|
|
) : activeTab === '3d' && capabilities ? (
|
|
<div
|
|
ref={splitContainerRef}
|
|
className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
|
|
>
|
|
{hasMultiplePlates && (
|
|
<div
|
|
ref={platesPanelRef}
|
|
style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
|
|
className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
|
|
<Layers className="w-4 h-4" />
|
|
{t('modelViewer.plates')}
|
|
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
</div>
|
|
<div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
|
|
<div
|
|
ref={platesViewportRef}
|
|
className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
|
|
>
|
|
<div
|
|
ref={platesGridRef}
|
|
className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
|
|
style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPlateId(null)}
|
|
className={`flex items-center rounded-lg border text-left transition-colors ${
|
|
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
|
} ${
|
|
selectedPlateId == null
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
|
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
|
}`}>
|
|
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
|
|
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
{t('modelViewer.plateCount', { count: plates.length })}
|
|
</p>
|
|
</div>
|
|
{selectedPlateId == null && (
|
|
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
|
)}
|
|
</button>
|
|
{pagedPlates.map((plate) => (
|
|
<button
|
|
key={plate.index}
|
|
type="button"
|
|
onClick={() => setSelectedPlateId(plate.index)}
|
|
className={`flex items-center rounded-lg border text-left transition-colors ${
|
|
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
|
} ${
|
|
selectedPlateId === plate.index
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{plate.has_thumbnail && plate.thumbnail_url ? (
|
|
<img
|
|
src={withStreamToken(plate.thumbnail_url)}
|
|
alt={`Plate ${plate.index}`}
|
|
className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
|
|
/>
|
|
) : (
|
|
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
|
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
|
}`}>
|
|
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
|
|
{plate.name || t('modelViewer.plateNumber', { number: plate.index })}
|
|
</p>
|
|
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
|
{t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
|
|
</p>
|
|
</div>
|
|
{selectedPlateId === plate.index && (
|
|
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{(selectedPlate || shouldPaginatePlates) && (
|
|
<div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
|
|
{selectedPlate && (
|
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
|
<span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
|
|
{selectedPlate.print_time_seconds != null && (
|
|
<span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
|
|
)}
|
|
{selectedPlate.filament_used_grams != null && (
|
|
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
|
)}
|
|
{selectedPlate.filaments.length > 0 && (
|
|
<span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{shouldPaginatePlates && (
|
|
<div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
|
|
<span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
|
|
disabled={platePage === 0}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === 0
|
|
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{t('modelViewer.pagination.prev')}
|
|
</button>
|
|
{(() => {
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage(0)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === 0
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
1
|
|
</button>
|
|
)}
|
|
{start > 1 && <span className="px-1">…</span>}
|
|
{pages.map((pageNumber) => (
|
|
<button
|
|
key={pageNumber}
|
|
type="button"
|
|
onClick={() => setPlatePage(pageNumber)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === pageNumber
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{pageNumber + 1}
|
|
</button>
|
|
))}
|
|
{end < totalPlatePages - 1 && <span className="px-1">…</span>}
|
|
{end < totalPlatePages && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage(totalPlatePages - 1)}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage === totalPlatePages - 1
|
|
? 'border-bambu-green text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{totalPlatePages}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
|
|
disabled={platePage >= totalPlatePages - 1}
|
|
className={`px-2 py-1 rounded border text-xs ${
|
|
platePage >= totalPlatePages - 1
|
|
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
{t('modelViewer.pagination.next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{splitFullscreen && (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
onMouseDown={(event) => {
|
|
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'
|
|
}`}
|
|
>
|
|
<div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
|
|
</div>
|
|
)}
|
|
<div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
|
|
<ModelViewer
|
|
url={isLibrary
|
|
? api.getLibraryFileDownloadUrl(libraryFileId!)
|
|
: (capabilities.has_source
|
|
? api.getSource3mfDownloadUrl(archiveId!)
|
|
: api.getArchiveDownload(archiveId!))}
|
|
fileType={fileType}
|
|
buildVolume={capabilities.build_volume}
|
|
filamentColors={capabilities.filament_colors}
|
|
selectedPlateId={selectedPlateId}
|
|
className="w-full h-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
|
{t('modelViewer.noPreview')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|