فهرست منبع

feat(queue): show build plate type on queue items + print modal (#1281)

  Reporter on a multi-printer farm with 40+-plate runs needed to walk to
  the printer with the right physical plate, but the queue and the
  scheduling modal didn't surface curr_bed_type the way the archive card
  already did.

  New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
  both the queue API and the /plates endpoint can return per-plate values.
  PrintQueueItemResponse.bed_type populated from archive.bed_type /
  library_file.file_metadata['bed_type'] as the default, then overridden
  per-plate via the helper when item.plate_id is set. /archives/{id}/plates
  (and library equivalent) include bed_type in each plate object.

  Per-plate accuracy matters because archive.bed_type is captured at
  ingest as only the first plate's value (services/archive.py:235) -
  a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
  level for every plate. The helper re-reads the 3MF and returns the
  truth.

  Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
  header all render bed icon + canonical label via the existing
  getBedTypeInfo() helper, same as the archive card.
maziggy 3 ماه پیش
والد
کامیت
432080d500

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


+ 13 - 1
backend/app/api/routes/archives.py

@@ -3268,7 +3268,14 @@ async def get_archive_plates(
                 root = ET.fromstring(content)
 
                 for plate_elem in root.findall(".//plate"):
-                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
+                    plate_info = {
+                        "filaments": [],
+                        "prediction": None,
+                        "weight": None,
+                        "name": None,
+                        "objects": [],
+                        "bed_type": None,
+                    }
 
                     # Get plate index from metadata
                     plate_index = None
@@ -3290,6 +3297,10 @@ async def get_archive_plates(
                                 plate_info["weight"] = float(value)
                             except ValueError:
                                 pass  # Skip non-numeric filament weight
+                        elif key == "curr_bed_type" and value:
+                            # Per-plate bed type so the PrintModal can show the
+                            # right plate alongside each option (#1281).
+                            plate_info["bed_type"] = value.strip()
 
                     # Get filaments used in this plate
                     for filament_elem in plate_elem.findall("filament"):
@@ -3391,6 +3402,7 @@ async def get_archive_plates(
                         "print_time_seconds": meta.get("prediction"),
                         "filament_used_grams": meta.get("weight"),
                         "filaments": meta.get("filaments", []),
+                        "bed_type": meta.get("bed_type"),
                     }
                 )
 

+ 9 - 1
backend/app/api/routes/print_queue.py

@@ -36,7 +36,7 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 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
+from backend.app.utils.threemf_tools import extract_bed_type_from_3mf, extract_filament_usage_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -245,6 +245,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.layer_height = item.archive.layer_height
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.sliced_for_model = item.archive.sliced_for_model
+            response.bed_type = item.archive.bed_type
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
@@ -252,10 +253,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
                     plate_weight = sum(
                         f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
                     )
+                    plate_bed = extract_bed_type_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 plate_bed:
+                        response.bed_type = plate_bed
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -272,6 +276,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.layer_height = item.library_file.file_metadata.get("layer_height")
             response.nozzle_diameter = item.library_file.file_metadata.get("nozzle_diameter")
             response.sliced_for_model = item.library_file.file_metadata.get("sliced_for_model")
+            response.bed_type = item.library_file.file_metadata.get("bed_type")
         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
@@ -280,10 +285,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
                 plate_weight = sum(
                     f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
                 )
+                plate_bed = extract_bed_type_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 plate_bed:
+                    response.bed_type = plate_bed
     if item.printer:
         response.printer_name = item.printer.name
     return response

+ 5 - 0
backend/app/schemas/print_queue.py

@@ -132,6 +132,11 @@ class PrintQueueItemResponse(BaseModel):
     layer_height: float | None = None  # e.g. 0.2 (from archive/library file)
     nozzle_diameter: float | None = None  # e.g. 0.4 (from archive/library file)
     sliced_for_model: str | None = None  # e.g. "P1S" (from archive/library file)
+    # Build plate type (e.g. "Textured PEI Plate") so the user knows which
+    # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
+    # 3MFs: when `plate_id` is set, the value is the matching plate's
+    # `curr_bed_type` rather than the archive-level first-plate default.
+    bed_type: str | None = None
 
     # User tracking (Issue #206)
     created_by_id: int | None = None

+ 50 - 0
backend/app/utils/threemf_tools.py

@@ -484,6 +484,56 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
     return filament_usage
 
 
+def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
+    """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
+
+    ``archive.bed_type`` is captured at ingest time but is one value per archive
+    (the first plate's `curr_bed_type` — see services/archive.py:235). For a
+    multi-plate 3MF where different plates target different beds (e.g. a 40-plate
+    file mixing PEI + Engineering), the archive-level value lies. When a queue
+    item or print modal targets a specific plate, this re-reads the 3MF and
+    returns that plate's actual bed type.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Plate index to filter for; if None, returns the first plate's
+            ``curr_bed_type`` (matches the archive-level capture).
+
+    Returns:
+        Bed type string (e.g. "Textured PEI Plate"), or None if not found.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            for plate_elem in root.findall(".//plate"):
+                plate_index = None
+                bed_value: str | None = None
+                for meta in plate_elem.findall("metadata"):
+                    key = meta.get("key")
+                    if key == "index":
+                        try:
+                            plate_index = int(meta.get("value", "0"))
+                        except ValueError:
+                            pass  # Skip plate with unparseable index
+                    elif key == "curr_bed_type" and meta.get("value"):
+                        bed_value = (meta.get("value") or "").strip()
+
+                if plate_id is None:
+                    # First plate wins when no plate_id is requested.
+                    return bed_value
+                if plate_index == plate_id:
+                    return bed_value
+    except Exception:
+        pass  # Return None on any failure rather than raising — caller decides
+
+    return None
+
+
 # Header values exposed as `{placeholder}` substitutions inside snippets.
 # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
 # Bambu/Orca header keys (`max_z_height`).

+ 141 - 0
backend/tests/unit/test_threemf_tools.py

@@ -10,6 +10,7 @@ import math
 import zipfile
 
 from backend.app.utils.threemf_tools import (
+    extract_bed_type_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_filament_usage_from_3mf,
     extract_plate_extruder_set_from_3mf,
@@ -700,3 +701,143 @@ class TestExtractEmbeddedPresetsFrom3mf:
                 "printer": None,
                 "process": None,
             }
+
+
+class TestExtractBedTypeFrom3mf:
+    """extract_bed_type_from_3mf reads per-plate `curr_bed_type` from
+    slice_info.config so the queue / print modal can show the right plate
+    even on multi-plate 3MFs where different plates target different beds
+    (#1281). archive.bed_type is one-value-per-archive (first plate's
+    curr_bed_type — see services/archive.py:235), so for accurate
+    per-plate surfacing we have to re-read the 3MF."""
+
+    def test_single_plate_returns_bed_type(self, tmp_path):
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <metadata key="curr_bed_type" value="Textured PEI Plate"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
+
+    def test_multi_plate_returns_per_plate_value(self, tmp_path):
+        # Reporter's case: a 3MF mixing PEI + Engineering across plates.
+        # Looking up by plate_id must return THAT plate's value, not the
+        # first plate's value the archive happens to cache.
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <metadata key="curr_bed_type" value="Textured PEI Plate"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <metadata key="curr_bed_type" value="Engineering Plate"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="3"/>
+                <metadata key="curr_bed_type" value="Cool Plate"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path, plate_id=1) == "Textured PEI Plate"
+        assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
+        assert extract_bed_type_from_3mf(file_path, plate_id=3) == "Cool Plate"
+
+    def test_no_plate_id_returns_first_plate(self, tmp_path):
+        # The plate_id=None branch must match the archive-level capture
+        # convention (first plate wins) so callers that don't care about
+        # plate selection see the same value the archive table holds.
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <metadata key="curr_bed_type" value="Cool Plate SuperTack"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <metadata key="curr_bed_type" value="Engineering Plate"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path) == "Cool Plate SuperTack"
+
+    def test_unknown_plate_id_returns_none(self, tmp_path):
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <metadata key="curr_bed_type" value="Textured PEI Plate"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path, plate_id=99) is None
+
+    def test_plate_without_bed_type_returns_none(self, tmp_path):
+        # Older slicers may export a plate without curr_bed_type. The
+        # helper must return None rather than falling through to another
+        # plate's value (which would silently lie).
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <metadata key="curr_bed_type" value="Engineering Plate"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path, plate_id=1) is None
+        assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
+
+    def test_missing_slice_info_returns_none(self, tmp_path):
+        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())
+
+        assert extract_bed_type_from_3mf(file_path) is None
+
+    def test_invalid_file_returns_none(self, tmp_path):
+        file_path = tmp_path / "invalid.3mf"
+        file_path.write_text("not a zip file")
+
+        assert extract_bed_type_from_3mf(file_path) is None
+
+    def test_whitespace_trimmed(self, tmp_path):
+        # 3MF values sometimes carry surrounding whitespace from manual
+        # template tweaks; getBedTypeInfo() on the frontend is also
+        # whitespace-tolerant, but the wire shape should be clean.
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <metadata key="curr_bed_type" value="  Textured PEI Plate  "/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(create_mock_3mf(xml_content).read())
+
+        assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"

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

@@ -1915,6 +1915,7 @@ export interface PrintQueueItem {
   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
+  bed_type?: string | null;  // Build plate type for this print (per-plate accurate, #1281)
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_username?: string | null;

+ 13 - 0
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
 import { withStreamToken } from '../../api/client';
+import { getBedTypeInfo } from '../../utils/bedType';
 
 /**
  * Plate selection grid for multi-plate 3MF files.
@@ -96,6 +97,18 @@ export function PlateSelector({
                     : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
                   {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
                 </p>
+                {(() => {
+                  // Per-plate bed-type badge so multi-plate prints make the
+                  // required plate explicit at scheduling time (#1281).
+                  const bed = getBedTypeInfo(plate.bed_type);
+                  if (!bed) return null;
+                  return (
+                    <p className="text-xs text-bambu-gray flex items-center gap-1 mt-0.5 truncate" title={bed.label}>
+                      <img src={bed.icon} alt="" className="w-3.5 h-3.5 object-contain flex-shrink-0" />
+                      <span className="truncate">{bed.label}</span>
+                    </p>
+                  );
+                })()}
               </div>
               {!multiSelect && isSelected && (
                 <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />

+ 21 - 0
frontend/src/components/PrintModal/index.tsx

@@ -13,6 +13,7 @@ import { buildLoadedFilaments, useFilamentMapping } from '../../hooks/useFilamen
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
 import { getCurrencySymbol } from '../../utils/currency';
+import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
 import { FilamentMapping } from './FilamentMapping';
@@ -979,6 +980,26 @@ export function PrintModal({
               )}
             </p>
 
+            {/* Build-plate badge for the selected (or sole) plate — surfaced
+                early so the user knows which plate to mount before scheduling
+                (#1281). PlateSelector renders its own per-plate badges for
+                multi-plate files; this badge covers the single-plate case and
+                the multi-plate case where exactly one plate is selected. */}
+            {(() => {
+              if (!plates.length) return null;
+              const target = selectedPlate != null
+                ? plates.find(p => p.index === selectedPlate)
+                : plates[0];
+              const bed = getBedTypeInfo(target?.bed_type);
+              if (!bed) return null;
+              return (
+                <p className="flex items-center gap-1.5 text-xs text-bambu-gray -mt-2" title={bed.label}>
+                  <img src={bed.icon} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
+                  <span className="truncate">{bed.label}</span>
+                </p>
+              );
+            })()}
+
             {/* Plate selection - first so users know filament requirements before selecting printers */}
             <PlateSelector
               plates={plates}

+ 1 - 0
frontend/src/components/PrintModal/types.ts

@@ -111,6 +111,7 @@ export interface PlateInfo {
   }>;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
+  bed_type?: string | null;  // Build plate type for this plate, e.g. "Textured PEI Plate" (#1281)
 }
 
 /**

+ 14 - 0
frontend/src/pages/QueuePage.tsx

@@ -57,6 +57,7 @@ import {
 } from 'lucide-react';
 import { api, ApiError } from '../api/client';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
+import { getBedTypeInfo } from '../utils/bedType';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card } from '../components/Card';
 import { Button } from '../components/Button';
@@ -525,6 +526,19 @@ function SortableQueueItem({
                 {formatWeight(item.filament_used_grams)}
               </span>
             )}
+            {(() => {
+              // Build plate badge so the user knows which plate to mount before
+              // walking to the printer (#1281). Hidden when the 3MF doesn't
+              // carry curr_bed_type or the slicer used an unknown label.
+              const bed = getBedTypeInfo(item.bed_type);
+              if (!bed) return null;
+              return (
+                <span className="flex items-center gap-1 sm:gap-1.5" title={bed.label}>
+                  <img src={bed.icon} alt="" className="w-3.5 h-3.5 sm:w-4 sm:h-4 object-contain" />
+                  <span className="truncate max-w-[120px]">{bed.label}</span>
+                </span>
+              );
+            })()}
             {item.created_by_username && (
               <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />

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

@@ -25,6 +25,10 @@ export interface PlateMetadata {
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   filaments: PlateFilament[];
+  // Per-plate build plate type so multi-plate prints can show the right
+  // plate at scheduling time (#1281). Falls back to null for older 3MFs
+  // that don't carry curr_bed_type in slice_info.config.
+  bed_type?: string | null;
 }
 
 // Printer / process preset names the source 3MF was prepared with, read from

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Bkfk-iWd.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DUIsWeHE.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bkfk-iWd.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B9tytutS.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است