| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- 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<HTMLCanvasElement>(null);
- const previewRef = useRef<WebGLPreview | null>(null);
- const renderTimeoutRef = useRef<number | null>(null);
- const initRef = useRef(false);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(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<number>();
- 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<number, number>();
- 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<HTMLInputElement>) => {
- handleLayerChange(parseInt(e.target.value, 10));
- };
- return (
- <div className={`relative flex flex-col h-full ${className}`}>
- <div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
- <canvas ref={canvasRef} className="w-full h-full" />
- {loading && (
- <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
- <div className="text-center">
- <Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
- <p className="text-bambu-gray text-sm">Loading G-code...</p>
- </div>
- </div>
- )}
- {notSliced && (
- <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
- <div className="text-center max-w-sm px-4">
- <FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
- <p className="text-white font-medium mb-2">G-code not available</p>
- <p className="text-bambu-gray text-sm">
- This file hasn't been sliced yet. G-code preview is only available
- after slicing in Bambu Studio or Orca Slicer.
- </p>
- </div>
- </div>
- )}
- {error && !notSliced && (
- <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
- <div className="text-center text-red-400">
- <p className="text-sm">{error}</p>
- </div>
- </div>
- )}
- </div>
- {!loading && !error && !notSliced && totalLayers > 0 && (
- <div className="mt-4 px-2">
- <div className="flex items-center gap-3">
- <Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
- <button
- onClick={() => handleLayerChange(currentLayer - 1)}
- disabled={currentLayer <= 1}
- className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
- >
- <ChevronLeft className="w-4 h-4" />
- </button>
- <input
- type="range"
- min={1}
- max={totalLayers}
- value={currentLayer}
- onChange={handleSliderChange}
- className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
- />
- <button
- onClick={() => handleLayerChange(currentLayer + 1)}
- disabled={currentLayer >= totalLayers}
- className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
- >
- <ChevronRight className="w-4 h-4" />
- </button>
- <span className="text-sm text-bambu-gray min-w-[80px] text-right">
- {currentLayer} / {totalLayers}
- </span>
- </div>
- </div>
- )}
- </div>
- );
- }
|