diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index e0905df40..38d406d25 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -31,6 +31,7 @@ from backend.app.schemas.print_queue import ( ) from backend.app.services.notification_service import notification_service from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id +from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf logger = logging.getLogger(__name__) @@ -205,12 +206,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: response.archive_name = item.archive.print_name or item.archive.filename response.archive_thumbnail = item.archive.thumbnail_path response.print_time_seconds = item.archive.print_time_seconds + response.filament_used_grams = item.archive.filament_used_grams if item.plate_id: archive_path = settings.base_dir / item.archive.file_path if archive_path.exists(): plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id) + plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)) if plate_time is not None: response.print_time_seconds = plate_time + if plate_weight > 0: + response.filament_used_grams = plate_weight if item.library_file: response.library_file_name = ( item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None @@ -221,13 +226,19 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: # Get print time from library file metadata if no archive if not item.archive and item.library_file.file_metadata: response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds") + response.filament_used_grams = item.library_file.file_metadata.get("filament_used_grams") if item.plate_id: lib_path = Path(item.library_file.file_path) library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path if library_file_path.exists(): plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id) + plate_weight = sum( + f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id) + ) if plate_time is not None: response.print_time_seconds = plate_time + if plate_weight > 0: + response.filament_used_grams = plate_weight if item.printer: response.printer_name = item.printer.name return response diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index f465b8d47..48b4e6939 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -97,6 +97,7 @@ class PrintQueueItemResponse(BaseModel): library_file_thumbnail: str | None = None # Thumbnail of library file printer_name: str | None = None print_time_seconds: int | None = None # Estimated print time from archive or library file + filament_used_grams: float | None = None # Estimated print weight from archive or library file # User tracking (Issue #206) created_by_id: int | None = None diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index c06c7a896..3814029d5 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -320,7 +320,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non return None -def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: +def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]: """Extract per-filament total usage from 3MF slice_info.config. This extracts the slicer-estimated total usage per filament slot, @@ -328,6 +328,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: Args: file_path: Path to the 3MF file + plate_id: Optional plate index to filter for (for multi-plate files) Returns: List of filament usage dictionaries: @@ -342,22 +343,55 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) - for f in root.findall(".//filament"): - filament_id = f.get("id") - used_g = f.get("used_g", "0") - try: - used_amount = float(used_g) - if filament_id: - filament_usage.append( - { - "slot_id": int(filament_id), - "used_g": used_amount, - "type": f.get("type", ""), - "color": f.get("color", ""), - } - ) - except (ValueError, TypeError): - pass # Skip filament entries with unparseable usage values + if plate_id is not None: + # Find the plate element with matching index + for plate_elem in root.findall(".//plate"): + plate_index = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + try: + plate_index = int(meta.get("value", "0")) + except ValueError: + pass + break + + if plate_index == plate_id: + for f in plate_elem.findall("filament"): + filament_id = f.get("id") + used_g = f.get("used_g", "0") + try: + used_amount = float(used_g) + if filament_id: + filament_usage.append( + { + "slot_id": int(filament_id), + "used_g": used_amount, + "type": f.get("type", ""), + "color": f.get("color", ""), + } + ) + except (ValueError, TypeError): + pass + break + else: + # No plate_id specified - extract all filaments + for f in root.findall(".//filament"): + filament_id = f.get("id") + used_g = f.get("used_g", "0") + try: + used_amount = float(used_g) + if filament_id: + filament_usage.append( + { + "slot_id": int(filament_id), + "used_g": used_amount, + "type": f.get("type", ""), + "color": f.get("color", ""), + } + ) + except (ValueError, TypeError): + pass # Skip filament entries with unparseable usage values + except Exception: pass # Return whatever usage data was collected before the error diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index b07bd749d..bacbed307 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/backend/tests/unit/test_threemf_tools.py @@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion, and cumulative layer usage lookup. """ +import io import math +import zipfile from backend.app.utils.threemf_tools import ( + extract_filament_usage_from_3mf, get_cumulative_usage_at_layer, mm_to_grams, parse_gcode_layer_filament_usage, ) +def create_mock_3mf(slice_info_content: str) -> io.BytesIO: + """Create a mock 3MF file (ZIP) with slice_info.config content.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("Metadata/slice_info.config", slice_info_content) + buffer.seek(0) + return buffer + + class TestParseGcodeLayerFilamentUsage: """Tests for parse_gcode_layer_filament_usage().""" @@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer: """Target layer 0.""" data = {0: {0: 10.0}, 1: {0: 20.0}} assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0} + + +class TestExtractFilamentUsageFrom3mf: + """Tests for extract_filament_usage_from_3mf function.""" + + def test_extract_single_filament(self, tmp_path): + """Test extracting a single filament.""" + xml_content = """ + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert len(result) == 1 + assert result[0]["slot_id"] == 1 + assert result[0]["used_g"] == 50.5 + assert result[0]["type"] == "PLA" + assert result[0]["color"] == "#FF0000" + + def test_extract_multiple_filaments(self, tmp_path): + """Test extracting multiple filaments.""" + xml_content = """ + + + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert len(result) == 3 + assert result[0]["slot_id"] == 1 + assert result[1]["slot_id"] == 2 + assert result[2]["slot_id"] == 3 + + def test_extract_filament_with_plate_id(self, tmp_path): + """Test extracting filament for a specific plate.""" + xml_content = """ + + + + + + + + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path, plate_id=2) + + assert len(result) == 1 + assert result[0]["used_g"] == 75.0 + assert result[0]["type"] == "PETG" + + def test_missing_slice_info_returns_empty(self, tmp_path): + """Test that missing slice_info.config returns empty list.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("other_file.txt", "content") + buffer.seek(0) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(buffer.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert result == [] + + def test_invalid_file_returns_empty(self, tmp_path): + """Test that invalid file returns empty list.""" + file_path = tmp_path / "invalid.3mf" + file_path.write_text("not a zip file") + + result = extract_filament_usage_from_3mf(file_path) + + assert result == [] + + def test_nonexistent_file_returns_empty(self, tmp_path): + """Test that nonexistent file returns empty list.""" + file_path = tmp_path / "nonexistent.3mf" + + result = extract_filament_usage_from_3mf(file_path) + + assert result == [] + + def test_filament_without_id_is_skipped(self, tmp_path): + """Test that filament without id is skipped.""" + xml_content = """ + + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert len(result) == 1 + assert result[0]["slot_id"] == 2 + + def test_invalid_used_g_is_skipped(self, tmp_path): + """Test that filament with invalid used_g is skipped.""" + xml_content = """ + + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert len(result) == 1 + assert result[0]["slot_id"] == 2 + + def test_missing_optional_fields(self, tmp_path): + """Test that missing type and color default to empty string.""" + xml_content = """ + + + + """ + mock_3mf = create_mock_3mf(xml_content) + file_path = tmp_path / "test.3mf" + file_path.write_bytes(mock_3mf.read()) + + result = extract_filament_usage_from_3mf(file_path) + + assert len(result) == 1 + assert result[0]["type"] == "" + assert result[0]["color"] == "" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aa1dce68d..e092bafbc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1236,6 +1236,7 @@ export interface PrintQueueItem { library_file_thumbnail?: string | null; printer_name?: string | null; print_time_seconds?: number | null; // Estimated print time from archive or library file + filament_used_grams?: number | null; // Estimated print weight from archive or library file // User tracking (Issue #206) created_by_id?: number | null; created_by_username?: string | null; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index b1413cecd..37ab9ca3e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -762,6 +762,7 @@ export default { printing: 'Druckt', queued: 'In Warteschlange', totalTime: 'Gesamte Wartezeit', + totalWeight: 'Gesamtgewicht der Warteschlange', history: 'Verlauf', }, // Filters diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 79f8c5c0c..258ef82d0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -762,6 +762,7 @@ export default { printing: 'Printing', queued: 'Queued', totalTime: 'Total Queue Time', + totalWeight: 'Total Queue Weight', history: 'History', }, // Filters diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 34f7805e9..66930631b 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -762,6 +762,7 @@ export default { printing: 'Impressions', queued: 'En attente', totalTime: 'Temps total estimé', + totalWeight: 'Poids total estimé', history: 'Historique', }, // Filters diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 557e0a42f..2c85d2044 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -749,6 +749,7 @@ export default { printing: 'In stampa', queued: 'In coda', totalTime: 'Tempo totale coda', + totalWeight: 'Peso totale della coda', history: 'Cronologia', }, // Filters diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 19fb21b5e..58baebcf7 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -830,6 +830,7 @@ export default { queued: 'キュー中', history: '履歴', totalTime: 'キュー合計時間', + totalWeight: 'キュー合計重量', }, filter: { allPrinters: 'すべてのプリンター', diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 9a63c1720..bbba6ce79 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -47,6 +47,7 @@ import { Square, User, Pause, + Weight, } from 'lucide-react'; import { api } from '../api/client'; import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date'; @@ -66,6 +67,11 @@ function formatDuration(seconds: number | null | undefined): string { return `${minutes}m`; } +function formatWeight(g: number, useKg = false): string { + if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`; + return `${Math.round(g)}g`; +} + function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record) => string): string { if (!dateString) return t?.('queue.time.asap') ?? 'ASAP'; const date = parseUTCDate(dateString); @@ -450,6 +456,12 @@ function SortableQueueItem({ {formatDuration(item.print_time_seconds)} )} + {item.filament_used_grams && ( + + + {formatWeight(item.filament_used_grams)} + + )} {item.created_by_username && ( @@ -894,6 +906,11 @@ export function QueuePage() { return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0); }, [pendingItems]); + // Calculate total material weight + const totalWeight = useMemo(() => { + return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0); + }, [pendingItems]); + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; @@ -925,7 +942,7 @@ export function QueuePage() { {/* Summary Cards */} - + @@ -968,6 +985,20 @@ export function QueuePage() { + + + + + + + + {formatWeight(totalWeight)} + {t('queue.summary.totalWeight')} + + + + +
{formatWeight(totalWeight)}
{t('queue.summary.totalWeight')}