ModelViewerModal.tsx 36 KB

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