diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 0e063fcea..49470c688 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1964,15 +1964,189 @@ async def upload_archives_bulk( } +@router.get("/{archive_id}/plates") +async def get_archive_plates( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Get available plates from a multi-plate 3MF archive. + + Returns a list of plates with their index, name, thumbnail availability, + and filament requirements. For single-plate exports, returns a single plate. + """ + import xml.etree.ElementTree as ET + + 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, "Archive file not found") + + plates = [] + + try: + with zipfile.ZipFile(file_path, "r") as zf: + namelist = zf.namelist() + + # Find all plate gcode files to determine available plates + gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")] + + if not gcode_files: + # No sliced plates found + return {"archive_id": archive_id, "filename": archive.filename, "plates": []} + + # Extract plate indices from gcode filenames + plate_indices = [] + for gf in gcode_files: + # "Metadata/plate_5.gcode" -> 5 + try: + plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode" + plate_indices.append(int(plate_str)) + except ValueError: + pass + + plate_indices.sort() + + # Parse slice_info.config for plate metadata + plate_metadata = {} # plate_index -> {filaments, prediction, weight, name} + if "Metadata/slice_info.config" in namelist: + content = zf.read("Metadata/slice_info.config").decode() + root = ET.fromstring(content) + + for plate_elem in root.findall(".//plate"): + plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None} + + # Get plate index from metadata + plate_index = None + for meta in plate_elem.findall("metadata"): + key = meta.get("key") + value = meta.get("value") + if key == "index" and value: + try: + plate_index = int(value) + except ValueError: + pass + elif key == "prediction" and value: + try: + plate_info["prediction"] = int(value) + except ValueError: + pass + elif key == "weight" and value: + try: + plate_info["weight"] = float(value) + except ValueError: + pass + + # Get filaments used in this plate + for filament_elem in plate_elem.findall("filament"): + filament_id = filament_elem.get("id") + filament_type = filament_elem.get("type", "") + filament_color = filament_elem.get("color", "") + used_g = filament_elem.get("used_g", "0") + used_m = filament_elem.get("used_m", "0") + + try: + used_grams = float(used_g) + except (ValueError, TypeError): + used_grams = 0 + + if used_grams > 0 and filament_id: + plate_info["filaments"].append( + { + "slot_id": int(filament_id), + "type": filament_type, + "color": filament_color, + "used_grams": round(used_grams, 1), + "used_meters": float(used_m) if used_m else 0, + } + ) + + # Sort filaments by slot ID + plate_info["filaments"].sort(key=lambda x: x["slot_id"]) + + # Get first object name as plate name hint + first_obj = plate_elem.find("object") + if first_obj is not None: + plate_info["name"] = first_obj.get("name") + + if plate_index is not None: + plate_metadata[plate_index] = plate_info + + # Build plate list + for idx in plate_indices: + meta = plate_metadata.get(idx, {}) + has_thumbnail = f"Metadata/plate_{idx}.png" in namelist + + plates.append( + { + "index": idx, + "name": meta.get("name"), + "has_thumbnail": has_thumbnail, + "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}" + if has_thumbnail + else None, + "print_time_seconds": meta.get("prediction"), + "filament_used_grams": meta.get("weight"), + "filaments": meta.get("filaments", []), + } + ) + + except Exception as e: + logger.warning(f"Failed to parse plates from archive {archive_id}: {e}") + + return { + "archive_id": archive_id, + "filename": archive.filename, + "plates": plates, + "is_multi_plate": len(plates) > 1, + } + + +@router.get("/{archive_id}/plate-thumbnail/{plate_index}") +async def get_plate_thumbnail( + archive_id: int, + plate_index: int, + db: AsyncSession = Depends(get_db), +): + """Get the thumbnail image for a specific plate.""" + 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, "Archive file not found") + + try: + with zipfile.ZipFile(file_path, "r") as zf: + thumb_path = f"Metadata/plate_{plate_index}.png" + if thumb_path in zf.namelist(): + data = zf.read(thumb_path) + return Response(content=data, media_type="image/png") + except Exception: + pass + + raise HTTPException(404, f"Thumbnail for plate {plate_index} not found") + + @router.get("/{archive_id}/filament-requirements") async def get_filament_requirements( archive_id: int, + plate_id: int | None = None, db: AsyncSession = Depends(get_db), ): """Get filament requirements from the archived 3MF file. Returns the filaments used in this print with their slot IDs, types, colors, and usage amounts. This can be compared with current AMS state before reprinting. + + Args: + archive_id: The archive ID + plate_id: Optional plate index to filter filaments for (for multi-plate files) """ import xml.etree.ElementTree as ET @@ -1994,31 +2168,70 @@ async def get_filament_requirements( content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) - # Extract filament elements - # Format: - for filament_elem in root.findall(".//filament"): - filament_id = filament_elem.get("id") - filament_type = filament_elem.get("type", "") - filament_color = filament_elem.get("color", "") - used_g = filament_elem.get("used_g", "0") - used_m = filament_elem.get("used_m", "0") + # If plate_id is specified, find filaments for that specific plate + if plate_id is not None: + # Find the plate element with matching index + for plate_elem in root.findall(".//plate"): + plate_index = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + try: + plate_index = int(meta.get("value", "0")) + except ValueError: + pass + break - # Only include filaments that are actually used - try: - used_grams = float(used_g) - except (ValueError, TypeError): - used_grams = 0 + if plate_index == plate_id: + # Extract filaments from this plate element + for filament_elem in plate_elem.findall("filament"): + filament_id = filament_elem.get("id") + filament_type = filament_elem.get("type", "") + filament_color = filament_elem.get("color", "") + used_g = filament_elem.get("used_g", "0") + used_m = filament_elem.get("used_m", "0") - if used_grams > 0 and filament_id: - filaments.append( - { - "slot_id": int(filament_id), - "type": filament_type, - "color": filament_color, - "used_grams": round(used_grams, 1), - "used_meters": float(used_m) if used_m else 0, - } - ) + try: + used_grams = float(used_g) + except (ValueError, TypeError): + used_grams = 0 + + if used_grams > 0 and filament_id: + filaments.append( + { + "slot_id": int(filament_id), + "type": filament_type, + "color": filament_color, + "used_grams": round(used_grams, 1), + "used_meters": float(used_m) if used_m else 0, + } + ) + break + else: + # No plate_id specified - extract all filaments with used_g > 0 + # This is the legacy behavior for single-plate files + for filament_elem in root.findall(".//filament"): + filament_id = filament_elem.get("id") + filament_type = filament_elem.get("type", "") + filament_color = filament_elem.get("color", "") + used_g = filament_elem.get("used_g", "0") + used_m = filament_elem.get("used_m", "0") + + # Only include filaments that are actually used + try: + used_grams = float(used_g) + except (ValueError, TypeError): + used_grams = 0 + + if used_grams > 0 and filament_id: + filaments.append( + { + "slot_id": int(filament_id), + "type": filament_type, + "color": filament_color, + "used_grams": round(used_grams, 1), + "used_meters": float(used_m) if used_m else 0, + } + ) # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) @@ -2029,6 +2242,7 @@ async def get_filament_requirements( return { "archive_id": archive_id, "filename": archive.filename, + "plate_id": plate_id, "filaments": filaments, } @@ -2129,18 +2343,22 @@ async def reprint_archive( # Register this as an expected print so we don't create a duplicate archive register_expected_print(printer_id, remote_filename, archive_id) - # Detect plate ID from 3MF file - plate_id = 1 - try: - with zipfile.ZipFile(file_path, "r") as zf: - for name in zf.namelist(): - if name.startswith("Metadata/plate_") and name.endswith(".gcode"): - # Extract plate number from "Metadata/plate_X.gcode" - plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode" - plate_id = int(plate_str) - break - except Exception: - pass # Default to plate 1 if detection fails + # Use plate_id from request if provided, otherwise auto-detect from 3MF file + if body.plate_id is not None: + plate_id = body.plate_id + else: + # Auto-detect plate ID from 3MF file (legacy behavior for single-plate files) + plate_id = 1 + try: + with zipfile.ZipFile(file_path, "r") as zf: + for name in zf.namelist(): + if name.startswith("Metadata/plate_") and name.endswith(".gcode"): + # Extract plate number from "Metadata/plate_X.gcode" + plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode" + plate_id = int(plate_str) + break + except Exception: + pass # Default to plate 1 if detection fails logger.info( f"Reprint archive {archive_id}: plate_id={plate_id}, " diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 0bbec7f6e..463852f0b 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -170,6 +170,10 @@ class ProjectPageUpdate(BaseModel): class ReprintRequest(BaseModel): """Request body for reprinting an archive.""" + # Plate selection for multi-plate 3MF files + # If not specified, auto-detects from file (legacy behavior for single-plate files) + plate_id: int | None = None + # AMS slot mapping: list of tray IDs for each filament slot in the 3MF # Global tray ID = (ams_id * 4) + slot_id, external = 254 ams_mapping: list[int] | None = None diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 3713115b8..476292f4e 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -380,3 +380,35 @@ class TestArchiveF3DEndpoints: assert with_f3d["f3d_path"] == "archives/test/design.f3d" assert without_f3d is not None assert without_f3d["f3d_path"] is None + + # ======================================================================== + # Multi-Plate 3MF endpoints (Issue #93) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_archive_plates_not_found(self, async_client: AsyncClient): + """Verify 404 when fetching plates for non-existent archive.""" + response = await async_client.get("/api/v1/archives/999999/plates") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient): + """Verify 404 when fetching plate thumbnail for non-existent archive.""" + response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_filament_requirements_not_found(self, async_client: AsyncClient): + """Verify filament-requirements returns 404 for non-existent archive.""" + response = await async_client.get("/api/v1/archives/999999/filament-requirements") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient): + """Verify filament-requirements with plate_id returns 404 for non-existent archive.""" + response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1") + assert response.status_code == 404 diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index fb900d489..1402ac03e 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -426,3 +426,188 @@ class TestThreeMFPlateIndexExtraction: # Verify thumbnail would use correct plate thumbnail_path = f"Metadata/plate_{plate_index}.png" assert thumbnail_path == "Metadata/plate_28.png" + + +class TestMultiPlate3MFParsing: + """Tests for parsing multi-plate 3MF files (Issue #93).""" + + def test_parse_multiple_plates_from_slice_info(self): + """Test parsing multiple plates from slice_info.config.""" + from xml.etree import ElementTree as ET + + # Multi-plate 3MF with 3 plates + slice_info_xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + """ + root = ET.fromstring(slice_info_xml) + plates = root.findall(".//plate") + + assert len(plates) == 3 + + # Parse each plate + plate_data = [] + for plate_elem in plates: + plate_info = {"index": None, "filaments": []} + + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + plate_info["index"] = int(meta.get("value")) + + for filament_elem in plate_elem.findall("filament"): + used_g = float(filament_elem.get("used_g", "0")) + if used_g > 0: + plate_info["filaments"].append( + { + "slot_id": int(filament_elem.get("id")), + "type": filament_elem.get("type"), + "color": filament_elem.get("color"), + "used_grams": used_g, + } + ) + + plate_data.append(plate_info) + + # Verify plate 1 + assert plate_data[0]["index"] == 1 + assert len(plate_data[0]["filaments"]) == 1 + assert plate_data[0]["filaments"][0]["slot_id"] == 1 + assert plate_data[0]["filaments"][0]["type"] == "PLA" + + # Verify plate 2 + assert plate_data[1]["index"] == 2 + assert len(plate_data[1]["filaments"]) == 1 + assert plate_data[1]["filaments"][0]["slot_id"] == 2 + assert plate_data[1]["filaments"][0]["type"] == "PETG" + + # Verify plate 3 (has 2 filaments) + assert plate_data[2]["index"] == 3 + assert len(plate_data[2]["filaments"]) == 2 + filament_types = {f["type"] for f in plate_data[2]["filaments"]} + assert filament_types == {"PLA", "TPU"} + + def test_filter_filaments_by_plate_id(self): + """Test filtering filaments for a specific plate.""" + from xml.etree import ElementTree as ET + + slice_info_xml = """ + + + + + + + + + + + """ + root = ET.fromstring(slice_info_xml) + + # Filter for plate 2 only + target_plate_id = 2 + filaments = [] + + for plate_elem in root.findall(".//plate"): + plate_index = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + plate_index = int(meta.get("value", "0")) + break + + if plate_index == target_plate_id: + for filament_elem in plate_elem.findall("filament"): + used_g = float(filament_elem.get("used_g", "0")) + if used_g > 0: + filaments.append( + { + "slot_id": int(filament_elem.get("id")), + "type": filament_elem.get("type"), + } + ) + break + + # Should only have plate 2's filament + assert len(filaments) == 1 + assert filaments[0]["slot_id"] == 2 + assert filaments[0]["type"] == "PETG" + + def test_detect_multi_plate_from_gcode_files(self): + """Test detecting multiple plates from gcode file presence.""" + # Simulate namelist from a multi-plate 3MF + namelist = [ + "Metadata/plate_1.gcode", + "Metadata/plate_2.gcode", + "Metadata/plate_3.gcode", + "Metadata/plate_1.png", + "Metadata/plate_2.png", + "Metadata/plate_3.png", + "Metadata/slice_info.config", + "3D/3dmodel.model", + ] + + # Extract plate indices from gcode files + gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")] + plate_indices = [] + for gf in gcode_files: + plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode" + plate_indices.append(int(plate_str)) + + plate_indices.sort() + + assert len(plate_indices) == 3 + assert plate_indices == [1, 2, 3] + + # Verify it's a multi-plate file + is_multi_plate = len(plate_indices) > 1 + assert is_multi_plate is True + + def test_single_plate_export_not_multi_plate(self): + """Test that single-plate exports are not detected as multi-plate.""" + # Simulate namelist from a single-plate export (plate 5 only) + namelist = [ + "Metadata/plate_5.gcode", + "Metadata/plate_1.png", + "Metadata/plate_2.png", + "Metadata/plate_3.png", + "Metadata/plate_4.png", + "Metadata/plate_5.png", # All thumbnails present + "Metadata/slice_info.config", + "3D/3dmodel.model", + ] + + # Extract plate indices from gcode files (not thumbnails!) + gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")] + plate_indices = [] + for gf in gcode_files: + plate_str = gf[15:-6] + plate_indices.append(int(plate_str)) + + # Only one gcode file = single plate export + assert len(plate_indices) == 1 + assert plate_indices[0] == 5 + + is_multi_plate = len(plate_indices) > 1 + assert is_multi_plate is False diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7f1d180ef..65af485f5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1780,10 +1780,32 @@ export const api = { `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`, getArchiveForSlicer: (id: number, filename: string) => `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`, - getArchiveFilamentRequirements: (archiveId: number) => + getArchivePlates: (archiveId: number) => request<{ archive_id: number; filename: string; + plates: Array<{ + index: number; + name: string | null; + has_thumbnail: boolean; + thumbnail_url: string | null; + print_time_seconds: number | null; + filament_used_grams: number | null; + filaments: Array<{ + slot_id: number; + type: string; + color: string; + used_grams: number; + used_meters: number; + }>; + }>; + is_multi_plate: boolean; + }>(`/archives/${archiveId}/plates`), + getArchiveFilamentRequirements: (archiveId: number, plateId?: number) => + request<{ + archive_id: number; + filename: string; + plate_id: number | null; filaments: Array<{ slot_id: number; type: string; @@ -1791,11 +1813,12 @@ export const api = { used_grams: number; used_meters: number; }>; - }>(`/archives/${archiveId}/filament-requirements`), + }>(`/archives/${archiveId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`), reprintArchive: ( archiveId: number, printerId: number, options?: { + plate_id?: number; ams_mapping?: number[]; timelapse?: boolean; bed_levelling?: boolean; diff --git a/frontend/src/components/ReprintModal.tsx b/frontend/src/components/ReprintModal.tsx index 27218688a..540ecc94a 100644 --- a/frontend/src/components/ReprintModal.tsx +++ b/frontend/src/components/ReprintModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings } from 'lucide-react'; +import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings, Layers } from 'lucide-react'; import { api } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; @@ -30,19 +30,29 @@ const DEFAULT_PRINT_OPTIONS: PrintOptions = { timelapse: false, }; +// Format seconds to human readable time +const formatTime = (seconds: number | null | undefined): string => { + if (!seconds) return ''; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +}; + export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) { const queryClient = useQueryClient(); const [selectedPrinter, setSelectedPrinter] = useState(null); + const [selectedPlate, setSelectedPlate] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [showOptions, setShowOptions] = useState(false); const [printOptions, setPrintOptions] = useState(DEFAULT_PRINT_OPTIONS); // Manual slot overrides: slot_id (1-indexed) -> globalTrayId const [manualMappings, setManualMappings] = useState>({}); - // Clear manual mappings when printer changes + // Clear manual mappings when printer or plate changes useEffect(() => { setManualMappings({}); - }, [selectedPrinter]); + }, [selectedPrinter, selectedPlate]); // Close on Escape key useEffect(() => { @@ -58,10 +68,24 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep queryFn: api.getPrinters, }); - // Fetch filament requirements from the archived 3MF + // Fetch available plates from the archived 3MF + const { data: platesData } = useQuery({ + queryKey: ['archive-plates', archiveId], + queryFn: () => api.getArchivePlates(archiveId), + }); + + // Auto-select the first plate for single-plate files, or require selection for multi-plate + useEffect(() => { + if (platesData?.plates?.length === 1) { + setSelectedPlate(platesData.plates[0].index); + } + }, [platesData]); + + // Fetch filament requirements from the archived 3MF (filtered by plate if selected) const { data: filamentReqs } = useQuery({ - queryKey: ['archive-filaments', archiveId], - queryFn: () => api.getArchiveFilamentRequirements(archiveId), + queryKey: ['archive-filaments', archiveId, selectedPlate], + queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined), + enabled: selectedPlate !== null || !platesData?.is_multi_plate, }); // Fetch printer status when a printer is selected @@ -75,6 +99,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep mutationFn: () => { if (!selectedPrinter) throw new Error('No printer selected'); return api.reprintArchive(archiveId, selectedPrinter, { + plate_id: selectedPlate ?? undefined, ams_mapping: amsMapping, ...printOptions, }); @@ -86,6 +111,8 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep }); const activePrinters = printers?.filter((p) => p.is_active) || []; + const isMultiPlate = platesData?.is_multi_plate ?? false; + const plates = platesData?.plates ?? []; // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB") const normalizeColor = (color: string | null | undefined): string => { @@ -372,8 +399,61 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep )} + {/* Plate selection - show when multi-plate file detected */} + {isMultiPlate && plates.length > 1 && ( +
+
+ + Select Plate to Print + {!selectedPlate && ( + + + Selection required + + )} +
+
+ {plates.map((plate) => ( + + ))} +
+
+ )} + {/* Filament comparison - show when printer selected and has filament requirements */} - {selectedPrinter && filamentComparison.length > 0 && ( + {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && filamentComparison.length > 0 && (
Filament Check @@ -561,7 +641,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep