GcodeViewer.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
  2. import { WebGLPreview } from 'gcode-preview';
  3. import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
  4. import { getAuthToken } from '../api/client';
  5. interface GcodeViewerProps {
  6. gcodeUrl: string;
  7. buildVolume?: { x: number; y: number; z: number };
  8. filamentColors?: string[];
  9. className?: string;
  10. }
  11. export function GcodeViewer({
  12. gcodeUrl,
  13. buildVolume = { x: 256, y: 256, z: 256 },
  14. filamentColors,
  15. className = ''
  16. }: GcodeViewerProps) {
  17. const canvasRef = useRef<HTMLCanvasElement>(null);
  18. const previewRef = useRef<WebGLPreview | null>(null);
  19. const renderTimeoutRef = useRef<number | null>(null);
  20. const initRef = useRef(false);
  21. const [loading, setLoading] = useState(true);
  22. const [error, setError] = useState<string | null>(null);
  23. const [notSliced, setNotSliced] = useState(false);
  24. const [currentLayer, setCurrentLayer] = useState(0);
  25. const [totalLayers, setTotalLayers] = useState(0);
  26. // Memoize colors to prevent re-renders
  27. const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]);
  28. useEffect(() => {
  29. if (!canvasRef.current || initRef.current) return;
  30. initRef.current = true;
  31. const canvas = canvasRef.current;
  32. // Set canvas size before creating preview
  33. const rect = canvas.parentElement?.getBoundingClientRect();
  34. if (rect) {
  35. canvas.width = rect.width;
  36. canvas.height = rect.height;
  37. }
  38. // Use extrusionColor as array for multi-tool support
  39. // Index in array = tool number
  40. const hasMultiColor = filamentColors && filamentColors.length > 1;
  41. const primaryColor = filamentColors?.[0] || '#00ae42';
  42. // Create preview
  43. const preview = new WebGLPreview({
  44. canvas,
  45. buildVolume,
  46. backgroundColor: 0x1a1a1a,
  47. // Pass full color array - library uses index as tool number
  48. extrusionColor: hasMultiColor ? filamentColors : primaryColor,
  49. disableGradient: true,
  50. lineHeight: 0.2,
  51. lineWidth: 2,
  52. renderTravel: false,
  53. renderExtrusion: true,
  54. });
  55. previewRef.current = preview;
  56. // Fetch and process gcode
  57. const headers: HeadersInit = {};
  58. const token = getAuthToken();
  59. if (token) {
  60. headers['Authorization'] = `Bearer ${token}`;
  61. }
  62. fetch(gcodeUrl, { headers })
  63. .then(async response => {
  64. if (!response.ok) {
  65. if (response.status === 404) {
  66. const data = await response.json().catch(() => ({}));
  67. if (data.detail?.includes('sliced')) {
  68. setNotSliced(true);
  69. throw new Error('not_sliced');
  70. }
  71. }
  72. throw new Error('Failed to load G-code');
  73. }
  74. return response.text();
  75. })
  76. .then(gcode => {
  77. // The gcode-preview library only supports T0-T7
  78. // We need to remap higher tool numbers to fit within this range
  79. // First, find all unique tool numbers used
  80. const toolNumbers = new Set<number>();
  81. const toolRegex = /^(\s*)T(\d+)(\s*;.*)?$/gim;
  82. let match;
  83. while ((match = toolRegex.exec(gcode)) !== null) {
  84. const toolNum = parseInt(match[2], 10);
  85. if (toolNum <= 15) { // Valid tool, not a special command
  86. toolNumbers.add(toolNum);
  87. }
  88. }
  89. // Create a mapping from original tool numbers to 0-7 range
  90. const toolMapping = new Map<number, number>();
  91. const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b);
  92. sortedTools.forEach((tool, index) => {
  93. toolMapping.set(tool, index % 8); // Map to 0-7
  94. });
  95. // Build remapped color array based on the mapping
  96. const remappedColors: string[] = [];
  97. sortedTools.forEach((originalTool, index) => {
  98. const color = filamentColors?.[originalTool] || '#00ae42';
  99. remappedColors[index % 8] = color;
  100. });
  101. // Process gcode: filter special commands and remap tool numbers
  102. const cleanedGcode = gcode
  103. .split('\n')
  104. .map(line => {
  105. const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
  106. if (match) {
  107. const toolNum = parseInt(match[2], 10);
  108. if (toolNum > 15) {
  109. // Filter out Bambu special commands (T255, T1000, T65535, etc.)
  110. return `; FILTERED: ${line.trim()}`;
  111. }
  112. // Remap tool number to 0-7 range
  113. const mappedTool = toolMapping.get(toolNum) ?? 0;
  114. return `${match[1]}T${mappedTool}${match[3] || ''}`;
  115. }
  116. return line;
  117. })
  118. .join('\n');
  119. // Update colors for the preview using the remapped array
  120. if (remappedColors.length > 0) {
  121. (preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;
  122. }
  123. preview.processGCode(cleanedGcode);
  124. const layers = preview.layers?.length || 0;
  125. setTotalLayers(layers);
  126. setCurrentLayer(layers);
  127. preview.render();
  128. setLoading(false);
  129. })
  130. .catch(err => {
  131. if (err.message !== 'not_sliced') {
  132. setError(err.message);
  133. }
  134. setLoading(false);
  135. });
  136. // Handle resize
  137. const handleResize = () => {
  138. if (canvas.parentElement && previewRef.current) {
  139. const newRect = canvas.parentElement.getBoundingClientRect();
  140. canvas.width = newRect.width;
  141. canvas.height = newRect.height;
  142. previewRef.current.resize();
  143. }
  144. };
  145. window.addEventListener('resize', handleResize);
  146. return () => {
  147. window.removeEventListener('resize', handleResize);
  148. if (renderTimeoutRef.current) {
  149. cancelAnimationFrame(renderTimeoutRef.current);
  150. }
  151. if (previewRef.current) {
  152. previewRef.current.dispose();
  153. previewRef.current = null;
  154. }
  155. initRef.current = false;
  156. };
  157. // eslint-disable-next-line react-hooks/exhaustive-deps
  158. }, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes
  159. const handleLayerChange = useCallback((layer: number) => {
  160. if (!previewRef.current) return;
  161. const newLayer = Math.max(1, Math.min(layer, totalLayers));
  162. setCurrentLayer(newLayer);
  163. if (renderTimeoutRef.current) {
  164. cancelAnimationFrame(renderTimeoutRef.current);
  165. }
  166. renderTimeoutRef.current = requestAnimationFrame(() => {
  167. if (previewRef.current) {
  168. previewRef.current.endLayer = newLayer;
  169. previewRef.current.render();
  170. }
  171. });
  172. }, [totalLayers]);
  173. const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  174. handleLayerChange(parseInt(e.target.value, 10));
  175. };
  176. return (
  177. <div className={`relative flex flex-col h-full ${className}`}>
  178. <div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
  179. <canvas ref={canvasRef} className="w-full h-full" />
  180. {loading && (
  181. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  182. <div className="text-center">
  183. <Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
  184. <p className="text-bambu-gray text-sm">Loading G-code...</p>
  185. </div>
  186. </div>
  187. )}
  188. {notSliced && (
  189. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  190. <div className="text-center max-w-sm px-4">
  191. <FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
  192. <p className="text-white font-medium mb-2">G-code not available</p>
  193. <p className="text-bambu-gray text-sm">
  194. This file hasn't been sliced yet. G-code preview is only available
  195. after slicing in Bambu Studio or Orca Slicer.
  196. </p>
  197. </div>
  198. </div>
  199. )}
  200. {error && !notSliced && (
  201. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
  202. <div className="text-center text-red-400">
  203. <p className="text-sm">{error}</p>
  204. </div>
  205. </div>
  206. )}
  207. </div>
  208. {!loading && !error && !notSliced && totalLayers > 0 && (
  209. <div className="mt-4 px-2">
  210. <div className="flex items-center gap-3">
  211. <Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  212. <button
  213. onClick={() => handleLayerChange(currentLayer - 1)}
  214. disabled={currentLayer <= 1}
  215. className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
  216. >
  217. <ChevronLeft className="w-4 h-4" />
  218. </button>
  219. <input
  220. type="range"
  221. min={1}
  222. max={totalLayers}
  223. value={currentLayer}
  224. onChange={handleSliderChange}
  225. className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
  226. />
  227. <button
  228. onClick={() => handleLayerChange(currentLayer + 1)}
  229. disabled={currentLayer >= totalLayers}
  230. className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
  231. >
  232. <ChevronRight className="w-4 h-4" />
  233. </button>
  234. <span className="text-sm text-bambu-gray min-w-[80px] text-right">
  235. {currentLayer} / {totalLayers}
  236. </span>
  237. </div>
  238. </div>
  239. )}
  240. </div>
  241. );
  242. }