Summary of Changes

Backend (backend/app/api/routes/archives.py):
  - Added filament_colors extraction from slice_info.config in 3MF files
  - Maps filament IDs (1-based) to tool numbers (0-based)

  Frontend (frontend/src/components/GcodeViewer.tsx):
  1. Multi-color support: Pass extrusionColor as array of CSS color strings for multi-color prints
  2. Bambu T-command filtering: Filter out special Bambu commands (T255, T1000, T1001, T65535, T65279) that corrupt tool state
  3. Initial tool: Prepend T0 to ensure initial tool is set correctly
  4. Disable gradient: Set disableGradient: true for multi-color to preserve actual filament colors (the gradient was turning black into gray/white)
  5. Disable topLayerColor: Don't set topLayerColor for multi-color (it overrides per-tool colors)

  Key fixes for Bambu G-code compatibility:
  - Bambu uses special T commands (T1000, T65535, etc.) that aren't real tool changes
  - The gcode-preview library's brightness gradient was modifying colors based on layer index

  Commit message suggestion:
  Add multi-color filament support to G-code viewer

  - Extract filament colors from 3MF slice_info.config
  - Pass color array to gcode-preview for tool-based coloring
  - Filter Bambu special T commands (T1000, T65535, etc.)
  - Disable gradient to preserve actual filament colors
This commit is contained in:
maziggy
2025-12-16 12:30:58 +01:00
parent 5cd366ceac
commit c8b9a8e3d6
6 changed files with 123 additions and 37 deletions
+42
View File
@@ -1325,6 +1325,7 @@ async def get_qrcode(
async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Check what viewing capabilities are available for this 3MF file."""
import json
import xml.etree.ElementTree as ET
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -1338,6 +1339,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
has_model = False
has_gcode = False
build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
filament_colors: list[str] = []
try:
with zipfile.ZipFile(file_path, "r") as zf:
@@ -1358,6 +1360,45 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
except Exception:
pass
# Extract filament colors from slice_info.config
# These are the actual filaments used in the print, indexed by tool/extruder
if "Metadata/slice_info.config" in names:
try:
slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
root = ET.fromstring(slice_content)
# Get all filaments with their IDs and colors
# <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
# ID corresponds to the tool number in G-code (T0, T1, etc.)
filaments = root.findall(".//filament")
filament_map: dict[int, str] = {}
for f in filaments:
fid = f.get("id")
fcolor = f.get("color")
used_g = f.get("used_g", "0")
try:
used_amount = float(used_g)
except (ValueError, TypeError):
used_amount = 0
# Include all filaments, but mark unused ones
if fid is not None and fcolor:
try:
# IDs are 1-based in slice_info, tools are 0-based
tool_id = int(fid) - 1
if tool_id >= 0 and used_amount > 0:
filament_map[tool_id] = fcolor
except ValueError:
pass
# Convert to ordered list (tool 0, tool 1, etc.)
if filament_map:
max_tool = max(filament_map.keys())
for i in range(max_tool + 1):
filament_colors.append(filament_map.get(i, "#00AE42"))
except Exception:
pass
# Extract build volume from project settings
if "Metadata/project_settings.config" in names:
try:
@@ -1401,6 +1442,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
"has_model": has_model,
"has_gcode": has_gcode,
"build_volume": build_volume,
"filament_colors": filament_colors,
}
+1
View File
@@ -1380,6 +1380,7 @@ export const api = {
has_model: boolean;
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
filament_colors: string[];
}>(`/archives/${id}/capabilities`),
// Project Page
getArchiveProjectPage: (id: number) =>
+43 -5
View File
@@ -11,10 +11,11 @@ interface BuildVolume {
interface GcodeViewerProps {
gcodeUrl: string;
buildVolume?: BuildVolume;
filamentColors?: string[];
className?: string;
}
export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: GcodeViewerProps) {
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 [loading, setLoading] = useState(true);
@@ -28,14 +29,26 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
const canvas = canvasRef.current;
const hasColors = filamentColors && filamentColors.length > 0;
const hasMultipleColors = filamentColors && filamentColors.length > 1;
// First color or default bambu green
const primaryColor = hasColors ? filamentColors[0] : '#00ae42';
// Initialize the preview
// For multi-color: pass array of CSS color strings to extrusionColor
// The library uses index to match tool number (T0, T1, T2...)
const preview = init({
canvas,
buildVolume: buildVolume,
backgroundColor: 0x1a1a1a,
travelColor: 0x444444,
extrusionColor: 0x00ae42,
topLayerColor: 0x00ff5a,
// Pass array for multi-color, single value for single color
extrusionColor: hasMultipleColors ? filamentColors : primaryColor,
// Disable topLayerColor for multi-color (it overrides per-tool colors)
...(hasMultipleColors ? {} : { topLayerColor: primaryColor }),
// Disable gradient for multi-color to preserve actual filament colors
...(hasMultipleColors ? { disableGradient: true } : {}),
lastSegmentColor: 0xffffff,
lineWidth: 2,
renderTravel: false,
@@ -64,8 +77,33 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
return response.text();
})
.then(gcode => {
let processedGcode = gcode;
if (hasMultipleColors) {
// Bambu G-code uses special T commands that confuse the parser:
// T255, T1000, T1001, T65535, T65279 etc. are not real tool changes
// Filter these out and keep only valid tool numbers (T0-T15)
processedGcode = gcode
.split('\n')
.map(line => {
const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
if (match) {
const toolNum = parseInt(match[2], 10);
// Keep only valid tool numbers (0-15), comment out others
if (toolNum > 15) {
return `${match[1]}; FILTERED: T${toolNum}${match[3] || ''}`;
}
}
return line;
})
.join('\n');
// Prepend T0 to ensure initial tool is set
processedGcode = `T0\n${processedGcode}`;
}
// Parse G-code
preview.processGCode(gcode);
preview.processGCode(processedGcode);
// Get layer count
const layers = preview.layers?.length || 0;
@@ -98,7 +136,7 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
window.removeEventListener('resize', handleResize);
preview.dispose();
};
}, [gcodeUrl, buildVolume]);
}, [gcodeUrl, buildVolume, filamentColors]);
const handleLayerChange = (layer: number) => {
if (!previewRef.current) return;
+3 -1
View File
@@ -17,6 +17,7 @@ interface Capabilities {
has_model: boolean;
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
filament_colors: string[];
}
export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
@@ -47,7 +48,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
})
.catch(() => {
// Fallback to 3D model tab if capabilities check fails
setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 } });
setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
setActiveTab('3d');
setLoading(false);
});
@@ -136,6 +137,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
<GcodeViewer
gcodeUrl={api.getArchiveGcode(archiveId)}
buildVolume={capabilities.build_volume}
filamentColors={capabilities.filament_colors}
className="w-full h-full"
/>
) : (
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-AT42oZVR.js"></script>
<script type="module" crossorigin src="/assets/index-RbSkJqNe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CbCN6LSA.css">
</head>
<body>