GCodeViewerPage.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { useMemo } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useNavigate, useSearchParams } from 'react-router-dom';
  4. import { ArrowLeft } from 'lucide-react';
  5. import { useTranslation } from 'react-i18next';
  6. import { api } from '../api/client';
  7. import { GcodeToolpathViewer } from '../components/GcodeToolpathViewer';
  8. /**
  9. * Full-page G-code preview.
  10. *
  11. * Previously an iframe onto a vendored copy of PrettyGCode served from
  12. * `/gcode-viewer/`. That brought its own problems -- a second viewer to keep
  13. * packaged and updated, no way to theme or translate it, and a whole
  14. * frame-refusal probe to detect when a proxy blocked the embed -- and its
  15. * output was the thing this page exists to show.
  16. *
  17. * It now renders Bambuddy's own toolpath viewer, which draws with OrcaSlicer's
  18. * `libvgcode` and colours by feature. Same component as the file-manager
  19. * preview, so the two surfaces cannot drift apart.
  20. */
  21. export function GCodeViewerPage() {
  22. const navigate = useNavigate();
  23. const [searchParams, setSearchParams] = useSearchParams();
  24. const { t } = useTranslation();
  25. const archiveId = searchParams.get('archive');
  26. const libraryFileId = searchParams.get('library_file');
  27. const plate = searchParams.get('plate');
  28. // Filament colours, so a multi-material print opens on its own colours.
  29. // The two sources differ: an archive reports them through its capabilities,
  30. // while a library file carries them in its plate metadata, read straight out
  31. // of the 3MF's slice info. Neither is worth blocking the preview over -- the
  32. // viewer falls back to feature colouring -- hence no retry and no error path.
  33. const archiveColorsQuery = useQuery({
  34. queryKey: ['gcode-viewer-archive-colors', archiveId],
  35. queryFn: () => api.getArchiveCapabilities(Number(archiveId)),
  36. enabled: Boolean(archiveId),
  37. staleTime: 5 * 60_000,
  38. retry: false,
  39. });
  40. const libraryPlatesQuery = useQuery({
  41. queryKey: ['gcode-viewer-library-colors', libraryFileId],
  42. queryFn: () => api.getLibraryFilePlates(Number(libraryFileId)),
  43. enabled: Boolean(libraryFileId),
  44. staleTime: 5 * 60_000,
  45. retry: false,
  46. });
  47. const archivePlatesQuery = useQuery({
  48. queryKey: ['gcode-viewer-archive-plates', archiveId],
  49. queryFn: () => api.getArchivePlates(Number(archiveId)),
  50. enabled: Boolean(archiveId),
  51. staleTime: 5 * 60_000,
  52. retry: false,
  53. });
  54. const plates = useMemo(
  55. () => (archiveId ? archivePlatesQuery.data?.plates : libraryPlatesQuery.data?.plates) ?? [],
  56. [archiveId, archivePlatesQuery.data, libraryPlatesQuery.data],
  57. );
  58. // Which plate the viewer is showing. Without a `plate` in the URL the backend
  59. // serves the lowest-numbered one, so that is what the switcher has to mark as
  60. // current — the URL stays clean until the user picks something else.
  61. const activePlate = useMemo(() => {
  62. if (plate) return Number(plate);
  63. if (plates.length === 0) return null;
  64. return Math.min(...plates.map((p) => p.index));
  65. }, [plate, plates]);
  66. const selectPlate = (index: number) => {
  67. // The G-code URL is derived from this parameter, so writing the one already
  68. // being shown would refetch the whole toolpath for no change.
  69. if (index === activePlate) return;
  70. const next = new URLSearchParams(searchParams);
  71. next.set('plate', String(index));
  72. setSearchParams(next, { replace: true });
  73. };
  74. const filamentColors = useMemo<string[] | undefined>(() => {
  75. if (archiveId) return archiveColorsQuery.data?.filament_colors;
  76. // Colours are per plate; use the one being previewed.
  77. const source = plates.find((p) => p.index === activePlate) || plates[0];
  78. if (!source?.filaments?.length) return undefined;
  79. // slot_id is 1-based and the G-code's tool numbers are 0-based, so index
  80. // by slot - 1 or every colour lands one filament out.
  81. const colors: string[] = [];
  82. for (const filament of source.filaments) {
  83. const slot = Math.max(0, (filament.slot_id ?? 1) - 1);
  84. if (filament.color) colors[slot] = filament.color;
  85. }
  86. return colors.length > 0 ? colors : undefined;
  87. }, [archiveId, archiveColorsQuery.data, plates, activePlate]);
  88. const gcodeUrl = useMemo(() => {
  89. // Multi-plate sources need the plate carried through, or the viewer shows
  90. // whichever plate the backend defaults to rather than the one picked.
  91. const withPlate = (base: string) => (plate ? `${base}?plate=${encodeURIComponent(plate)}` : base);
  92. if (archiveId) return withPlate(api.getArchiveGcode(Number(archiveId)));
  93. if (libraryFileId) return withPlate(api.getLibraryFileGcodeUrl(Number(libraryFileId)));
  94. return null;
  95. }, [archiveId, libraryFileId, plate]);
  96. const handleBack = () => {
  97. if (window.history.length > 1) navigate(-1);
  98. else navigate(archiveId ? '/archives' : '/files');
  99. };
  100. const backLabel = archiveId
  101. ? t('gcodeViewer.backToArchives', 'Back to Archives')
  102. : t('gcodeViewer.backToFiles', 'Back to File Manager');
  103. // The viewer pane is `flex-1 min-h-0`, which only means anything if this
  104. // column has a height to divide. `h-full` did not give it one: it resolves
  105. // against `<main>`, whose own height comes from `flex-1` under a
  106. // `min-h-screen` root -- a minimum, so the height property stays `auto` and
  107. // the percentage falls through to content (#2887). Same viewport-minus-header
  108. // pattern the File Manager page uses, with `min-h` keeping the toolbar
  109. // reachable on short screens where a hard height would clip it.
  110. return (
  111. <div className="flex flex-col min-h-[calc(100vh-64px)] lg:h-screen">
  112. <div className="flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2">
  113. <button
  114. type="button"
  115. onClick={handleBack}
  116. className="inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
  117. >
  118. <ArrowLeft className="w-4 h-4" />
  119. {backLabel}
  120. </button>
  121. {/* A sliced multi-plate 3MF holds one toolpath per plate, and only one
  122. of them can be on screen. Without this the other plates were
  123. unreachable: nothing that opens this page from the File Manager
  124. passes a plate, so it showed whichever one the backend picked. */}
  125. {plates.length > 1 && (
  126. <div className="flex flex-wrap items-center gap-1.5">
  127. <span className="text-xs text-bambu-gray">{t('gcodeViewer.plates', 'Plates')}</span>
  128. {plates.map((p) => (
  129. <button
  130. key={p.index}
  131. type="button"
  132. onClick={() => selectPlate(p.index)}
  133. aria-pressed={p.index === activePlate}
  134. title={p.name ?? undefined}
  135. className={`px-2 py-0.5 rounded text-xs transition-colors ${
  136. p.index === activePlate
  137. ? 'bg-bambu-green text-white'
  138. : 'bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
  139. }`}
  140. >
  141. {t('gcodeViewer.plateN', 'Plate {{n}}', { n: p.index })}
  142. </button>
  143. ))}
  144. </div>
  145. )}
  146. </div>
  147. {gcodeUrl ? (
  148. <GcodeToolpathViewer
  149. gcodeUrl={gcodeUrl}
  150. filamentColors={filamentColors}
  151. className="flex-1 min-h-0"
  152. />
  153. ) : (
  154. <div className="flex-1 flex items-center justify-center text-sm text-bambu-gray">
  155. {t('gcodeViewer.noSource', 'No file was given to preview.')}
  156. </div>
  157. )}
  158. </div>
  159. );
  160. }