From be7aff1f91b69829ef8e93b92df65a13f3a6eeee Mon Sep 17 00:00:00 2001 From: MisterBeardy Date: Mon, 2 Feb 2026 15:25:14 -0500 Subject: [PATCH] Plate management updates --- .python-bin/python | 1 + backend/app/api/routes/archives.py | 93 ++- backend/app/api/routes/library.py | 86 ++- backend/app/api/routes/printers.py | 315 ++++++++++ frontend/src/__tests__/mocks/handlers.ts | 10 +- .../src/__tests__/pages/ArchivesPage.test.tsx | 10 +- frontend/src/api/client.ts | 73 +-- frontend/src/components/FileManagerModal.tsx | 239 +++++++- frontend/src/components/ModelViewer.tsx | 543 +++++++++++++----- frontend/src/components/ModelViewerModal.tsx | 198 ++++++- frontend/src/pages/FileManagerPage.tsx | 43 +- frontend/src/types/plates.ts | 41 ++ plans/3mf-plate-management-verification.md | 13 + plans/3mf-plate-management.md | 206 +++++++ whats_new.md | 28 + 15 files changed, 1663 insertions(+), 236 deletions(-) create mode 120000 .python-bin/python create mode 100644 frontend/src/types/plates.ts create mode 100644 plans/3mf-plate-management-verification.md create mode 100644 plans/3mf-plate-management.md create mode 100644 whats_new.md diff --git a/.python-bin/python b/.python-bin/python new file mode 120000 index 000000000..c9b234cf2 --- /dev/null +++ b/.python-bin/python @@ -0,0 +1 @@ +/Users/wreaves/projects/MisterBeardy/bambuddy/.venv/bin/python \ No newline at end of file diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 4d34a0508..d709ea5b7 100644 --- a/backend/app/api/routes/archives.py +++ b/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": []} + # 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) - # 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 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 diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 957e1a396..fc9ac764e 100644 --- a/backend/app/api/routes/library.py +++ b/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,18 +1301,44 @@ 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 - return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False} + # 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) - # 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 + if not plate_indices: + # No plate metadata found + return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False} plate_indices.sort() @@ -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 diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index fa890f1d2..f393451a9 100644 --- a/backend/app/api/routes/printers.py +++ b/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, diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 8d0158981..5ce009b2a 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/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', () => { diff --git a/frontend/src/__tests__/pages/ArchivesPage.test.tsx b/frontend/src/__tests__/pages/ArchivesPage.test.tsx index 0b19a0fd1..7f10618be 100644 --- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx +++ b/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([]); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e26ac48c2..e7cafe107 100644 --- a/frontend/src/api/client.ts +++ b/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 => { 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(`/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(`/library/files/${fileId}/plates`), getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) => request<{ file_id: number; diff --git a/frontend/src/components/FileManagerModal.tsx b/frontend/src/components/FileManagerModal.tsx index 2cf9a118a..44e87fa6d 100644 --- a/frontend/src/components/FileManagerModal.tsx +++ b/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(null); + const [plates, setPlates] = useState([]); + const [platesLoading, setPlatesLoading] = useState(false); + const [selectedPlateId, setSelectedPlateId] = useState(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 ( +
+
e.stopPropagation()} + > +
+

{filename}

+ +
+ +
+ + +
+ +
+ {activeTab === '3d' && hasModel ? ( +
+ {hasMultiplePlates && ( +
+
+ + Plates + {platesLoading && } +
+
+ + {plates.map((plate) => ( + + ))} +
+ {selectedPlate && ( +
+ Plate {selectedPlate.index} + {selectedPlate.print_time_seconds != null && ( + ETA {Math.round(selectedPlate.print_time_seconds / 60)} min + )} + {selectedPlate.filament_used_grams != null && ( + {selectedPlate.filament_used_grams.toFixed(1)} g + )} + {selectedPlate.filaments.length > 0 && ( + {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} + )} +
+ )} +
+ )} +
+ +
+
+ ) : activeTab === 'gcode' && hasGcode ? ( + + ) : ( +
+ No preview available for this file +
+ )} +
+
+
+ ); +} + 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([]); const [sortBy, setSortBy] = useState('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 @@ -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) => (