Browse Source

Plate management updates

MisterBeardy 3 months ago
parent
commit
be7aff1f91

+ 1 - 0
.python-bin/python

@@ -0,0 +1 @@
+/Users/wreaves/projects/MisterBeardy/bambuddy/.venv/bin/python

+ 80 - 15
backend/app/api/routes/archives.py

@@ -2160,6 +2160,8 @@ async def get_archive_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     """
+    import json
+    import re
     import xml.etree.ElementTree as ET
 
     service = ArchiveService(db)
@@ -2180,19 +2182,50 @@ async def get_archive_plates(
             # 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
+            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
+            plate_indices: list[int] = []
+            if gcode_files:
+                # Extract plate indices from gcode filenames
+                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
+            else:
+                plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
+                plate_png_files = [
+                    n
+                    for n in namelist
+                    if n.startswith("Metadata/plate_")
+                    and n.endswith(".png")
+                    and "_small" not in n
+                    and "no_light" not in n
+                ]
+                plate_name_candidates = plate_json_files + plate_png_files
+                plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
+                seen_indices: set[int] = set()
+                for name in plate_name_candidates:
+                    match = plate_re.match(name)
+                    if match:
+                        try:
+                            index = int(match.group(1))
+                        except ValueError:
+                            continue
+                        if index in seen_indices:
+                            continue
+                        seen_indices.add(index)
+                        plate_indices.append(index)
+
+            if not plate_indices:
+                # No plate metadata found
+                return {
+                    "archive_id": archive_id,
+                    "filename": archive.filename,
+                    "plates": [],
+                    "is_multi_plate": False,
+                }
 
             plate_indices.sort()
 
@@ -2297,16 +2330,48 @@ async def get_archive_plates(
 
                         plate_metadata[plate_index] = plate_info
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+
+                plate_name = meta.get("name")
+                if not plate_name:
+                    plate_name = plate_names.get(idx)
+                if not plate_name and objects:
+                    plate_name = objects[0]
 
                 plates.append(
                     {
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail

+ 73 - 13
backend/app/api/routes/library.py

@@ -1272,6 +1272,8 @@ async def get_library_file_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     """
+    import json
+    import re
     import xml.etree.ElementTree as ET
     import zipfile
 
