Explorar el Código

feat(queue): add filament weight display
- Show total filament weight on Queue page
- Display weight per queue item
- Add translations for queue weight labels

AneoPsy hace 3 meses
padre
commit
aa684b4b36

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

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

+ 51 - 17
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
 

+ 1 - 0
frontend/src/api/client.ts

@@ -1211,6 +1211,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;

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -730,6 +730,7 @@ export default {
       printing: 'Druckt',
       queued: 'In Warteschlange',
       totalTime: 'Gesamte Wartezeit',
+      totalWeight: 'Gesamtgewicht der Warteschlange',
       history: 'Verlauf',
     },
     // Filters

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -730,6 +730,7 @@ export default {
       printing: 'Printing',
       queued: 'Queued',
       totalTime: 'Total Queue Time',
+      totalWeight: 'Total Queue Weight',
       history: 'History',
     },
     // Filters

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -730,6 +730,7 @@ export default {
       printing: 'Impressions',
       queued: 'En attente',
       totalTime: 'Temps total estimé',
+      totalWeight: 'Poids total estimé',
       history: 'Historique',
     },
     // Filters

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -717,6 +717,7 @@ export default {
       printing: 'In stampa',
       queued: 'In coda',
       totalTime: 'Tempo totale coda',
+      totalWeight: 'Peso totale della coda',
       history: 'Cronologia',
     },
     // Filters

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -798,6 +798,7 @@ export default {
       queued: 'キュー中',
       history: '履歴',
       totalTime: 'キュー合計時間',
+      totalWeight: 'キュー合計重量',
     },
     filter: {
       allPrinters: 'すべてのプリンター',

+ 32 - 1
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, unknown>) => 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)}
               </span>
             )}
+            {item.filament_used_grams && (
+              <span className="flex items-center gap-1.5">
+                <Weight className="w-3.5 h-3.5" />
+                {formatWeight(item.filament_used_grams)}
+              </span>
+            )}
             {item.created_by_username && (
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
@@ -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() {
       </div>
 
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
+      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
@@ -968,6 +985,20 @@ export function QueuePage() {
           </CardContent>
         </Card>
 
+        <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
+                <Weight className="w-5 h-5 text-purple-500" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">