Browse Source

Minor GCode viewer improvements

maziggy 4 months ago
parent
commit
c9ad09864d

+ 210 - 55
backend/app/api/routes/archives.py

@@ -1520,38 +1520,122 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
 
     has_model = False
     has_gcode = False
+    has_source = False
     build_volume = {"x": 256, "y": 256, "z": 256}  # Default to X1/P1 size
     filament_colors: list[str] = []
 
+    # Check if source 3MF exists - this is where actual mesh data typically lives
+    source_path = None
+    if archive.source_3mf_path:
+        source_path = settings.base_dir / archive.source_3mf_path
+        if source_path.exists():
+            has_source = True
+
+    # Helper function to check for mesh data and extract colors from a 3MF file
+    def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:
+        """Extract mesh presence, colors, and build volume from a 3MF file."""
+        found_mesh = False
+        colors: list[str] = []
+        volume = {"x": 256, "y": 256, "z": 256}
+
+        try:
+            with zipfile.ZipFile(zf_path, "r") as zf:
+                names = zf.namelist()
+
+                # Check for 3D model - look for actual mesh data
+                for name in names:
+                    if name.endswith(".model"):
+                        try:
+                            content = zf.read(name).decode("utf-8")
+                            if "<vertex" in content or "<mesh" in content:
+                                found_mesh = True
+                                break
+                        except Exception:
+                            pass
+
+                # Extract filament colors from project_settings.config
+                if "Metadata/project_settings.config" in names:
+                    try:
+                        config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
+                        config_data = json.loads(config_content)
+
+                        # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
+                        printable_area = config_data.get("printable_area", [])
+                        if printable_area and len(printable_area) >= 3:
+                            max_x = 0
+                            max_y = 0
+                            for coord in printable_area:
+                                if "x" in coord:
+                                    parts = coord.split("x")
+                                    if len(parts) == 2:
+                                        try:
+                                            x, y = int(parts[0]), int(parts[1])
+                                            max_x = max(max_x, x)
+                                            max_y = max(max_y, y)
+                                        except ValueError:
+                                            pass
+                            if max_x > 0 and max_y > 0:
+                                volume["x"] = max_x
+                                volume["y"] = max_y
+
+                        # Parse printable_height
+                        printable_height = config_data.get("printable_height")
+                        if printable_height:
+                            try:
+                                volume["z"] = int(printable_height)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Extract filament colors
+                        raw_colors = config_data.get("filament_colour", [])
+                        if raw_colors:
+                            for color in raw_colors:
+                                if color and isinstance(color, str):
+                                    colors.append(color)
+                    except Exception:
+                        pass
+        except zipfile.BadZipFile:
+            pass
+
+        return found_mesh, colors, volume
+
+    # First check source 3MF for mesh data and colors (preferred for 3D model viewing)
+    if has_source and source_path:
+        source_has_mesh, source_colors, source_volume = extract_3mf_info(source_path)
+        if source_has_mesh:
+            has_model = True
+        if source_colors:
+            filament_colors = source_colors
+        if source_volume["x"] != 256 or source_volume["y"] != 256 or source_volume["z"] != 256:
+            build_volume = source_volume
+
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             names = zf.namelist()
 
-            # Check for G-code
+            # Check for G-code in the sliced file
             has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
 
-            # Check for 3D model - need to look for actual mesh data
-            for name in names:
-                if name.endswith(".model"):
-                    try:
-                        content = zf.read(name).decode("utf-8")
-                        # Check if this model file contains actual mesh vertices
-                        if "<vertex" in content or "<mesh" in content:
-                            has_model = True
-                            break
-                    except Exception:
-                        pass
+            # Check for 3D model in sliced file (fallback if no source)
+            if not has_model:
+                for name in names:
+                    if name.endswith(".model"):
+                        try:
+                            content = zf.read(name).decode("utf-8")
+                            if "<vertex" in content or "<mesh" in content:
+                                has_model = True
+                                break
+                        except Exception:
+                            pass
 
-            # Extract filament colors from slice_info.config
+            # Extract filament colors from slice_info.config (for gcode preview)
             # These are the actual filaments used in the print, indexed by tool/extruder
+            slice_colors: list[str] = []
             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:
