ModelViewerModal.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery } from '@tanstack/react-query';
  4. import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
  5. import { ModelViewer } from './ModelViewer';
  6. import { Button } from './Button';
  7. import { api, withStreamToken } from '../api/client';
  8. import { useToast } from '../contexts/ToastContext';
  9. import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
  10. import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
  11. // The modal shows the model only; G-code has its own full-page viewer.
  12. type ViewTab = '3d';
  13. interface ModelViewerModalProps {
  14. archiveId?: number;
  15. libraryFileId?: number;
  16. title: string;
  17. fileType?: string;
  18. onClose: () => void;
  19. // When set and `settings.use_slicer_api` is on, the header's slicer button
  20. // becomes "Slice" and calls this instead of opening BambuStudio / Orca
  21. // externally — so the preview modal's slice action matches the file row's
  22. // Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
  23. onSliceWithBambuddy?: () => void;
  24. }
  25. interface Capabilities {
  26. has_model: boolean;
  27. has_source: boolean;
  28. build_volume: { x: number; y: number; z: number };
  29. filament_colors: string[];
  30. }
  31. interface SlicerSplitButtonProps {
  32. icon: ReactNode;
  33. label: string;
  34. dropdownLabel: string;
  35. onPrimary: () => void;
  36. items: Array<{ key: string; label: string; onClick: () => void }>;
  37. }
  38. // Split button: the primary part runs the default slicer action, the chevron
  39. // opens a dropdown with the other slicer options. Outside click or Escape
  40. // (non-propagating) closes the dropdown. The split only renders when the
  41. // action is already possible, so there is no disabled state to express.
  42. function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
  43. const [open, setOpen] = useState(false);
  44. const containerRef = useRef<HTMLDivElement>(null);
  45. useEffect(() => {
  46. if (!open) return;
  47. const handlePointerDown = (e: MouseEvent) => {
  48. if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
  49. setOpen(false);
  50. }
  51. };
  52. const handleKeyDown = (e: KeyboardEvent) => {
  53. if (e.key === 'Escape') {
  54. e.stopPropagation();
  55. setOpen(false);
  56. }
  57. };
  58. document.addEventListener('mousedown', handlePointerDown);
  59. document.addEventListener('keydown', handleKeyDown);
  60. return () => {
  61. document.removeEventListener('mousedown', handlePointerDown);
  62. document.removeEventListener('keydown', handleKeyDown);
  63. };
  64. }, [open]);
  65. return (
  66. <div className="relative inline-flex" ref={containerRef}>
  67. <div className="flex relative z-50">
  68. <Button
  69. variant="secondary"
  70. size="sm"
  71. onClick={() => {
  72. setOpen(false);
  73. onPrimary();
  74. }}
  75. className="rounded-r-none"
  76. >
  77. {icon}
  78. {label}
  79. </Button>
  80. <Button
  81. variant="secondary"
  82. size="sm"
  83. onClick={() => setOpen((prev) => !prev)}
  84. aria-label={dropdownLabel}
  85. aria-haspopup="menu"
  86. aria-expanded={open}
  87. className="rounded-l-none border-l border-bambu-dark px-2"
  88. >
  89. <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
  90. </Button>
  91. </div>
  92. {open && (
  93. <div
  94. role="menu"
  95. 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"
  96. >
  97. {items.map((item) => (
  98. <button
  99. key={item.key}
  100. type="button"
  101. role="menuitem"
  102. onClick={() => {
  103. setOpen(false);
  104. item.onClick();
  105. }}
  106. 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"
  107. >
  108. <ExternalLink className="w-4 h-4 flex-shrink-0" />
  109. {item.label}
  110. </button>
  111. ))}
  112. </div>
  113. )}
  114. </div>
  115. );
  116. }
  117. export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
  118. const { t } = useTranslation();
  119. const { showToast } = useToast();
  120. const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
  121. // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
  122. // user hasn't explicitly chosen a different desktop slicer (#1329). This
  123. // variable is only used for URI-handoff; sidecar slicing keeps using
  124. // preferred_slicer directly.
  125. const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
  126. const isLibrary = libraryFileId != null;
  127. const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
  128. const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
  129. const [loading, setLoading] = useState(true);
  130. const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
  131. const [platesLoading, setPlatesLoading] = useState(false);
  132. const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
  133. const [platePage, setPlatePage] = useState(0);
  134. const [isFullscreen, setIsFullscreen] = useState(false);
  135. const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
  136. const [isDraggingDivider, setIsDraggingDivider] = useState(false);
  137. const [hasCustomSplit, setHasCustomSplit] = useState(false);
  138. const splitContainerRef = useRef<HTMLDivElement>(null);
  139. const platesPanelRef = useRef<HTMLDivElement>(null);
  140. const dividerHeight = 10;
  141. const minPlateHeight = 160;
  142. const minViewerPx = 240;
  143. const minViewerRatio = 0.35;
  144. // Close on Escape key
  145. useEffect(() => {
  146. const handleKeyDown = (e: KeyboardEvent) => {
  147. if (e.key === 'Escape') onClose();
  148. };
  149. window.addEventListener('keydown', handleKeyDown);
  150. return () => window.removeEventListener('keydown', handleKeyDown);
  151. }, [onClose]);
  152. useEffect(() => {
  153. setLoading(true);
  154. if (isLibrary) {
  155. const normalizedType = (fileType || '').toLowerCase();
  156. // A `.gcode.3mf` file is the slicer's sliced output — it carries
  157. // both the per-plate model (in `3D/3dmodel.model`) and the g-code
  158. // for the active plate (in `Metadata/plate_*.gcode`). The backend
  159. // library scan path (library.py) tags it `gcode.3mf` while the
  160. // upload path tags it `3mf`, so we accept both shapes here for
  161. // the 3D-tab + g-code-tab gating (#1543).
  162. const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
  163. const hasModel = isThreeMfFamily || normalizedType === 'stl';
  164. setCapabilities({
  165. has_model: hasModel,
  166. has_source: false,
  167. build_volume: { x: 256, y: 256, z: 256 },
  168. filament_colors: [],
  169. });
  170. setActiveTab(hasModel ? '3d' : null);
  171. setLoading(false);
  172. return;
  173. }
  174. if (!archiveId) {
  175. setCapabilities(null);
  176. setActiveTab(null);
  177. setLoading(false);
  178. return;
  179. }
  180. api.getArchiveCapabilities(archiveId)
  181. .then(caps => {
  182. setCapabilities(caps);
  183. // Auto-select the first available tab
  184. if (caps.has_model) {
  185. setActiveTab('3d');
  186. }
  187. setLoading(false);
  188. })
  189. .catch(() => {
  190. // Fallback to 3D model tab if capabilities check fails
  191. setCapabilities({ has_model: true, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
  192. setActiveTab('3d');
  193. setLoading(false);
  194. });
  195. }, [archiveId, fileType, isLibrary]);
  196. useEffect(() => {
  197. setPlatesLoading(true);
  198. setSelectedPlateId(null);
  199. setPlatePage(0);
  200. if (isLibrary) {
  201. const normalizedType = (fileType || '').toLowerCase();
  202. // Same 3mf-family gate as the capabilities branch above — sliced
  203. // `.gcode.3mf` files have plate metadata too (#1543).
  204. const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
  205. if (!libraryFileId || !isThreeMfFamily) {
  206. setPlatesData(null);
  207. setPlatesLoading(false);
  208. return;
  209. }
  210. api.getLibraryFilePlates(libraryFileId)
  211. .then((data) => setPlatesData(data))
  212. .catch(() => setPlatesData(null))
  213. .finally(() => setPlatesLoading(false));
  214. return;
  215. }
  216. if (!archiveId) {
  217. setPlatesData(null);
  218. setPlatesLoading(false);
  219. return;
  220. }
  221. api.getArchivePlates(archiveId)
  222. .then((data) => setPlatesData(data))
  223. .catch(() => setPlatesData(null))
  224. .finally(() => setPlatesLoading(false));
  225. }, [archiveId, fileType, isLibrary, libraryFileId]);
  226. const plates = useMemo(() => platesData?.plates ?? [], [platesData]);
  227. const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
  228. const splitFullscreen = isFullscreen && hasMultiplePlates;
  229. const selectedPlate: PlateMetadata | null = selectedPlateId == null
  230. ? null
  231. : plates.find((plate) => plate.index === selectedPlateId) ?? null;
  232. const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0;
  233. const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0);
  234. const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount;
  235. const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates');
  236. const hasObjectCount = plates.length > 0;
  237. const platesGridRef = useRef<HTMLDivElement>(null);
  238. const platesViewportRef = useRef<HTMLDivElement>(null);
  239. const [platesPerPage, setPlatesPerPage] = useState(10);
  240. const [plateColumns, setPlateColumns] = useState(3);
  241. const shouldPaginatePlates = plates.length > platesPerPage;
  242. const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage));
  243. const pagedPlates = shouldPaginatePlates
  244. ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage)
  245. : plates;
  246. useEffect(() => {
  247. if (!splitFullscreen) {
  248. setPlatesPerPage(10);
  249. setPlateColumns(3);
  250. return;
  251. }
  252. const grid = platesGridRef.current;
  253. const viewport = platesViewportRef.current;
  254. if (!grid || !viewport) return;
  255. let rafId = 0;
  256. const updateLayout = () => {
  257. const availableWidth = viewport.clientWidth;
  258. const minButtonWidth = 210;
  259. const computedCols = Math.floor(availableWidth / minButtonWidth);
  260. const nextCols = Math.max(3, Math.min(5, computedCols || 3));
  261. setPlateColumns((prev) => (prev === nextCols ? prev : nextCols));
  262. const computed = window.getComputedStyle(grid);
  263. const rowGap = Number.parseFloat(computed.rowGap || '0');
  264. const firstItem = grid.querySelector<HTMLElement>('button');
  265. const rowHeight = firstItem?.getBoundingClientRect().height ?? 44;
  266. const availableHeight = viewport.clientHeight;
  267. const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)));
  268. const maxSlots = rows * nextCols;
  269. const nextPerPage = Math.max(1, maxSlots - 1);
  270. setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage));
  271. };
  272. const scheduleUpdate = () => {
  273. if (rafId) cancelAnimationFrame(rafId);
  274. rafId = requestAnimationFrame(updateLayout);
  275. };
  276. scheduleUpdate();
  277. const resizeObserver = new ResizeObserver(scheduleUpdate);
  278. resizeObserver.observe(viewport);
  279. resizeObserver.observe(grid);
  280. return () => {
  281. if (rafId) cancelAnimationFrame(rafId);
  282. resizeObserver.disconnect();
  283. };
  284. }, [splitFullscreen, plates.length]);
  285. useEffect(() => {
  286. if (!shouldPaginatePlates) {
  287. setPlatePage(0);
  288. return;
  289. }
  290. setPlatePage((prev) => Math.min(prev, totalPlatePages - 1));
  291. }, [plates.length, shouldPaginatePlates, totalPlatePages]);
  292. useEffect(() => {
  293. if (!shouldPaginatePlates || selectedPlateId == null) return;
  294. const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId);
  295. if (selectedIndex < 0) return;
  296. const nextPage = Math.floor(selectedIndex / platesPerPage);
  297. setPlatePage((prev) => (prev === nextPage ? prev : nextPage));
  298. }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]);
  299. useEffect(() => {
  300. if (!splitFullscreen) {
  301. setPlatePanelHeight(null);
  302. setHasCustomSplit(false);
  303. return;
  304. }
  305. if (hasCustomSplit) return;
  306. const container = splitContainerRef.current;
  307. const panel = platesPanelRef.current;
  308. if (!container || !panel) return;
  309. const containerHeight = container.clientHeight;
  310. if (!containerHeight) return;
  311. const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
  312. const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
  313. const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight);
  314. setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight));
  315. }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
  316. useEffect(() => {
  317. if (!isDraggingDivider) return;
  318. const handleMouseMove = (event: MouseEvent) => {
  319. const container = splitContainerRef.current;
  320. if (!container) return;
  321. const rect = container.getBoundingClientRect();
  322. const containerHeight = rect.height;
  323. if (!containerHeight) return;
  324. const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
  325. const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
  326. const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top));
  327. setPlatePanelHeight(nextHeight);
  328. };
  329. const handleMouseUp = () => {
  330. setIsDraggingDivider(false);
  331. setHasCustomSplit(true);
  332. };
  333. document.addEventListener('mousemove', handleMouseMove);
  334. document.addEventListener('mouseup', handleMouseUp);
  335. document.body.style.cursor = 'row-resize';
  336. document.body.style.userSelect = 'none';
  337. return () => {
  338. document.removeEventListener('mousemove', handleMouseMove);
  339. document.removeEventListener('mouseup', handleMouseUp);
  340. document.body.style.cursor = '';
  341. document.body.style.userSelect = '';
  342. };
  343. }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
  344. // Which file types can be handed to a desktop slicer via the URL protocol
  345. // handler. Shares its list with `isSliceableFilename()`, which the File
  346. // Manager's card menu and list row use, so a file's "Slice" action and its
  347. // 3D-preview slicer button can no longer disagree about the same file.
  348. const slicerReadyType = isSliceableFileType(fileType);
  349. const canOpenInSlicer = isLibrary ? slicerReadyType : true;
  350. // The sidecar's list is narrower: its CLI cannot load STEP even though the
  351. // desktop GUI opens one fine, so in-app slicing is gated separately.
  352. const apiSlicerReadyType = isApiSliceableFileType(fileType);
  353. // When the user has the in-app Slicer API enabled (Settings → Workflow →
  354. // Slicer → Use Slicer API), library-mode previews route the header's slicer
  355. // button into Bambuddy's own SliceModal — same behaviour as the Cog button
  356. // in the file-row actions. Falls back to the external-slicer launcher when
  357. // the API is off, when no in-app handler is wired (e.g. archive preview),
  358. // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
  359. const useBambuddySlicer = Boolean(
  360. isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && apiSlicerReadyType,
  361. );
  362. const handleOpenInSlicer = async (slicer: SlicerType) => {
  363. if (!canOpenInSlicer) return;
  364. const filename = title || 'model';
  365. try {
  366. if (isLibrary) {
  367. const { token } = await api.createLibrarySlicerToken(libraryFileId!);
  368. const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
  369. openInSlicer(`${window.location.origin}${path}`, slicer);
  370. } else {
  371. const { token } = await api.createArchiveSlicerToken(archiveId!);
  372. const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
  373. openInSlicer(`${window.location.origin}${path}`, slicer);
  374. }
  375. } catch {
  376. // Fallback to direct URL (works when auth is disabled). With auth on the
  377. // slicer may then hit a 401, so surface the failure instead of making a
  378. // permission denial look identical to "no slicer installed".
  379. showToast(t('modelViewer.openInSlicerFailed'), 'error');
  380. if (isLibrary) {
  381. const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
  382. openInSlicer(downloadUrl, slicer);
  383. } else {
  384. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
  385. openInSlicer(downloadUrl, slicer);
  386. }
  387. }
  388. };
  389. const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
  390. ? ['bambu_studio', 'orcaslicer']
  391. : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
  392. const slicerName = (slicer: SlicerType) =>
  393. slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
  394. const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
  395. key: slicer,
  396. label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
  397. onClick: () => handleOpenInSlicer(slicer),
  398. }));
  399. return (
  400. <div
  401. className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
  402. onClick={onClose}
  403. >
  404. <div
  405. className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
  406. isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
  407. }`}
  408. onClick={(e) => e.stopPropagation()}
  409. >
  410. {/* Header */}
  411. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  412. <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
  413. <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
  414. {hasObjectCount && (
  415. <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
  416. {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
  417. </span>
  418. )}
  419. </div>
  420. <div className="flex items-center gap-2">
  421. {useBambuddySlicer ? (
  422. <SlicerSplitButton
  423. icon={<Cog className="w-4 h-4" />}
  424. label={t('slice.action')}
  425. dropdownLabel={t('modelViewer.moreSlicerOptions')}
  426. onPrimary={() => onSliceWithBambuddy?.()}
  427. items={slicerDropdownItems}
  428. />
  429. ) : canOpenInSlicer ? (
  430. <SlicerSplitButton
  431. icon={<ExternalLink className="w-4 h-4" />}
  432. label={t('modelViewer.openInSlicer')}
  433. dropdownLabel={t('modelViewer.moreSlicerOptions')}
  434. onPrimary={() => handleOpenInSlicer(preferredSlicer)}
  435. items={slicerDropdownItems}
  436. />
  437. ) : (
  438. <Button variant="secondary" size="sm" disabled>
  439. <ExternalLink className="w-4 h-4" />
  440. {t('modelViewer.openInSlicer')}
  441. </Button>
  442. )}
  443. <Button
  444. variant="secondary"
  445. size="sm"
  446. onClick={() => setIsFullscreen((prev) => !prev)}
  447. title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
  448. >
  449. {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
  450. </Button>
  451. <Button variant="ghost" size="sm" onClick={onClose}>
  452. <X className="w-5 h-5" />
  453. </Button>
  454. </div>
  455. </div>
  456. {/* Tabs - only show if we have capabilities */}
  457. {capabilities && (
  458. <div className="flex border-b border-bambu-dark-tertiary">
  459. <button
  460. onClick={() => capabilities.has_model && setActiveTab('3d')}
  461. disabled={!capabilities.has_model}
  462. className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
  463. activeTab === '3d'
  464. ? 'text-bambu-green border-b-2 border-bambu-green'
  465. : capabilities.has_model
  466. ? 'text-bambu-gray hover:text-white'
  467. : 'text-bambu-gray/30 cursor-not-allowed'
  468. }`}
  469. >
  470. <Box className="w-4 h-4" />
  471. {t('modelViewer.tabs.model')}
  472. {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
  473. </button>
  474. </div>
  475. )}
  476. {/* Viewer */}
  477. <div className="flex-1 overflow-hidden p-4">
  478. {loading ? (
  479. <div className="w-full h-full flex items-center justify-center">
  480. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  481. </div>
  482. ) : activeTab === '3d' && capabilities ? (
  483. <div
  484. ref={splitContainerRef}
  485. className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
  486. >
  487. {hasMultiplePlates && (
  488. <div
  489. ref={platesPanelRef}
  490. style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
  491. className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
  492. >
  493. <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
  494. <Layers className="w-4 h-4" />
  495. {t('modelViewer.plates')}
  496. {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
  497. </div>
  498. <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
  499. <div
  500. ref={platesViewportRef}
  501. className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
  502. >
  503. <div
  504. ref={platesGridRef}
  505. className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
  506. style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
  507. >
  508. <button
  509. type="button"
  510. onClick={() => setSelectedPlateId(null)}
  511. className={`flex items-center rounded-lg border text-left transition-colors ${
  512. splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
  513. } ${
  514. selectedPlateId == null
  515. ? 'border-bambu-green bg-bambu-green/10'
  516. : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
  517. }`}
  518. >
  519. <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
  520. splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
  521. }`}>
  522. <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
  523. </div>
  524. <div className="min-w-0 flex-1">
  525. <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
  526. <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
  527. {t('modelViewer.plateCount', { count: plates.length })}
  528. </p>
  529. </div>
  530. {selectedPlateId == null && (
  531. <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
  532. )}
  533. </button>
  534. {pagedPlates.map((plate) => (
  535. <button
  536. key={plate.index}
  537. type="button"
  538. onClick={() => setSelectedPlateId(plate.index)}
  539. className={`flex items-center rounded-lg border text-left transition-colors ${
  540. splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
  541. } ${
  542. selectedPlateId === plate.index
  543. ? 'border-bambu-green bg-bambu-green/10'
  544. : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
  545. }`}
  546. >
  547. {plate.has_thumbnail && plate.thumbnail_url ? (
  548. <img
  549. src={withStreamToken(plate.thumbnail_url)}
  550. alt={`Plate ${plate.index}`}
  551. className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
  552. />
  553. ) : (
  554. <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
  555. splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
  556. }`}>
  557. <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
  558. </div>
  559. )}
  560. <div className="min-w-0 flex-1">
  561. <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
  562. {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
  563. </p>
  564. <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
  565. {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
  566. </p>
  567. </div>
  568. {selectedPlateId === plate.index && (
  569. <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
  570. )}
  571. </button>
  572. ))}
  573. </div>
  574. </div>
  575. {(selectedPlate || shouldPaginatePlates) && (
  576. <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
  577. {selectedPlate && (
  578. <div className="flex items-center gap-3 whitespace-nowrap">
  579. <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
  580. {selectedPlate.print_time_seconds != null && (
  581. <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
  582. )}
  583. {selectedPlate.filament_used_grams != null && (
  584. <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
  585. )}
  586. {selectedPlate.filaments.length > 0 && (
  587. <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
  588. )}
  589. </div>
  590. )}
  591. {shouldPaginatePlates && (
  592. <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
  593. <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
  594. <div className="flex items-center gap-1">
  595. <button
  596. type="button"
  597. onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
  598. disabled={platePage === 0}
  599. className={`px-2 py-1 rounded border text-xs ${
  600. platePage === 0
  601. ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
  602. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  603. }`}
  604. >
  605. {t('modelViewer.pagination.prev')}
  606. </button>
  607. {(() => {
  608. const maxVisible = 5;
  609. let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
  610. const end = Math.min(totalPlatePages, start + maxVisible);
  611. if (end - start < maxVisible) {
  612. start = Math.max(0, end - maxVisible);
  613. }
  614. const pages = Array.from({ length: end - start }, (_, i) => start + i);
  615. return (
  616. <>
  617. {start > 0 && (
  618. <button
  619. type="button"
  620. onClick={() => setPlatePage(0)}
  621. className={`px-2 py-1 rounded border text-xs ${
  622. platePage === 0
  623. ? 'border-bambu-green text-bambu-green'
  624. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  625. }`}
  626. >
  627. 1
  628. </button>
  629. )}
  630. {start > 1 && <span className="px-1">…</span>}
  631. {pages.map((pageNumber) => (
  632. <button
  633. key={pageNumber}
  634. type="button"
  635. onClick={() => setPlatePage(pageNumber)}
  636. className={`px-2 py-1 rounded border text-xs ${
  637. platePage === pageNumber
  638. ? 'border-bambu-green text-bambu-green'
  639. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  640. }`}
  641. >
  642. {pageNumber + 1}
  643. </button>
  644. ))}
  645. {end < totalPlatePages - 1 && <span className="px-1">…</span>}
  646. {end < totalPlatePages && (
  647. <button
  648. type="button"
  649. onClick={() => setPlatePage(totalPlatePages - 1)}
  650. className={`px-2 py-1 rounded border text-xs ${
  651. platePage === totalPlatePages - 1
  652. ? 'border-bambu-green text-bambu-green'
  653. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  654. }`}
  655. >
  656. {totalPlatePages}
  657. </button>
  658. )}
  659. </>
  660. );
  661. })()}
  662. <button
  663. type="button"
  664. onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
  665. disabled={platePage >= totalPlatePages - 1}
  666. className={`px-2 py-1 rounded border text-xs ${
  667. platePage >= totalPlatePages - 1
  668. ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
  669. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  670. }`}
  671. >
  672. {t('modelViewer.pagination.next')}
  673. </button>
  674. </div>
  675. </div>
  676. )}
  677. </div>
  678. )}
  679. </div>
  680. </div>
  681. )}
  682. {splitFullscreen && (
  683. <div
  684. role="separator"
  685. aria-orientation="horizontal"
  686. onMouseDown={(event) => {
  687. event.preventDefault();
  688. setIsDraggingDivider(true);
  689. setHasCustomSplit(true);
  690. }}
  691. className={`h-2 cursor-row-resize flex items-center justify-center ${
  692. isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
  693. }`}
  694. >
  695. <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
  696. </div>
  697. )}
  698. <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
  699. <ModelViewer
  700. url={isLibrary
  701. ? api.getLibraryFileDownloadUrl(libraryFileId!)
  702. : (capabilities.has_source
  703. ? api.getSource3mfDownloadUrl(archiveId!)
  704. : api.getArchiveDownload(archiveId!))}
  705. fileType={fileType}
  706. buildVolume={capabilities.build_volume}
  707. filamentColors={capabilities.filament_colors}
  708. selectedPlateId={selectedPlateId}
  709. className="w-full h-full"
  710. />
  711. </div>
  712. </div>
  713. ) : (
  714. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  715. {t('modelViewer.noPreview')}
  716. </div>
  717. )}
  718. </div>
  719. </div>
  720. </div>
  721. );
  722. }