@@ -1299,19 +1301,45 @@ async def get_library_file_plates(
             # 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
+            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
+            plate_indices: list[int] = []
+            if gcode_files:
+                # Extract plate indices from gcode filenames
+                for gf in gcode_files:
+                    try:
+                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        plate_indices.append(int(plate_str))
+                    except ValueError:
+                        pass
+            else:
+                plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
+                plate_png_files = [
+                    n
+                    for n in namelist
+                    if n.startswith("Metadata/plate_")
+                    and n.endswith(".png")
+                    and "_small" not in n
+                    and "no_light" not in n
+                ]
+                plate_name_candidates = plate_json_files + plate_png_files
+                plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
+                seen_indices: set[int] = set()
+                for name in plate_name_candidates:
+                    match = plate_re.match(name)
+                    if match:
+                        try:
+                            index = int(match.group(1))
+                        except ValueError:
+                            continue
+                        if index in seen_indices:
+                            continue
+                        seen_indices.add(index)
+                        plate_indices.append(index)
+
+            if not plate_indices:
+                # No plate metadata found
                 return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
 
-            # Extract plate indices from gcode filenames
-            plate_indices = []
-            for gf in gcode_files:
-                try:
-                    plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
-                    plate_indices.append(int(plate_str))
-                except ValueError:
-                    pass
-
             plate_indices.sort()
 
             # Parse model_settings.config for plate names
@@ -1408,16 +1436,48 @@ async def get_library_file_plates(
                             plate_info["name"] = plate_info["objects"][0]
                         plate_metadata[plate_index] = plate_info
 
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names: list[str] = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
             # Build plate list
             for idx in plate_indices:
                 meta = plate_metadata.get(idx, {})
                 has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+
+                plate_name = meta.get("name")
+                if not plate_name:
+                    plate_name = plate_names.get(idx)
+                if not plate_name and objects:
+                    plate_name = objects[0]
 
                 plates.append(
                     {
                         "index": idx,
-                        "name": meta.get("name"),
-                        "objects": meta.get("objects", []),
+                        "name": plate_name,
+                        "objects": objects,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         if has_thumbnail

+ 315 - 0
backend/app/api/routes/printers.py

@@ -802,6 +802,321 @@ async def download_printer_file(
     )
 
 
+@router.get("/{printer_id}/files/gcode")
+async def get_printer_file_gcode(
+    printer_id: int,
+    path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get gcode for a file stored on a printer (for preview)."""
+    import io
+
+    # Validate printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+    if data is None:
+        raise HTTPException(404, f"File not found: {path}")
+
+    filename = path.split("/")[-1]
+    lower = filename.lower()
+
+    if lower.endswith(".gcode"):
+        return Response(content=data, media_type="text/plain")
+    if lower.endswith(".3mf"):
+        try:
+            with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
+                gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
+                if not gcode_files:
+                    raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
+                gcode_content = zf.read(gcode_files[0])
+                return Response(content=gcode_content, media_type="text/plain")
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid 3MF file")
+
+    raise HTTPException(status_code=400, detail="Unsupported file type")
+
+
+@router.get("/{printer_id}/files/plates")
+async def get_printer_file_plates(
+    printer_id: int,
+    path: str = Query(..., description="Full path to the 3MF file on the printer"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get available plates from a multi-plate 3MF file stored on a printer."""
+    import io
+    import json
+    import xml.etree.ElementTree as ET
+    import zipfile
+
+    # Validate printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    filename = path.split("/")[-1]
+    if not filename.lower().endswith(".3mf"):
+        return {
+            "printer_id": printer_id,
+            "path": path,
+            "filename": filename,
+            "plates": [],
+            "is_multi_plate": False,
+        }
+
+    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+    if data is None:
+        raise HTTPException(404, f"File not found: {path}")
+
+    plates = []
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(data), "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 no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
+            plate_indices: list[int] = []
+            if gcode_files:
+                for gf in gcode_files:
+                    try:
+                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        plate_indices.append(int(plate_str))
+                    except ValueError:
+                        pass
+            else:
+                plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
+                plate_png_files = [
+                    n
+                    for n in namelist
+                    if n.startswith("Metadata/plate_")
+                    and n.endswith(".png")
+                    and "_small" not in n
+                    and "no_light" not in n
+                ]
+                plate_name_candidates = plate_json_files + plate_png_files
+                plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
+                seen_indices: set[int] = set()
+                for name in plate_name_candidates:
+                    match = plate_re.match(name)
+                    if match:
+                        try:
+                            index = int(match.group(1))
+                        except ValueError:
+                            continue
+                        if index in seen_indices:
+                            continue
+                        seen_indices.add(index)
+                        plate_indices.append(index)
+
+            if not plate_indices:
+                return {
+                    "printer_id": printer_id,
+                    "path": path,
+                    "filename": filename,
+                    "plates": [],
+                    "is_multi_plate": False,
+                }
+
+            plate_indices.sort()
+
+            # Parse model_settings.config for plate names
+            plate_names = {}
+            if "Metadata/model_settings.config" in namelist:
+                try:
+                    model_content = zf.read("Metadata/model_settings.config").decode()
+                    model_root = ET.fromstring(model_content)
+                    for plate_elem in model_root.findall(".//plate"):
+                        plater_id = None
+                        plater_name = None
+                        for meta in plate_elem.findall("metadata"):
+                            key = meta.get("key")
+                            value = meta.get("value")
+                            if key == "plater_id" and value:
+                                try:
+                                    plater_id = int(value)
+                                except ValueError:
+                                    pass
+                            elif key == "plater_name" and value:
+                                plater_name = value.strip()
+                        if plater_id is not None and plater_name:
+                            plate_names[plater_id] = plater_name
+                except Exception:
+                    pass
+
+            # Parse slice_info.config for plate metadata
+            plate_metadata = {}
+            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, "objects": []}
+
+                    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,
+                                }
+                            )
+
+                    plate_info["filaments"].sort(key=lambda x: x["slot_id"])
+
+                    # Collect object names
+                    for obj_elem in plate_elem.findall("object"):
+                        obj_name = obj_elem.get("name")
+                        if obj_name and obj_name not in plate_info["objects"]:
+                            plate_info["objects"].append(obj_name)
+
+                    # Set plate name
+                    if plate_index is not None:
+                        custom_name = plate_names.get(plate_index)
+                        if custom_name:
+                            plate_info["name"] = custom_name
+                        elif plate_info["objects"]:
+                            plate_info["name"] = plate_info["objects"][0]
+                        plate_metadata[plate_index] = plate_info
+
+            # Parse plate_*.json for object lists when slice_info is missing
+            plate_json_objects: dict[int, list[str]] = {}
+            for name in namelist:
+                match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
+                if not match:
+                    continue
+                try:
+                    plate_index = int(match.group(1))
+                except ValueError:
+                    continue
+                try:
+                    payload = json.loads(zf.read(name).decode())
+                    bbox_objects = payload.get("bbox_objects", [])
+                    names: list[str] = []
+                    for obj in bbox_objects:
+                        obj_name = obj.get("name") if isinstance(obj, dict) else None
+                        if obj_name and obj_name not in names:
+                            names.append(obj_name)
+                    if names:
+                        plate_json_objects[plate_index] = names
+                except Exception:
+                    continue
+
+            # Build plate list
+            for idx in plate_indices:
+                meta = plate_metadata.get(idx, {})
+                has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+                objects = meta.get("objects", [])
+                if not objects:
+                    objects = plate_json_objects.get(idx, [])
+
+                plate_name = meta.get("name")
+                if not plate_name:
+                    plate_name = plate_names.get(idx)
+                if not plate_name and objects:
+                    plate_name = objects[0]
+
+                plates.append(
+                    {
+                        "index": idx,
+                        "name": plate_name,
+                        "objects": objects,
+                        "has_thumbnail": has_thumbnail,
+                        "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
+                        "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 printer file {path}: {e}")
+
+    return {
+        "printer_id": printer_id,
+        "path": path,
+        "filename": filename,
+        "plates": plates,
+        "is_multi_plate": len(plates) > 1,
+    }
+
+
+@router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
+async def get_printer_file_plate_thumbnail(
+    printer_id: int,
+    plate_index: int,
+    path: str = Query(..., description="Full path to the 3MF file on the printer"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a plate thumbnail image from a printer-stored 3MF file."""
+    import io
+    import zipfile
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+    if data is None:
+        raise HTTPException(404, f"File not found: {path}")
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
+            thumb_path = f"Metadata/plate_{plate_index}.png"
+            if thumb_path in zf.namelist():
+                image_data = zf.read(thumb_path)
+                return Response(content=image_data, media_type="image/png")
+    except Exception:
+        pass
+
+    raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
+
+
 @router.post("/{printer_id}/files/download-zip")
 async def download_printer_files_as_zip(
     printer_id: int,

+ 8 - 2
frontend/src/__tests__/mocks/handlers.ts

@@ -383,8 +383,14 @@ export const handlers = [
   // Archives
   // ========================================================================
 
-  http.get('/api/v1/archives/:id/plates', () => {
-    return HttpResponse.json([]);
+  http.get('/api/v1/archives/:id/plates', ({ params }) => {
+    const archiveId = Number(params.id);
+    return HttpResponse.json({
+      archive_id: Number.isFinite(archiveId) ? archiveId : 0,
+      filename: 'sample.3mf',
+      plates: [],
+      is_multi_plate: false,
+    });
   }),
 
   http.get('/api/v1/archives/:id/filament-requirements', () => {

+ 8 - 2
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -84,8 +84,14 @@ describe('ArchivesPage', () => {
       http.get('/api/v1/archives/tags', () => {
         return HttpResponse.json(['test', 'calibration', 'functional']);
       }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json([]);
+      http.get('/api/v1/archives/:id/plates', ({ params }) => {
+        const archiveId = Number(params.id);
+        return HttpResponse.json({
+          archive_id: Number.isFinite(archiveId) ? archiveId : 0,
+          filename: 'sample.3mf',
+          plates: [],
+          is_multi_plate: false,
+        });
       }),
       http.get('/api/v1/archives/:id/filament-requirements', () => {
         return HttpResponse.json([]);

+ 31 - 42
frontend/src/api/client.ts

@@ -1,3 +1,5 @@
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
+
 const API_BASE = '/api/v1';
 
 // Auth token storage
@@ -2054,6 +2056,33 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  getPrinterFileGcodeUrl: (printerId: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
+  getPrinterFilePlates: (printerId: number, path: string) =>
+    request<{
+      printer_id: number;
+      path: string;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        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;
+    }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
+  getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
   downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
     const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
       method: 'POST',
@@ -2441,27 +2470,7 @@ export const api = {
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
-    request<{
-      archive_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        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`),
+    request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
     request<{
       archive_id: number;
@@ -3375,27 +3384,7 @@ export const api = {
       }
     ),
   getLibraryFilePlates: (fileId: number) =>
-    request<{
-      file_id: number;
-      filename: string;
-      plates: Array<{
-        index: number;
-        name: string | null;
-        objects: string[];
-        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;
-    }>(`/library/files/${fileId}/plates`),
+    request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
   getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
     request<{
       file_id: number;

+ 236 - 3
frontend/src/components/FileManagerModal.tsx

@@ -19,10 +19,14 @@ import {
   CheckSquare,
   Square,
   MinusSquare,
+  Box,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
+import { ModelViewer } from './ModelViewer';
+import { GcodeViewer } from './GcodeViewer';
+import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 
 interface FileManagerModalProps {
@@ -31,6 +35,205 @@ interface FileManagerModalProps {
   onClose: () => void;
 }
 
+type PrinterViewerTab = '3d' | 'gcode';
+
+interface PrinterFileViewerModalProps {
+  printerId: number;
+  filePath: string;
+  filename: string;
+  onClose: () => void;
+}
+
+function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) {
+  const [activeTab, setActiveTab] = useState<PrinterViewerTab | null>(null);
+  const [plates, setPlates] = useState<PlateMetadata[]>([]);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
+
+  const ext = filename.toLowerCase().split('.').pop() || '';
+  const hasModel = ext === '3mf' || ext === 'stl';
+  const hasGcode = ext === 'gcode' || ext === '3mf';
+
+  useEffect(() => {
+    setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+  }, [hasModel, hasGcode]);
+
+  useEffect(() => {
+    setPlates([]);
+    setSelectedPlateId(null);
+
+    if (!hasModel) return;
+
+    setPlatesLoading(true);
+    api.getPrinterFilePlates(printerId, filePath)
+      .then((data) => setPlates(data.plates || []))
+      .catch(() => setPlates([]))
+      .finally(() => setPlatesLoading(false));
+  }, [filePath, hasModel, printerId]);
+
+  const hasMultiplePlates = plates.length > 1;
+  const selectedPlate = selectedPlateId == null
+    ? null
+    : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{filename}</h2>
+          <Button variant="ghost" size="sm" onClick={onClose}>
+            <X className="w-5 h-5" />
+          </Button>
+        </div>
+
+        <div className="flex border-b border-bambu-dark-tertiary">
+          <button
+            onClick={() => hasModel && setActiveTab('3d')}
+            disabled={!hasModel}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === '3d'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : hasModel
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <Box className="w-4 h-4" />
+            3D Model
+            {!hasModel && <span className="text-xs">(not available)</span>}
+          </button>
+          <button
+            onClick={() => hasGcode && setActiveTab('gcode')}
+            disabled={!hasGcode}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === 'gcode'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : hasGcode
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <FileText className="w-4 h-4" />
+            G-code Preview
+            {!hasGcode && <span className="text-xs">(not sliced)</span>}
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-hidden p-4">
+          {activeTab === '3d' && hasModel ? (
+            <div className="w-full h-full flex flex-col gap-3">
+              {hasMultiplePlates && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                    <Box className="w-4 h-4" />
+                    Plates
+                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                  </div>
+                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+                    <button
+                      type="button"
+                      onClick={() => setSelectedPlateId(null)}
+                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                        selectedPlateId == null
+                          ? 'border-bambu-green bg-bambu-green/10'
+                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                      }`}
+                    >
+                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                        <Box 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">All Plates</p>
+                        <p className="text-xs text-bambu-gray truncate">
+                          {plates.length} plate{plates.length !== 1 ? 's' : ''}
+                        </p>
+                      </div>
+                      {selectedPlateId == null && (
+                        <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </button>
+                    {plates.map((plate) => (
+                      <button
+                        key={plate.index}
+                        type="button"
+                        onClick={() => setSelectedPlateId(plate.index)}
+                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                          selectedPlateId === plate.index
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
+                      >
+                        {plate.has_thumbnail ? (
+                          <img
+                            src={api.getPrinterFilePlateThumbnail(printerId, plate.index, filePath)}
+                            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">
+                            <Box 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.name || `Plate ${plate.index}`}
+                          </p>
+                          <p className="text-xs text-bambu-gray truncate">
+                            {plate.objects.length > 0
+                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
+                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                          </p>
+                        </div>
+                        {selectedPlateId === plate.index && (
+                          <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                  {selectedPlate && (
+                    <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
+                      <span>Plate {selectedPlate.index}</span>
+                      {selectedPlate.print_time_seconds != null && (
+                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
+                      )}
+                      {selectedPlate.filament_used_grams != null && (
+                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                      )}
+                      {selectedPlate.filaments.length > 0 && (
+                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              )}
+              <div className="flex-1">
+                <ModelViewer
+                  url={api.getPrinterFileDownloadUrl(printerId, filePath)}
+                  fileType={ext}
+                  selectedPlateId={selectedPlateId}
+                  className="w-full h-full"
+                />
+              </div>
+            </div>
+          ) : activeTab === 'gcode' && hasGcode ? (
+            <GcodeViewer
+              gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
+              className="w-full h-full"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              No preview available for this file
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 function formatFileSize(bytes: number): string {
   if (bytes === 0) return '0 B';
   const k = 1024;
@@ -90,6 +293,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
   const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
+  const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);
 
   // Close on Escape key
   useEffect(() => {
@@ -249,6 +453,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               <button
                 onClick={onClose}
                 className="text-bambu-gray hover:text-white transition-colors"
+                title="Close file manager"
+                aria-label="Close file manager"
               >
                 <X className="w-5 h-5" />
               </button>
@@ -290,6 +496,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               value={sortBy}
               onChange={(e) => setSortBy(e.target.value as SortOption)}
               className="appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer"
+              title="Sort files"
+              aria-label="Sort files"
             >
               {SORT_OPTIONS.map((option) => (
                 <option key={option.value} value={option.value}>
@@ -314,6 +522,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               onClick={navigateUp}
               disabled={currentPath === '/'}
               className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+              title="Go to parent folder"
+              aria-label="Go to parent folder"
             >
               <ChevronLeft className="w-4 h-4" />
             </button>
@@ -404,9 +614,23 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                         />
                         <span className="flex-1 text-white truncate">{file.name}</span>
                         {!file.is_directory && (
-                          <span className="text-sm text-bambu-gray">
-                            {formatFileSize(file.size)}
-                          </span>
+                          <div className="flex items-center gap-3">
+                            <span className="text-sm text-bambu-gray">
+                              {formatFileSize(file.size)}
+                            </span>
+                            {(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && (
+                              <button
+                                onClick={(e) => {
+                                  e.stopPropagation();
+                                  setViewerFile({ path: file.path, name: file.name });
+                                }}
+                                className="p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green"
+                                title="3D View"
+                              >
+                                <Box className="w-4 h-4" />
+                              </button>
+                            )}
+                          </div>
                         )}
                         {file.is_directory && (
                           <ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
@@ -504,6 +728,15 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
           onCancel={() => setFilesToDelete([])}
         />
       )}
+
+      {viewerFile && (
+        <PrinterFileViewerModal
+          printerId={printerId}
+          filePath={viewerFile.path}
+          filename={viewerFile.name}
+          onClose={() => setViewerFile(null)}
+        />
+      )}
     </div>
   );
 }

+ 401 - 146
frontend/src/components/ModelViewer.tsx

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import * as THREE from 'three';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
 import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
@@ -14,8 +15,10 @@ interface BuildVolume {
 
 interface ModelViewerProps {
   url: string;
+  fileType?: string;
   buildVolume?: BuildVolume;
   filamentColors?: string[];
+  selectedPlateId?: number | null;
   className?: string;
 }
 
@@ -29,12 +32,20 @@ interface ObjectData {
   id: string;
   meshes: MeshData[];
   defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
+  plateId?: number | null;
 }
 
 interface BuildItem {
   objectId: string;
   transform: THREE.Matrix4;
   extruder?: number; // Can override object's extruder
+  plateId?: number | null;
+}
+
+interface Parsed3MFData {
+  objects: Map<string, ObjectData>;
+  buildItems: BuildItem[];
+  plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
 }
 
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
@@ -101,10 +112,34 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
   return meshes;
 }
 
-async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
-  const zip = await JSZip.loadAsync(arrayBuffer);
+function parsePlateIdFromAttributes(element: Element): number | null {
+  const plateAttribute = Array.from(element.attributes).find((attr) => {
+    const name = attr.name.toLowerCase();
+    return (
+      name === 'plate_id' ||
+      name === 'plater_id' ||
+      name === 'plateid' ||
+      name === 'platerid' ||
+      name.endsWith(':plate_id') ||
+      name.endsWith(':plater_id')
+    );
+  });
+
+  if (!plateAttribute?.value) return null;
+  const parsed = Number.parseInt(plateAttribute.value, 10);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
+  let zip: JSZip;
+  try {
+    zip = await JSZip.loadAsync(arrayBuffer);
+  } catch {
+    throw new Error('Unsupported file format');
+  }
   const objects = new Map<string, ObjectData>();
   const buildItems: BuildItem[] = [];
+  const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
   const parser = new DOMParser();
 
   // Helper to load and parse a model file from the zip
@@ -121,6 +156,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
   // 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 objectNameById = new Map<string, string>();
+  const plateAssignmentsByObjectId = new Map<string, number>();
   const modelSettingsFile = zip.files['Metadata/model_settings.config'];
   if (modelSettingsFile) {
     try {
@@ -132,7 +169,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         const objectId = objEl.getAttribute('id');
         if (!objectId) continue;
 
-        // Find object-level extruder
+        // Find object-level extruder + name
         const directMetadata = Array.from(objEl.children).filter(
           (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
         );
@@ -143,6 +180,14 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
           }
         }
 
+        const nameMetadata = Array.from(objEl.children).find(
+          (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
+        );
+        const objectName = nameMetadata?.getAttribute('value');
+        if (objectName) {
+          objectNameById.set(objectId, objectName);
+        }
+
         // Find part-level extruders
         const partElements = objEl.getElementsByTagName('part');
         for (let j = 0; j < partElements.length; j++) {
@@ -162,11 +207,78 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
           }
         }
       }
+
+      // Parse plate -> object assignments
+      const plateElements = doc.getElementsByTagName('plate');
+      for (let i = 0; i < plateElements.length; i++) {
+        const plateEl = plateElements[i];
+        let plateId: number | null = null;
+        const metadataElements = plateEl.getElementsByTagName('metadata');
+        for (let j = 0; j < metadataElements.length; j++) {
+          const metaEl = metadataElements[j];
+          const key = metaEl.getAttribute('key');
+          if (key === 'plater_id' || key === 'plate_id') {
+            const value = metaEl.getAttribute('value');
+            if (value) {
+              const parsed = Number.parseInt(value, 10);
+              if (Number.isFinite(parsed)) {
+                plateId = parsed;
+              }
+            }
+          }
+        }
+        if (plateId == null) continue;
+
+        const modelInstances = plateEl.getElementsByTagName('model_instance');
+        for (let j = 0; j < modelInstances.length; j++) {
+          const instanceEl = modelInstances[j];
+          const instanceMetadata = instanceEl.getElementsByTagName('metadata');
+          for (let k = 0; k < instanceMetadata.length; k++) {
+            const metaEl = instanceMetadata[k];
+            if (metaEl.getAttribute('key') === 'object_id') {
+              const value = metaEl.getAttribute('value');
+              if (value) {
+                plateAssignmentsByObjectId.set(value, plateId);
+              }
+            }
+          }
+        }
+      }
     } catch {
       // Silently ignore model_settings.config parsing errors
     }
   }
 
+  // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
+  const plateAssignmentsByName = new Map<string, number>();
+  const plateJsonNames = Object.keys(zip.files).filter(
+    (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
+  );
+  for (const name of plateJsonNames) {
+    const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
+    if (!match) continue;
+    const plateIndex = Number.parseInt(match[1], 10);
+    if (!Number.isFinite(plateIndex)) continue;
+    try {
+      const payload = await zip.files[name].async('string');
+      const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
+      const objectsList = json.bbox_objects ?? [];
+      for (const entry of objectsList) {
+        if (entry?.name) {
+          plateAssignmentsByName.set(entry.name, plateIndex);
+        }
+      }
+      if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
+        const [minX, minY, maxX, maxY] = json.bbox_all;
+        if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
+          plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
+        }
+      }
+    } catch {
+      // Ignore plate json parsing errors
+    }
+  }
+
   // Find the main 3D model file
   const mainModelPath = Object.keys(zip.files).find(
     (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
@@ -184,11 +296,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
         }
       }
     }
-    return { objects, buildItems };
+    return { objects, buildItems, plateBounds };
   }
 
   const mainDoc = await loadModelFile(mainModelPath);
-  if (!mainDoc) return { objects, buildItems };
+  if (!mainDoc) return { objects, buildItems, plateBounds };
 
   // Parse objects - Bambu Studio uses components to reference external files
   const objectElements = mainDoc.getElementsByTagName('object');
@@ -197,6 +309,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     const objectId = objEl.getAttribute('id');
     if (!objectId) continue;
 
+    const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
+
     // Get default extruder from model_settings.config map, falling back to attribute or default
     let defaultExtruder = extruderMapById.get(objectId) ?? -1;
     if (defaultExtruder < 0) {
@@ -279,7 +393,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
     }
 
     if (meshes.length > 0) {
-      objects.set(objectId, { id: objectId, meshes, defaultExtruder });
+      objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
     }
   }
 
@@ -293,11 +407,15 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
       if (!objectId) continue;
 
       const transform = parseTransform(itemEl.getAttribute('transform'));
-      buildItems.push({ objectId, transform });
+      const itemPlateId = parsePlateIdFromAttributes(itemEl);
+      const objectPlateId = objects.get(objectId)?.plateId ?? null;
+      const objectName = objectNameById.get(objectId);
+      const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
+      buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
     }
   }
 
-  return { objects, buildItems };
+  return { objects, buildItems, plateBounds };
 }
 
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
@@ -321,14 +439,145 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
   return geometry;
 }
 
-export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
+function disposeGroup(group: THREE.Group) {
+  group.traverse((child) => {
+    if (child instanceof THREE.Mesh) {
+      child.geometry.dispose();
+      if (Array.isArray(child.material)) {
+        for (const material of child.material) {
+          material.dispose();
+        }
+      } else {
+        child.material.dispose();
+      }
+    }
+  });
+}
+
+function buildModelGroup(
+  parsedData: Parsed3MFData,
+  selectedPlateId: number | null,
+  filamentColors?: string[],
+): THREE.Group {
+  const { objects, buildItems } = parsedData;
+  const group = new THREE.Group();
+
+  // 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,
+    });
+  };
+
+  // Group geometries by extruder index (using per-mesh extruder)
+  const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
+
+  const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
+  const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
+    ? buildItems
+    : buildItems.filter((item) => item.plateId === selectedPlateId);
+  const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
+
+  // If we have build items, use them for positioning
+  if (activeBuildItems.length > 0) {
+    for (const item of activeBuildItems) {
+      const objectData = objects.get(item.objectId);
+      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) {
+          const v = new THREE.Vector3(
+            meshData.vertices[k],
+            meshData.vertices[k + 1],
+            meshData.vertices[k + 2]
+          );
+          v.applyMatrix4(item.transform);
+          transformedVertices.push(v.x, v.y, v.z);
+        }
+        // Now create geometry with coordinate conversion
+        const geometry = createGeometryFromMesh({
+          vertices: transformedVertices,
+          triangles: meshData.triangles,
+          extruder: extruder,
+        });
+
+        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);
+        if (!geometriesByExtruder.has(extruder)) {
+          geometriesByExtruder.set(extruder, []);
+        }
+        geometriesByExtruder.get(extruder)!.push(geometry);
+      }
+    }
+  }
+
+  // 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 (geometries.length > 1) {
+      for (const geom of geometries) {
+        geom.dispose();
+      }
+    }
+  }
+
+  return group;
+}
+
+export function ModelViewer({
+  url,
+  fileType,
+  buildVolume = { x: 256, y: 256, z: 256 },
+  filamentColors,
+  selectedPlateId = null,
+  className = '',
+}: ModelViewerProps) {
   const containerRef = useRef<HTMLDivElement>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
+  const modelGroupRef = useRef<THREE.Group | null>(null);
+  const plateRef = useRef<THREE.Mesh | null>(null);
+  const gridRef = useRef<THREE.GridHelper | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
+  const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
+  const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
 
   useEffect(() => {
     if (!containerRef.current) return;
@@ -377,6 +626,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     const gridDivisions = Math.ceil(gridSize / 16);
     const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
     scene.add(gridHelper);
+    gridRef.current = gridHelper;
 
     // Build plate indicator
     const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
@@ -390,6 +640,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     plate.rotation.x = -Math.PI / 2;
     plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
     scene.add(plate);
+    plateRef.current = plate;
 
     // Animation loop - keep it simple for reliability
     let animationId: number;
@@ -400,143 +651,51 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
     };
     animate();
 
-    // Load 3MF
-    fetch(url)
-      .then((res) => {
-        if (!res.ok) throw new Error('Failed to load file');
-        return res.arrayBuffer();
-      })
-      .then(parse3MF)
-      .then(({ objects, buildItems }) => {
-        if (objects.size === 0) {
-          throw new Error('No meshes found in 3MF file');
-        }
-
-        // 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();
-        // 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) {
-          for (const item of buildItems) {
-            const objectData = objects.get(item.objectId);
-            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) {
-                const v = new THREE.Vector3(
-                  meshData.vertices[k],
-                  meshData.vertices[k + 1],
-                  meshData.vertices[k + 2]
-                );
-                v.applyMatrix4(item.transform);
-                transformedVertices.push(v.x, v.y, v.z);
-              }
-              // Now create geometry with coordinate conversion
-              const geometry = createGeometryFromMesh({
-                vertices: transformedVertices,
-                triangles: meshData.triangles,
-                extruder: extruder,
-              });
-
-              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);
-              if (!geometriesByExtruder.has(extruder)) {
-                geometriesByExtruder.set(extruder, []);
-              }
-              geometriesByExtruder.get(extruder)!.push(geometry);
-            }
-          }
-        }
-
-        // 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 (geometries.length > 1) {
-            for (const geom of geometries) {
-              geom.dispose();
-            }
+    setLoading(true);
+    setError(null);
+    setParsedData(null);
+    setStlGeometry(null);
+
+    const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
+
+    if (normalizedType === 'stl') {
+      fetch(url)
+        .then((res) => {
+          if (!res.ok) throw new Error('Failed to load file');
+          return res.arrayBuffer();
+        })
+        .then((buffer) => {
+          const loader = new STLLoader();
+          const geometry = loader.parse(buffer);
+          geometry.computeVertexNormals();
+          geometry.rotateX(-Math.PI / 2);
+          setStlGeometry(geometry);
+        })
+        .catch((err) => {
+          setError(err.message);
+          setLoading(false);
+        });
+    } else if (normalizedType === '3mf') {
+      fetch(url)
+        .then((res) => {
+          if (!res.ok) throw new Error('Failed to load file');
+          return res.arrayBuffer();
+        })
+        .then(parse3MF)
+        .then((parsed) => {
+          if (parsed.objects.size === 0) {
+            throw new Error('No meshes found in 3MF file');
           }
-        }
-
-        // Get bounding box to position model
-        const box = new THREE.Box3().setFromObject(group);
-        const center = box.getCenter(new THREE.Vector3());
-
-        // Always place models on the build plate (Y=0)
-        group.position.y = -box.min.y;
-
-        // For models without build transforms, also center X/Z
-        if (buildItems.length === 0) {
-          group.position.x = -center.x;
-          group.position.z = -center.z;
-        }
-
-        scene.add(group);
-
-        // Recalculate bounding box after positioning
-        const finalBox = new THREE.Box3().setFromObject(group);
-        const finalCenter = finalBox.getCenter(new THREE.Vector3());
-        const finalSize = finalBox.getSize(new THREE.Vector3());
-
-        // Adjust camera to fit model
-        const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
-        const cameraDistance = maxDim * 1.8;
-        camera.position.set(
-          finalCenter.x + cameraDistance * 0.7,
-          finalCenter.y + cameraDistance * 0.5,
-          finalCenter.z + cameraDistance * 0.7
-        );
-        controls.target.copy(finalCenter);
-        controls.update();
-
-        setLoading(false);
-      })
-      .catch((err) => {
-        setError(err.message);
-        setLoading(false);
-      });
+          setParsedData(parsed);
+        })
+        .catch((err) => {
+          setError(err.message);
+          setLoading(false);
+        });
+    } else {
+      setError('Unsupported file format');
+      setLoading(false);
+    }
 
     // Handle resize
     const handleResize = () => {
@@ -555,8 +714,104 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
       controls.dispose();
       renderer.dispose();
       container.removeChild(renderer.domElement);
+      modelGroupRef.current = null;
+      plateRef.current = null;
+      gridRef.current = null;
     };
-  }, [url, buildVolume, filamentColors]);
+  }, [url, buildVolume, fileType]);
+
+  useEffect(() => {
+    if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
+    if (!parsedData && !stlGeometry) return;
+
+    if (modelGroupRef.current) {
+      sceneRef.current.remove(modelGroupRef.current);
+      disposeGroup(modelGroupRef.current);
+    }
+
+    const isStlModel = !!stlGeometry;
+    const group = isStlModel
+      ? (() => {
+          const materialColor = filamentColors?.[0] || '#00ae42';
+          const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
+          const mesh = new THREE.Mesh(stlGeometry!, material);
+          const stlGroup = new THREE.Group();
+          stlGroup.add(mesh);
+          return stlGroup;
+        })()
+      : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
+    modelGroupRef.current = group;
+    sceneRef.current.add(group);
+
+    // Get bounding box to position model
+    const box = new THREE.Box3().setFromObject(group);
+    const center = box.getCenter(new THREE.Vector3());
+
+    // Always place models on the build plate (Y=0)
+    group.position.y = -box.min.y;
+
+    // For a selected plate, center the plate contents on the build plate
+    const shouldRecenter = isStlModel || parsedData!.buildItems.length === 0;
+    const centerOffsetX = shouldRecenter ? -center.x : 0;
+    const centerOffsetZ = shouldRecenter ? -center.z : 0;
+
+    let plateOffsetX = 0;
+    let plateOffsetZ = 0;
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+      const plateBox = new THREE.Box3().setFromObject(group);
+      const bounds = parsedData!.plateBounds.get(selectedPlateId);
+      if (bounds) {
+        plateOffsetX = plateBox.min.x - bounds.minX;
+        plateOffsetZ = plateBox.min.z - bounds.minY;
+      } else {
+        const epsilon = 1e-6;
+        plateOffsetX = Math.floor((plateBox.min.x + epsilon) / buildVolume.x) * buildVolume.x;
+        plateOffsetZ = Math.floor((plateBox.min.z + epsilon) / buildVolume.y) * buildVolume.y;
+      }
+    }
+
+    const plateCenterX = buildVolume.x / 2;
+    const plateCenterZ = buildVolume.y / 2;
+
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+      group.position.x = centerOffsetX - plateOffsetX;
+      group.position.z = centerOffsetZ - plateOffsetZ;
+    } else if (isStlModel) {
+      group.position.x = centerOffsetX + plateCenterX;
+      group.position.z = centerOffsetZ + plateCenterZ;
+    } else {
+      group.position.x = centerOffsetX;
+      group.position.z = centerOffsetZ;
+    }
+
+    if (plateRef.current) {
+      plateRef.current.position.x = plateCenterX;
+      plateRef.current.position.z = plateCenterZ;
+    }
+
+    if (gridRef.current) {
+      gridRef.current.position.x = plateCenterX;
+      gridRef.current.position.z = plateCenterZ;
+    }
+
+    // Recalculate bounding box after positioning
+    const finalBox = new THREE.Box3().setFromObject(group);
+    const finalCenter = finalBox.getCenter(new THREE.Vector3());
+    const finalSize = finalBox.getSize(new THREE.Vector3());
+
+    // Adjust camera to fit model
+    const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
+    const cameraDistance = maxDim * 1.8;
+    cameraRef.current.position.set(
+      finalCenter.x + cameraDistance * 0.7,
+      finalCenter.y + cameraDistance * 0.5,
+      finalCenter.z + cameraDistance * 0.7
+    );
+    controlsRef.current.target.copy(finalCenter);
+    controlsRef.current.update();
+
+    setLoading(false);
+  }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
 
   const resetView = () => {
     if (cameraRef.current && controlsRef.current) {

+ 183 - 15
frontend/src/components/ModelViewerModal.tsx

@@ -1,16 +1,19 @@
 import { useState, useEffect } from 'react';
-import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
+import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
+import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
 
 interface ModelViewerModalProps {
-  archiveId: number;
+  archiveId?: number;
+  libraryFileId?: number;
   title: string;
+  fileType?: string;
   onClose: () => void;
 }
 
@@ -22,10 +25,14 @@ interface Capabilities {
   filament_colors: string[];
 }
 
-export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
+export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
+  const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [loading, setLoading] = useState(true);
+  const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
+  const [platesLoading, setPlatesLoading] = useState(false);
+  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
 
   // Close on Escape key
   useEffect(() => {
@@ -37,6 +44,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
   }, [onClose]);
 
   useEffect(() => {
+    setLoading(true);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
+      const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
+      setCapabilities({
+        has_model: hasModel,
+        has_gcode: hasGcode,
+        has_source: false,
+        build_volume: { x: 256, y: 256, z: 256 },
+        filament_colors: [],
+      });
+      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setLoading(false);
+      return;
+    }
+
+    if (!archiveId) {
+      setCapabilities(null);
+      setActiveTab(null);
+      setLoading(false);
+      return;
+    }
+
     api.getArchiveCapabilities(archiveId)
       .then(caps => {
         setCapabilities(caps);
@@ -54,12 +86,56 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         setActiveTab('3d');
         setLoading(false);
       });
-  }, [archiveId]);
+  }, [archiveId, fileType, isLibrary]);
+
+  useEffect(() => {
+    setPlatesLoading(true);
+    setSelectedPlateId(null);
+
+    if (isLibrary) {
+      const normalizedType = (fileType || '').toLowerCase();
+      if (!libraryFileId || normalizedType !== '3mf') {
+        setPlatesData(null);
+        setPlatesLoading(false);
+        return;
+      }
+      api.getLibraryFilePlates(libraryFileId)
+        .then((data) => setPlatesData(data))
+        .catch(() => setPlatesData(null))
+        .finally(() => setPlatesLoading(false));
+      return;
+    }
+
+    if (!archiveId) {
+      setPlatesData(null);
+      setPlatesLoading(false);
+      return;
+    }
+
+    api.getArchivePlates(archiveId)
+      .then((data) => setPlatesData(data))
+      .catch(() => setPlatesData(null))
+      .finally(() => setPlatesLoading(false));
+  }, [archiveId, fileType, isLibrary, libraryFileId]);
+
+  const plates = platesData?.plates ?? [];
+  const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
+  const selectedPlate: PlateMetadata | null = selectedPlateId == null
+    ? null
+    : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+
+  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
   const handleOpenInSlicer = () => {
+    if (!canOpenInSlicer) return;
     // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
+    if (isLibrary) {
+      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+      openInSlicer(downloadUrl);
+      return;
+    }
+    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     openInSlicer(downloadUrl);
   };
 
@@ -76,7 +152,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
           <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
           <div className="flex items-center gap-2">
-            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
+            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
               <ExternalLink className="w-4 h-4" />
               Open in Slicer
             </Button>
@@ -129,17 +205,109 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
             </div>
           ) : activeTab === '3d' && capabilities ? (
-            <ModelViewer
-              url={capabilities.has_source
-                ? api.getSource3mfDownloadUrl(archiveId)
-                : api.getArchiveDownload(archiveId)}
-              buildVolume={capabilities.build_volume}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
+            <div className="w-full h-full flex flex-col gap-3">
+              {hasMultiplePlates && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                    <Layers className="w-4 h-4" />
+                    Plates
+                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                  </div>
+                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+                    <button
+                      type="button"
+                      onClick={() => setSelectedPlateId(null)}
+                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                        selectedPlateId == null
+                          ? 'border-bambu-green bg-bambu-green/10'
+                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                      }`}
+                    >
+                      <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">All Plates</p>
+                        <p className="text-xs text-bambu-gray truncate">
+                          {plates.length} plate{plates.length !== 1 ? 's' : ''}
+                        </p>
+                      </div>
+                      {selectedPlateId == null && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </button>
+                    {plates.map((plate) => (
+                      <button
+                        key={plate.index}
+                        type="button"
+                        onClick={() => setSelectedPlateId(plate.index)}
+                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
+                          selectedPlateId === plate.index
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary 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.name || `Plate ${plate.index}`}
+                          </p>
+                          <p className="text-xs text-bambu-gray truncate">
+                            {plate.objects.length > 0
+                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
+                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                          </p>
+                        </div>
+                        {selectedPlateId === plate.index && (
+                          <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                  {selectedPlate && (
+                    <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
+                      <span>Plate {selectedPlate.index}</span>
+                      {selectedPlate.print_time_seconds != null && (
+                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
+                      )}
+                      {selectedPlate.filament_used_grams != null && (
+                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                      )}
+                      {selectedPlate.filaments.length > 0 && (
+                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
+                      )}
+                    </div>
+                  )}
+                </div>
+              )}
+              <div className="flex-1">
+                  <ModelViewer
+                    url={isLibrary
+                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                      : (capabilities.has_source
+                        ? api.getSource3mfDownloadUrl(archiveId!)
+                        : api.getArchiveDownload(archiveId!))}
+                    fileType={fileType}
+                    buildVolume={capabilities.build_volume}
+                    filamentColors={capabilities.filament_colors}
+                    selectedPlateId={selectedPlateId}
+                    className="w-full h-full"
+                  />
+              </div>
+            </div>
           ) : activeTab === 'gcode' && capabilities ? (
             <GcodeViewer
-              gcodeUrl={api.getArchiveGcode(archiveId)}
+              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
               filamentColors={capabilities.filament_colors}
               className="w-full h-full"
             />

+ 42 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -36,6 +36,7 @@ import {
   Pencil,
   Play,
   Image,
+  Box,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -50,6 +51,7 @@ import type {
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
+import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
@@ -878,6 +880,7 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
+  onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
@@ -885,7 +888,7 @@ interface FileCardProps {
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -983,6 +986,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   Add to Queue
                 </button>
               )}
+              {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:read')}
+                  title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}
+                >
+                  <Box className="w-3.5 h-3.5" />
+                  3D Preview
+                </button>
+              )}
               <button
                 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -1070,6 +1086,7 @@ export function FileManagerPage() {
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
+  const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1926,6 +1943,7 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
+                    onPreview3d={setViewerFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
@@ -2042,6 +2060,20 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
+                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
+                        <button
+                          onClick={() => hasPermission('library:read') && setViewerFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:read')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
+                          disabled={!hasPermission('library:read')}
+                        >
+                          <Box className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
@@ -2193,6 +2225,15 @@ export function FileManagerPage() {
         />
       )}
 
+      {viewerFile && (
+        <ModelViewerModal
+          libraryFileId={viewerFile.id}
+          title={viewerFile.print_name || viewerFile.filename}
+          fileType={viewerFile.file_type}
+          onClose={() => setViewerFile(null)}
+        />
+      )}
+
       {renameItem && (
         <RenameModal
           type={renameItem.type}

+ 41 - 0
frontend/src/types/plates.ts

@@ -0,0 +1,41 @@
+export interface PlateFilament {
+  slot_id: number;
+  type: string;
+  color: string;
+  used_grams: number;
+  used_meters: number;
+}
+
+export interface PlateMetadata {
+  index: number;
+  name: string | null;
+  objects: string[];
+  has_thumbnail: boolean;
+  thumbnail_url: string | null;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filaments: PlateFilament[];
+}
+
+export interface ArchivePlatesResponse {
+  archive_id: number;
+  filename: string;
+  plates: PlateMetadata[];
+  is_multi_plate: boolean;
+}
+
+export interface LibraryFilePlatesResponse {
+  file_id: number;
+  filename: string;
+  plates: PlateMetadata[];
+  is_multi_plate: boolean;
+}
+
+export interface ViewerPlateSelectionState {
+  selected_plate_id: number | null;
+}
+
+export interface PlateAssignment {
+  object_id: string;
+  plate_id: number | null;
+}

+ 13 - 0
plans/3mf-plate-management-verification.md

@@ -0,0 +1,13 @@
+# Plate Management Verification Checklist
+
+## Manual Verification
+- Open an archive with a multi-plate 3MF and verify the plate selector shows All Plates plus each plate entry.
+- Select each plate and confirm the 3D viewer updates to show only that plate’s objects.
+- Switch back to All Plates and confirm all geometry renders again.
+- Verify the plate thumbnail and plate name are shown in the selector for plates with metadata.
+- Open a single-plate 3MF and confirm the selector does not appear.
+- Open an STL or non-3MF archive and confirm the viewer shows a friendly unsupported format error (no crash).
+
+## Test Targets
+- MSW handler for /api/v1/archives/:id/plates should return a structured response.
+- ArchivesPage plate hover handlers should not throw when plates data is empty.

+ 206 - 0
plans/3mf-plate-management.md

@@ -0,0 +1,206 @@
+# 3MF Plate Management Feature Plan
+## Overview
+This plan outlines the implementation of plate management features for the bambuddy 3D viewer. This will allow users to view, select, and manage individual plates within 3MF files, similar to how Bambu Studio handles multi-plate projects.
+## Background
+### Current 3MF File Structure
+3MF files use a hierarchical XML structure to manage multiple build plates:
+```
+3dmodel.model (root)
+├── Metadata/
+│   └── model_settings.config  # Plate configuration manager
+├── 3dmodel.model (main model data)
+│   ├── <object> definitions (unique geometry)
+│   └── <build> items (plate assignments)
+└── Metadata/
+    └── plate_1.png, plate_2.png, etc. (plate thumbnails)
+```
+### Key Concepts
+- **Build Items**: Objects assigned to specific plates via `bambu:plate_id` attribute
+- **Plate Configuration**: `Metadata/model_settings.config` manages plate definitions, names, bed types
+- **Object Instances**: Each unique object defined once, referenced by multiple build items
+- **Transformation Matrix**: Each build item has position, rotation, scale relative to its plate's center
+- **Plate Thumbnails**: PNG images showing each plate's layout for preview
+## Feature Requirements
+### 1. Plate Metadata Parsing
+**Goal**: Read and parse `Metadata/model_settings.config` from 3MF files
+**Technical Details**:
+- Parse XML structure to extract:
+  - Plate count
+  - Plate names (custom or default)
+  - Plate dimensions
+  - Bed type per plate (Textured PE, Cool Plate, etc.)
+  - Printer type per plate (X1C, A1 mini, etc.)
+- Extract plate-to-object mappings from `<build>` items
+- Parse object-to-plate assignments from `<object>` `bambu:plate_id` attributes
+**Implementation Approach**:
+- Use JSZip to extract `Metadata/model_settings.config` from 3MF ZIP
+- Parse XML using DOMParser or similar library
+- Create TypeScript interfaces for plate metadata
+### 2. UI/UX Design
+**Goal**: Intuitive plate selection and object management interface
+**Components Needed**:
+#### A. Plate Selector/Tab System
+- **Plate Tabs**: Horizontal tabs showing each plate (Plate 1, Plate 2, etc.)
+- **Plate Dropdown**: Dropdown to select active plate
+- **Plate Info Panel**: Display plate name, dimensions, bed type
+- **Thumbnail Preview**: Show plate thumbnail when selected
+#### B. Object Filtering
+- **Filter by Plate**: When a plate is selected, show only objects on that plate
+- **Object List**: Display objects with their positions on current plate
+- **Object Selection**: Allow selecting individual objects (checkbox, multi-select)
+- **Object Info**: Show object name, dimensions, material color
+#### C. 3D Viewer Enhancements
+- **Plate-Specific Rendering**: Render only objects for selected plate
+- **Object Highlighting**: Highlight selected objects in 3D view
+- **Plate Grid Overlay**: Show build plate boundaries when viewing specific plate
+- **Plate Indicator**: Visual indicator of which plate is currently active
+#### D. Object Manipulation (Optional - Future Enhancement)
+- **Drag and Drop**: Move objects between plates
+- **Object Repositioning**: Adjust X/Y/Z position on current plate
+- **Object Rotation**: Rotate objects on build plate
+- **Object Scaling**: Resize objects
+- **Delete from Plate**: Remove object from current plate
+- **Add to Plate**: Copy object to different plate
+### 3. Data Model Design
+**Goal**: Track plate assignments and configurations
+**State Structure**:
+```typescript
+interface Plate {
+  id: string;
+  name: string;
+  width: number;
+  depth: number;
+  bedType: string;
+  printerType: string;
+  objectIds: string[];
+}
+interface ObjectAssignment {
+  objectId: string;
+  plateId: string;
+  position: { x: number; y: number; z: number };
+  rotation: { x: number; y: number; z: number };
+  scale: { x: number; y: number; z: number };
+}
+interface ViewerState {
+  selectedPlateId: string | null;
+  selectedObjectIds: string[];
+  filterMode: 'all' | 'plate';
+}
+```
+**Storage Considerations**:
+- In-memory state for current session
+- Optional: Save plate configurations to localStorage
+- Optional: Save to backend as user preferences
+### 4. Backend API Requirements
+**Goal**: API endpoints for saving/loading plate configurations
+**Required Endpoints**:
+#### A. Get Plate Metadata
+```
+GET /api/v1/library/files/{id}/plates
+```
+Returns plate metadata from 3MF file
+#### B. Save Plate Configuration (Optional)
+```
+POST /api/v1/library/files/{id}/plates/config
+Body: {
+  plates: Plate[];
+  defaultPlateId?: string;
+}
+```
+Save custom plate names, assignments to user preferences
+#### C. Get Plate Thumbnail
+```
+GET /api/v1/library/files/{id}/plates/{plateId}/thumbnail
+```
+Returns PNG image of specific plate
+### 5. Implementation Steps
+#### Step 1: 3MF Metadata Parser
+- Create `PlateMetadataParser` class
+- Implement `parse3MFPlateMetadata(file: File)` method
+- Extract `Metadata/model_settings.config` from ZIP
+- Parse XML structure
+- Return typed plate metadata
+#### Step 2: Update ModelViewer Component
+- Add `plates` prop to ModelViewerProps
+- Add `selectedPlateId` prop to ModelViewerProps
+- Add `selectedObjectIds` prop to ModelViewerProps
+- Add `filterMode` prop to ModelViewerProps
+- Modify `useEffect` to filter objects by selected plate
+- Add plate selection state management
+- Implement object highlighting for selected objects
+- Add plate grid overlay visualization
+#### Step 3: Update ModelViewerModal
+- Add plate selector UI (tabs or dropdown)
+- Display plate information panel
+- Show plate thumbnail preview
+- Add "All Plates" view option
+- Pass selected plate and objects to ModelViewer
+#### Step 4: Update FileManagerPage
+- Add plate management state
+- Add plate selector to file cards (optional)
+- Display plate badge on file cards
+- Add plate count indicator in file list
+#### Step 5: API Client Updates
+- Add `getLibraryFilePlates(fileId: number)` method
+- Add `saveLibraryFilePlateConfig(fileId: number, config: PlateConfig)` method
+- Add `getLibraryFilePlateThumbnail(fileId: number, plateId: string)` method
+#### Step 6: Testing
+- Test with multi-plate 3MF files
+- Test plate selection and object filtering
+- Test plate switching
+- Test object highlighting
+- Verify thumbnail generation
+### 6. Technical Considerations
+#### A. Coordinate Systems
+- **3MF**: Uses local (0,0,0) origin per plate
+- **Viewer**: Uses Three.js Y-up coordinate system
+- **Challenge**: Need to convert between coordinate systems when rendering specific plates
+**Solution**: Store plate origin offset, apply when rendering specific plate
+#### B. Performance
+- **Lazy Loading**: Load plate metadata on-demand, not entire file upfront
+- **Object Culling**: Don't render objects not on current plate
+- **Thumbnail Caching**: Cache plate thumbnails
+#### C. Backward Compatibility
+- **Single Plate Files**: Continue working as-is (show all plates)
+- **No Plate Metadata**: Gracefully degrade to full model view
+- **STL Files**: Plate management not applicable (no plate structure)
+### 7. UI/UX Flow
+```mermaid
+flowchart TD
+    A[User opens 3MF file] --> B{3D Viewer loads file}
+    B --> C{Parse plate metadata}
+    C --> D{Display plate selector}
+    D --> E{User selects plate}
+    E --> F{Filter objects by plate}
+    F --> G{Highlight selected objects}
+    G --> H{Render plate-specific view}
+    H --> I{User clicks Print}
+```
+### 8. Limitations
+- **Complexity**: 3MF plate structure is complex, requires careful XML parsing
+- **File Size**: Large 3MF files with many plates may have performance impact
+- **STL Files**: No plate structure, feature not applicable
+- **Testing**: Extensive testing needed for various plate configurations
+- **Backward Compatibility**: Must maintain existing single-plate view behavior
+### 9. Future Enhancements (Out of Scope)
+- **Object Manipulation**: Drag-and-drop, rotation, scaling
+- **Plate Creation**: Add new plates, duplicate objects between plates
+- **Plate Templates**: Save and reuse plate configurations
+- **Batch Operations**: Apply settings to multiple plates at once
+- **Visual Plate Editor**: Graphical plate layout designer
+## Success Criteria
+- [ ] Users can view all plates in a 3MF file
+- [ ] Users can select individual plates
+- [ ] Objects are filtered by selected plate
+- [ ] Selected objects are highlighted in 3D view
+- [ ] Plate information is displayed (name, dimensions, bed type)
+- [ ] Plate thumbnails are shown
+- [ ] Works with both 3MF and STL files
+- [ ] Backward compatible with single-plate files
+- [ ] Performance is acceptable with large files
+- [ ] All existing features continue to work
+## Implementation Priority
+1. **High**: Core plate parsing and rendering
+2. **Medium**: UI components and state management
+3. **Low**: Backend API and advanced features
+4. **Future**: Object manipulation and plate creation

+ 28 - 0
whats_new.md

@@ -0,0 +1,28 @@
+# What's New: 3D File Preview Updates
+
+## Overview
+- Expanded 3D previews in the Library File Manager and Printer File Manager.
+- Added STL support (interactive 3D view) alongside existing 3MF/G-code previews.
+- Improved multi-plate handling for 3MF files in the printer file manager.
+
+## Library File Manager (Files Page)
+- 3D preview now supports `.3mf`, `.gcode`, and `.stl` files.
+- STL files open in the same viewer modal used by 3MF files.
+- Plate-aware selection continues to work for 3MF files with multiple plates.
+
+## Printer File Manager (Printers → File Manager)
+- Added a 3D View action for `.3mf`, `.gcode`, and `.stl` files on the printer.
+- New printer-side 3MF plate endpoints:
+  - Plate list and metadata retrieval.
+  - Plate thumbnail retrieval.
+- Added a printer-side gcode preview endpoint for 3MF and `.gcode` files.
+- STL models are centered on the build sheet in the 3D viewer.
+
+## Model Viewer Enhancements
+- Added STL rendering support using `STLLoader`.
+- Viewer now selects the correct rendering pipeline based on file type.
+- STL models auto-center on the build plate for a consistent viewing experience.
+
+## Affected Areas
+- Frontend: File Manager modals and 3D viewer.
+- Backend: Printer file preview endpoints for plates, plate thumbnails, and gcode.