@@ -1563,59 +1647,66 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(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"))
+                            slice_colors.append(filament_map.get(i, "#00AE42"))
                 except Exception:
                     pass
 
-            # Extract build volume from project settings
-            if "Metadata/project_settings.config" in names:
-                try:
-                    config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
-                    config_data = json.loads(config_content)
-
-                    # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
-                    printable_area = config_data.get("printable_area", [])
-                    if printable_area and len(printable_area) >= 3:
-                        # Get max X and Y from the corner coordinates
-                        max_x = 0
-                        max_y = 0
-                        for coord in printable_area:
-                            if "x" in coord:
-                                parts = coord.split("x")
-                                if len(parts) == 2:
-                                    try:
-                                        x, y = int(parts[0]), int(parts[1])
-                                        max_x = max(max_x, x)
-                                        max_y = max(max_y, y)
-                                    except ValueError:
-                                        pass
-                        if max_x > 0 and max_y > 0:
-                            build_volume["x"] = max_x
-                            build_volume["y"] = max_y
-
-                    # Parse printable_height
-                    printable_height = config_data.get("printable_height")
-                    if printable_height:
-                        try:
-                            build_volume["z"] = int(printable_height)
-                        except (ValueError, TypeError):
-                            pass
-                except Exception:
-                    pass
+            # Use slice_info colors if we don't have colors from source yet
+            if not filament_colors and slice_colors:
+                filament_colors = slice_colors
+
+            # Extract build volume from sliced file if not already set from source
+            if build_volume["x"] == 256 and build_volume["y"] == 256:
+                if "Metadata/project_settings.config" in names:
+                    try:
+                        config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
+                        config_data = json.loads(config_content)
+
+                        printable_area = config_data.get("printable_area", [])
+                        if printable_area and len(printable_area) >= 3:
+                            max_x = 0
+                            max_y = 0
+                            for coord in printable_area:
+                                if "x" in coord:
+                                    parts = coord.split("x")
+                                    if len(parts) == 2:
+                                        try:
+                                            x, y = int(parts[0]), int(parts[1])
+                                            max_x = max(max_x, x)
+                                            max_y = max(max_y, y)
+                                        except ValueError:
+                                            pass
+                            if max_x > 0 and max_y > 0:
+                                build_volume["x"] = max_x
+                                build_volume["y"] = max_y
+
+                        printable_height = config_data.get("printable_height")
+                        if printable_height:
+                            try:
+                                build_volume["z"] = int(printable_height)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Fallback colors from project_settings if still empty
+                        if not filament_colors:
+                            raw_colors = config_data.get("filament_colour", [])
+                            if raw_colors:
+                                for color in raw_colors:
+                                    if color and isinstance(color, str):
+                                        filament_colors.append(color)
+                    except Exception:
+                        pass
 
     except zipfile.BadZipFile:
         raise HTTPException(400, "Invalid 3MF file")
@@ -1623,6 +1714,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
     return {
         "has_model": has_model,
         "has_gcode": has_gcode,
+        "has_source": has_source,
         "build_volume": build_volume,
         "filament_colors": filament_colors,
     }
@@ -1661,6 +1753,69 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
         raise HTTPException(500, f"Error extracting G-code: {str(e)}")
 
 
+@router.get("/{archive_id}/plate-preview")
+async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the plate preview image from the 3MF file.
+
+    Returns the slicer-generated plate thumbnail which shows the model
+    with correct colors and positioning.
+    """
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+
+            # Try to find plate preview images in order of preference
+            # First look for the specific plate being printed (check slice_info for plate index)
+            plate_num = 1
+            if "Metadata/slice_info.config" in names:
+                try:
+                    import xml.etree.ElementTree as ET
+
+                    slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
+                    root = ET.fromstring(slice_content)
+                    plate_elem = root.find(".//plate/metadata[@key='index']")
+                    if plate_elem is not None:
+                        plate_num = int(plate_elem.get("value", "1"))
+                except Exception:
+                    pass
+
+            # Try plate-specific image first, then fall back to plate_1
+            preview_paths = [
+                f"Metadata/plate_{plate_num}.png",
+                "Metadata/plate_1.png",
+                "Metadata/thumbnail.png",
+            ]
+
+            for preview_path in preview_paths:
+                if preview_path in names:
+                    image_data = zf.read(preview_path)
+                    return Response(content=image_data, media_type="image/png")
+
+            # If no plate image, try any PNG in Metadata
+            for name in names:
+                if name.startswith("Metadata/plate_") and name.endswith(".png") and "_small" not in name:
+                    image_data = zf.read(name)
+                    return Response(content=image_data, media_type="image/png")
+
+            raise HTTPException(404, "No plate preview found in 3MF file")
+
+    except zipfile.BadZipFile:
+        raise HTTPException(400, "Invalid 3MF file")
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(500, f"Error extracting plate preview: {str(e)}")
+
+
 @router.post("/upload")
 async def upload_archive(
     file: UploadFile = File(...),

+ 0 - 1
frontend/package-lock.json

@@ -4678,7 +4678,6 @@
       "version": "2.18.0",
       "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
       "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
-      "license": "MIT",
       "dependencies": {
         "lil-gui": "^0.19.2",
         "three": "^0.159.0"

+ 2 - 0
frontend/src/api/client.ts

@@ -1512,6 +1512,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
+  getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   scanArchiveTimelapse: (id: number) =>
     request<{
@@ -1640,6 +1641,7 @@ export const api = {
     request<{
       has_model: boolean;
       has_gcode: boolean;
+      has_source: boolean;
       build_volume: { x: number; y: number; z: number };
       filament_colors: string[];
     }>(`/archives/${id}/capabilities`),

+ 97 - 76
frontend/src/components/GcodeViewer.tsx

@@ -1,56 +1,60 @@
-import { useEffect, useRef, useState, useCallback } from 'react';
-import { WebGLPreview, init } from 'gcode-preview';
+import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+import { WebGLPreview } from 'gcode-preview';
 import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
 
-interface BuildVolume {
-  x: number;
-  y: number;
-  z: number;
-}
-
 interface GcodeViewerProps {
   gcodeUrl: string;
-  buildVolume?: BuildVolume;
+  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) {
+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) return;
+    if (!canvasRef.current || initRef.current) return;
+    initRef.current = true;
 
     const canvas = canvasRef.current;
 
-    const hasColors = filamentColors && filamentColors.length > 0;
-    const hasMultipleColors = filamentColors && filamentColors.length > 1;
+    // Set canvas size before creating preview
+    const rect = canvas.parentElement?.getBoundingClientRect();
+    if (rect) {
+      canvas.width = rect.width;
+      canvas.height = rect.height;
+    }
 
-    // First color or default bambu green
-    const primaryColor = hasColors ? filamentColors[0] : '#00ae42';
+    // Use extrusionColor as array for multi-tool support
+    // Index in array = tool number
+    const hasMultiColor = filamentColors && filamentColors.length > 1;
+    const primaryColor = 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({
+    // Create preview
+    const preview = new WebGLPreview({
       canvas,
-      buildVolume: buildVolume,
+      buildVolume,
       backgroundColor: 0x1a1a1a,
-      travelColor: 0x444444,
-      // 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,
+      // 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,
@@ -58,11 +62,7 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
 
     previewRef.current = preview;
 
-    // Fetch and parse G-code
-    setLoading(true);
-    setError(null);
-    setNotSliced(false);
-
+    // Fetch and process gcode
     fetch(gcodeUrl)
       .then(async response => {
         if (!response.ok) {
@@ -78,59 +78,83 @@ 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] || ''}`;
-                }
+        // 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()}`;
               }
-              return line;
-            })
-            .join('\n');
+              // Remap tool number to 0-7 range
+              const mappedTool = toolMapping.get(toolNum) ?? 0;
+              return `${match[1]}T${mappedTool}${match[3] || ''}`;
+            }
+            return line;
+          })
+          .join('\n');
 
-          // Prepend T0 to ensure initial tool is set
-          processedGcode = `T0\n${processedGcode}`;
+        // Update colors for the preview using the remapped array
+        if (remappedColors.length > 0) {
+          (preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;
         }
 
-        // Parse G-code
-        preview.processGCode(processedGcode);
+        preview.processGCode(cleanedGcode);
 
-        // Get layer count
         const layers = preview.layers?.length || 0;
         setTotalLayers(layers);
         setCurrentLayer(layers);
 
-        // Render all layers initially
         preview.render();
         setLoading(false);
       })
       .catch(err => {
-        setError(err.message);
+        if (err.message !== 'not_sliced') {
+          setError(err.message);
+        }
         setLoading(false);
       });
 
     // Handle resize
     const handleResize = () => {
-      if (canvas.parentElement) {
-        const rect = canvas.parentElement.getBoundingClientRect();
-        canvas.width = rect.width;
-        canvas.height = rect.height;
-        preview.resize();
+      if (canvas.parentElement && previewRef.current) {
+        const newRect = canvas.parentElement.getBoundingClientRect();
+        canvas.width = newRect.width;
+        canvas.height = newRect.height;
+        previewRef.current.resize();
       }
     };
 
-    handleResize();
     window.addEventListener('resize', handleResize);
 
     return () => {
@@ -138,17 +162,19 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
       if (renderTimeoutRef.current) {
         cancelAnimationFrame(renderTimeoutRef.current);
       }
-      preview.dispose();
+      if (previewRef.current) {
+        previewRef.current.dispose();
+        previewRef.current = null;
+      }
+      initRef.current = false;
     };
-  }, [gcodeUrl, buildVolume, filamentColors]);
+  }, [gcodeUrl, colorsKey]); // Use colorsKey instead of filamentColors
 
-  // Debounce render to prevent freezing when dragging slider
   const handleLayerChange = useCallback((layer: number) => {
     if (!previewRef.current) return;
     const newLayer = Math.max(1, Math.min(layer, totalLayers));
     setCurrentLayer(newLayer);
 
-    // Debounce the actual render to avoid freezing
     if (renderTimeoutRef.current) {
       cancelAnimationFrame(renderTimeoutRef.current);
     }
@@ -167,12 +193,8 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
 
   return (
     <div className={`relative flex flex-col h-full ${className}`}>
-      {/* Canvas container */}
       <div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
-        <canvas
-          ref={canvasRef}
-          className="w-full h-full"
-        />
+        <canvas ref={canvasRef} className="w-full h-full" />
 
         {loading && (
           <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
@@ -190,7 +212,7 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
               <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 the model in Bambu Studio or Orca Slicer.
+                after slicing in Bambu Studio or Orca Slicer.
               </p>
             </div>
           </div>
@@ -205,7 +227,6 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
         )}
       </div>
 
-      {/* Layer controls */}
       {!loading && !error && !notSliced && totalLayers > 0 && (
         <div className="mt-4 px-2">
           <div className="flex items-center gap-3">

+ 116 - 25
frontend/src/components/ModelViewer.tsx

@@ -15,22 +15,26 @@ interface BuildVolume {
 interface ModelViewerProps {
   url: string;
   buildVolume?: BuildVolume;
+  filamentColors?: string[];
   className?: string;
 }
 
 interface MeshData {
   vertices: number[];
   triangles: number[];
+  extruder: number; // Per-mesh extruder index for coloring
 }
 
 interface ObjectData {
   id: string;
   meshes: MeshData[];
+  defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
 }
 
 interface BuildItem {
   objectId: string;
   transform: THREE.Matrix4;
+  extruder?: number; // Can override object's extruder
 }
 
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
@@ -61,7 +65,7 @@ function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
 // Alias for backwards compatibility
 const parseTransform = parseTransform3MF;
 
-async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
+async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise<MeshData[]> {
   const meshes: MeshData[] = [];
   const meshElements = doc.getElementsByTagName('mesh');
 
@@ -91,7 +95,7 @@ async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
     }
 
     if (vertices.length > 0 && triangles.length > 0) {
-      meshes.push({ vertices, triangles });
+      meshes.push({ vertices, triangles, extruder: defaultExtruder });
     }
   }
   return meshes;
@@ -113,6 +117,56 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     return parser.parseFromString(content, 'application/xml');
   }
 
+  // Parse model_settings.config to get extruder assignments
+  // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
+  const extruderMapById = new Map<string, number>();
+  const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
+  const modelSettingsFile = zip.files['Metadata/model_settings.config'];
+  if (modelSettingsFile) {
+    try {
+      const content = await modelSettingsFile.async('string');
+      const doc = parser.parseFromString(content, 'application/xml');
+      const objectElements = doc.getElementsByTagName('object');
+      for (let i = 0; i < objectElements.length; i++) {
+        const objEl = objectElements[i];
+        const objectId = objEl.getAttribute('id');
+        if (!objectId) continue;
+
+        // Find object-level extruder
+        const directMetadata = Array.from(objEl.children).filter(
+          (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
+        );
+        if (directMetadata.length > 0) {
+          const extruderVal = directMetadata[0].getAttribute('value');
+          if (extruderVal) {
+            extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
+          }
+        }
+
+        // Find part-level extruders
+        const partElements = objEl.getElementsByTagName('part');
+        for (let j = 0; j < partElements.length; j++) {
+          const partEl = partElements[j];
+          const partId = partEl.getAttribute('id');
+          if (!partId) continue;
+
+          // Look for extruder in part's direct children
+          const partMetadata = Array.from(partEl.children).filter(
+            (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
+          );
+          if (partMetadata.length > 0) {
+            const extruderVal = partMetadata[0].getAttribute('value');
+            if (extruderVal) {
+              partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));
+            }
+          }
+        }
+      }
+    } catch (e) {
+      // Silently ignore model_settings.config parsing errors
+    }
+  }
+
   // Find the main 3D model file
   const mainModelPath = Object.keys(zip.files).find(
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
@@ -124,9 +178,9 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     if (anyModelPath) {
       const doc = await loadModelFile(anyModelPath);
       if (doc) {
-        const meshes = await parseMeshFromDoc(doc);
+        const meshes = await parseMeshFromDoc(doc, 0);
         if (meshes.length > 0) {
-          objects.set('1', { id: '1', meshes });
+          objects.set('1', { id: '1', meshes, defaultExtruder: 0 });
         }
       }
     }
@@ -143,6 +197,13 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     const objectId = objEl.getAttribute('id');
     if (!objectId) continue;
 
+    // Get default extruder from model_settings.config map, falling back to attribute or default
+    let defaultExtruder = extruderMapById.get(objectId) ?? -1;
+    if (defaultExtruder < 0) {
+      const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1';
+      defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
+    }
+
     const meshes: MeshData[] = [];
 
     // Check for direct mesh in this object
@@ -173,7 +234,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
       }
 
       if (vertices.length > 0 && triangles.length > 0) {
-        meshes.push({ vertices, triangles });
+        meshes.push({ vertices, triangles, extruder: defaultExtruder });
       }
     }
 
@@ -183,11 +244,17 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
       const compEl = componentElements[j];
       // p:path attribute contains the external file reference
       const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
+      // objectid in component corresponds to part id in model_settings
+      const compObjectId = compEl.getAttribute('objectid');
 
       if (extPath) {
         const extDoc = await loadModelFile(extPath);
         if (extDoc) {
-          const extMeshes = await parseMeshFromDoc(extDoc);
+          // Look up per-part extruder, falling back to object's default
+          const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
+          const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder;
+
+          const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
 
           // Apply component transform if present
           const compTransformStr = compEl.getAttribute('transform');
@@ -202,7 +269,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
                 v.applyMatrix4(compTransform);
                 transformedVertices.push(v.x, v.y, v.z);
               }
-              meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
+              meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });
             } else {
               meshes.push(mesh);
             }
@@ -212,7 +279,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     }
 
     if (meshes.length > 0) {
-      objects.set(objectId, { id: objectId, meshes });
+      objects.set(objectId, { id: objectId, meshes, defaultExtruder });
     }
   }
 
@@ -254,7 +321,7 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
   return geometry;
 }
 
-export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
+export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
   const containerRef = useRef<HTMLDivElement>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
@@ -345,14 +412,22 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
           throw new Error('No meshes found in 3MF file');
         }
 
-        const material = new THREE.MeshPhongMaterial({
-          color: 0x00ae42,
-          shininess: 30,
-          flatShading: false,
-        });
+        // Create materials for each extruder color
+        const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
+          const defaultColor = '#00ae42';
+          const colorStr = filamentColors?.[extruder] || defaultColor;
+          // Convert hex color string to THREE.js color
+          const color = new THREE.Color(colorStr);
+          return new THREE.MeshPhongMaterial({
+            color,
+            shininess: 30,
+            flatShading: false,
+          });
+        };
 
         const group = new THREE.Group();
-        const allGeometries: THREE.BufferGeometry[] = [];
+        // Group geometries by extruder index (using per-mesh extruder)
+        const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
 
         // If we have build items, use them for positioning
         if (buildItems.length > 0) {
@@ -361,6 +436,9 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
             if (!objectData) continue;
 
             for (const meshData of objectData.meshes) {
+              // Use mesh's extruder, or item override, or object default
+              const extruder = item.extruder ?? meshData.extruder;
+
               // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
               const transformedVertices: number[] = [];
               for (let k = 0; k < meshData.vertices.length; k += 3) {
@@ -376,34 +454,47 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
               const geometry = createGeometryFromMesh({
                 vertices: transformedVertices,
                 triangles: meshData.triangles,
+                extruder: extruder,
               });
-              allGeometries.push(geometry);
+
+              if (!geometriesByExtruder.has(extruder)) {
+                geometriesByExtruder.set(extruder, []);
+              }
+              geometriesByExtruder.get(extruder)!.push(geometry);
             }
           }
         } else {
           // Fallback: just add all objects without transforms
           for (const objectData of objects.values()) {
             for (const meshData of objectData.meshes) {
+              // Use per-mesh extruder
+              const extruder = meshData.extruder;
               const geometry = createGeometryFromMesh(meshData);
-              allGeometries.push(geometry);
+              if (!geometriesByExtruder.has(extruder)) {
+                geometriesByExtruder.set(extruder, []);
+              }
+              geometriesByExtruder.get(extruder)!.push(geometry);
             }
           }
         }
 
-        // Merge all geometries into one for better performance
-        if (allGeometries.length > 0) {
-          const mergedGeometry = allGeometries.length === 1
-            ? allGeometries[0]
-            : mergeGeometries(allGeometries, false);
+        // Create meshes for each extruder group
+        for (const [extruder, geometries] of geometriesByExtruder) {
+          if (geometries.length === 0) continue;
+
+          const mergedGeometry = geometries.length === 1
+            ? geometries[0]
+            : mergeGeometries(geometries, false);
 
           if (mergedGeometry) {
+            const material = getMaterial(extruder);
             const mesh = new THREE.Mesh(mergedGeometry, material);
             group.add(mesh);
           }
 
           // Dispose individual geometries if merged
-          if (allGeometries.length > 1) {
-            for (const geom of allGeometries) {
+          if (geometries.length > 1) {
+            for (const geom of geometries) {
               geom.dispose();
             }
           }
@@ -465,7 +556,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
       renderer.dispose();
       container.removeChild(renderer.domElement);
     };
-  }, [url, buildVolume]);
+  }, [url, buildVolume, filamentColors]);
 
   const resetView = () => {
     if (cameraRef.current && controlsRef.current) {

+ 6 - 3
frontend/src/components/ModelViewerModal.tsx

@@ -17,6 +17,7 @@ interface ModelViewerModalProps {
 interface Capabilities {
   has_model: boolean;
   has_gcode: boolean;
+  has_source: boolean;
   build_volume: { x: number; y: number; z: number };
   filament_colors: string[];
 }
@@ -49,7 +50,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 }, filament_colors: [] });
+        setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
         setActiveTab('3d');
         setLoading(false);
       });
@@ -129,14 +130,16 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
             </div>
           ) : activeTab === '3d' && capabilities ? (
             <ModelViewer
-              url={api.getArchiveDownload(archiveId)}
+              url={capabilities.has_source
+                ? api.getSource3mfDownloadUrl(archiveId)
+                : api.getArchiveDownload(archiveId)}
               buildVolume={capabilities.build_volume}
+              filamentColors={capabilities.filament_colors}
               className="w-full h-full"
             />
           ) : activeTab === 'gcode' && capabilities ? (
             <GcodeViewer
               gcodeUrl={api.getArchiveGcode(archiveId)}
-              buildVolume={capabilities.build_volume}
               filamentColors={capabilities.filament_colors}
               className="w-full h-full"
             />

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-KNsnljef.js


+ 1 - 1
static/index.html

@@ -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-DZu3XO3T.js"></script>
+    <script type="module" crossorigin src="/assets/index-KNsnljef.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-j3n1gAEX.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff