Browse Source

Add multi-plate 3MF plate selection for reprinting (Issue #93)

When reprinting a multi-plate 3MF file exported with "All sliced file",
the system now asks which plate to print and only maps filaments for
that specific plate.

Backend changes:
- Add GET /archives/{id}/plates endpoint to list available plates
  with thumbnails, print times, and per-plate filament requirements
- Add GET /archives/{id}/plate-thumbnail/{index} for plate thumbnails
- Update GET /archives/{id}/filament-requirements to accept plate_id
  query parameter for filtering filaments by plate
- Add plate_id field to ReprintRequest schema
- Update POST /archives/{id}/reprint to use plate_id from request body
  instead of auto-detecting (maintains backward compatibility)

Frontend changes:
- Add getArchivePlates API method
- Update getArchiveFilamentRequirements to accept optional plateId
- Update reprintArchive to accept plate_id in options
- Add plate selection UI to ReprintModal when multi-plate 3MF detected
- Show plate thumbnails, names, and filament counts in selection grid
- Require plate selection before printing multi-plate files
- Filter filament requirements to show only selected plate's filaments

Tests:
- Add unit tests for multi-plate slice_info.config parsing
- Add unit tests for plate detection from gcode files
- Add integration tests for new plate endpoints
maziggy 4 months ago
parent
commit
af0dad11ec

+ 254 - 36
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: <filament id="1" type="PLA" color="#FFFFFF" used_g="100" used_m="10" />
-                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 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,
-                            }
-                        )
+                        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")
+
+                                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}, "

+ 4 - 0
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

+ 32 - 0
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

+ 185 - 0
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 = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.0" />
+                <filament id="1" type="PLA" color="#FF0000" used_g="25.0" used_m="8.5" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="7200" />
+                <metadata key="weight" value="100.0" />
+                <filament id="2" type="PETG" color="#00FF00" used_g="50.0" used_m="17.0" />
+                <object identify_id="2" name="Part_B" skipped="false" />
+            </plate>
+            <plate>
+                <metadata key="index" value="3" />
+                <metadata key="prediction" value="1800" />
+                <metadata key="weight" value="25.0" />
+                <filament id="1" type="PLA" color="#FF0000" used_g="12.5" used_m="4.2" />
+                <filament id="3" type="TPU" color="#0000FF" used_g="12.5" used_m="4.2" />
+                <object identify_id="3" name="Part_C" skipped="false" />
+            </plate>
+        </config>
+        """
+        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 = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <filament id="1" type="PLA" color="#FF0000" used_g="25.0" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <filament id="2" type="PETG" color="#00FF00" used_g="50.0" />
+            </plate>
+        </config>
+        """
+        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

+ 25 - 2
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;

+ 88 - 8
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<number | null>(null);
+  const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [showOptions, setShowOptions] = useState(false);
   const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
   // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
   const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
 
-  // 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
             </div>
           )}
 
+          {/* Plate selection - show when multi-plate file detected */}
+          {isMultiPlate && plates.length > 1 && (
+            <div className="mb-4">
+              <div className="flex items-center gap-2 mb-2">
+                <Layers className="w-4 h-4 text-bambu-gray" />
+                <span className="text-sm text-bambu-gray">Select Plate to Print</span>
+                {!selectedPlate && (
+                  <span className="text-xs text-orange-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Selection required
+                  </span>
+                )}
+              </div>
+              <div className="grid grid-cols-2 gap-2">
+                {plates.map((plate) => (
+                  <button
+                    key={plate.index}
+                    onClick={() => setSelectedPlate(plate.index)}
+                    className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+                      selectedPlate === plate.index
+                        ? 'border-bambu-green bg-bambu-green/10'
+                        : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+                    }`}
+                  >
+                    {plate.has_thumbnail && plate.thumbnail_url ? (
+                      <img
+                        src={plate.thumbnail_url}
+                        alt={`Plate ${plate.index}`}
+                        className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                      />
+                    ) : (
+                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                        <Layers className="w-5 h-5 text-bambu-gray" />
+                      </div>
+                    )}
+                    <div className="min-w-0 flex-1">
+                      <p className="text-sm text-white font-medium truncate">
+                        Plate {plate.index}
+                      </p>
+                      <p className="text-xs text-bambu-gray truncate">
+                        {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                        {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
+                      </p>
+                    </div>
+                    {selectedPlate === plate.index && (
+                      <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                    )}
+                  </button>
+                ))}
+              </div>
+            </div>
+          )}
+
           {/* Filament comparison - show when printer selected and has filament requirements */}
-          {selectedPrinter && filamentComparison.length > 0 && (
+          {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && filamentComparison.length > 0 && (
             <div className="mb-4">
               <div className="flex items-center gap-2 mb-2">
                 <span className="text-sm text-bambu-gray">Filament Check</span>
@@ -561,7 +641,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </Button>
             <Button
               onClick={() => reprintMutation.mutate()}
-              disabled={!selectedPrinter || reprintMutation.isPending}
+              disabled={!selectedPrinter || (isMultiPlate && !selectedPlate) || reprintMutation.isPending}
               className="flex-1"
             >
               {reprintMutation.isPending ? (

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BNfnoADT.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-CVMDsKi7.js"></script>
+    <script type="module" crossorigin src="/assets/index-BNfnoADT.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
   </head>
   <body>

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