import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { WebGLPreview } from 'gcode-preview'; import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react'; import { getAuthToken } from '../api/client'; interface GcodeViewerProps { gcodeUrl: string; buildVolume?: { x: number; y: number; z: number }; filamentColors?: string[]; className?: string; } export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: GcodeViewerProps) { const canvasRef = useRef(null); const previewRef = useRef(null); const renderTimeoutRef = useRef(null); const initRef = useRef(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [notSliced, setNotSliced] = useState(false); const [currentLayer, setCurrentLayer] = useState(0); const [totalLayers, setTotalLayers] = useState(0); // Memoize colors to prevent re-renders const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]); useEffect(() => { if (!canvasRef.current || initRef.current) return; initRef.current = true; const canvas = canvasRef.current; // Set canvas size before creating preview const rect = canvas.parentElement?.getBoundingClientRect(); if (rect) { canvas.width = rect.width; canvas.height = rect.height; } // Use extrusionColor as array for multi-tool support // Index in array = tool number const hasMultiColor = filamentColors && filamentColors.length > 1; const primaryColor = filamentColors?.[0] || '#00ae42'; // Create preview const preview = new WebGLPreview({ canvas, buildVolume, backgroundColor: 0x1a1a1a, // Pass full color array - library uses index as tool number extrusionColor: hasMultiColor ? filamentColors : primaryColor, disableGradient: true, lineHeight: 0.2, lineWidth: 2, renderTravel: false, renderExtrusion: true, }); previewRef.current = preview; // Fetch and process gcode const headers: HeadersInit = {}; const token = getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } fetch(gcodeUrl, { headers }) .then(async response => { if (!response.ok) { if (response.status === 404) { const data = await response.json().catch(() => ({})); if (data.detail?.includes('sliced')) { setNotSliced(true); throw new Error('not_sliced'); } } throw new Error('Failed to load G-code'); } return response.text(); }) .then(gcode => { // The gcode-preview library only supports T0-T7 // We need to remap higher tool numbers to fit within this range // First, find all unique tool numbers used const toolNumbers = new Set(); const toolRegex = /^(\s*)T(\d+)(\s*;.*)?$/gim; let match; while ((match = toolRegex.exec(gcode)) !== null) { const toolNum = parseInt(match[2], 10); if (toolNum <= 15) { // Valid tool, not a special command toolNumbers.add(toolNum); } } // Create a mapping from original tool numbers to 0-7 range const toolMapping = new Map(); const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b); sortedTools.forEach((tool, index) => { toolMapping.set(tool, index % 8); // Map to 0-7 }); // Build remapped color array based on the mapping const remappedColors: string[] = []; sortedTools.forEach((originalTool, index) => { const color = filamentColors?.[originalTool] || '#00ae42'; remappedColors[index % 8] = color; }); // Process gcode: filter special commands and remap tool numbers const cleanedGcode = gcode .split('\n') .map(line => { const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i); if (match) { const toolNum = parseInt(match[2], 10); if (toolNum > 15) { // Filter out Bambu special commands (T255, T1000, T65535, etc.) return `; FILTERED: ${line.trim()}`; } // Remap tool number to 0-7 range const mappedTool = toolMapping.get(toolNum) ?? 0; return `${match[1]}T${mappedTool}${match[3] || ''}`; } return line; }) .join('\n'); // Update colors for the preview using the remapped array if (remappedColors.length > 0) { (preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors; } preview.processGCode(cleanedGcode); const layers = preview.layers?.length || 0; setTotalLayers(layers); setCurrentLayer(layers); preview.render(); setLoading(false); }) .catch(err => { if (err.message !== 'not_sliced') { setError(err.message); } setLoading(false); }); // Handle resize const handleResize = () => { if (canvas.parentElement && previewRef.current) { const newRect = canvas.parentElement.getBoundingClientRect(); canvas.width = newRect.width; canvas.height = newRect.height; previewRef.current.resize(); } }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (renderTimeoutRef.current) { cancelAnimationFrame(renderTimeoutRef.current); } if (previewRef.current) { previewRef.current.dispose(); previewRef.current = null; } initRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes const handleLayerChange = useCallback((layer: number) => { if (!previewRef.current) return; const newLayer = Math.max(1, Math.min(layer, totalLayers)); setCurrentLayer(newLayer); if (renderTimeoutRef.current) { cancelAnimationFrame(renderTimeoutRef.current); } renderTimeoutRef.current = requestAnimationFrame(() => { if (previewRef.current) { previewRef.current.endLayer = newLayer; previewRef.current.render(); } }); }, [totalLayers]); const handleSliderChange = (e: React.ChangeEvent) => { handleLayerChange(parseInt(e.target.value, 10)); }; return (
{loading && (

Loading G-code...

)} {notSliced && (

G-code not available

This file hasn't been sliced yet. G-code preview is only available after slicing in Bambu Studio or Orca Slicer.

)} {error && !notSliced && (

{error}

)}
{!loading && !error && !notSliced && totalLayers > 0 && (
{currentLayer} / {totalLayers}
)}
); }