mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|