Browse Source

feat(archives): build-plate icon on cards + uniform printer/model line (#1253)

  Show an OrcaSlicer-style bed icon in the archive card's printer-name row
  indicating which build plate the print was sliced for (Cool /
  Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
  with the full plate name in the hover tooltip. Closes the gap where
  users had to remember which plate matched a re-print or open the
  source 3MF in a slicer just to read the bed setting.

  Card row also unified: archives with a real Bambuddy-printer
  association used to render "H2D-1 GCODE ..." while slicer-only uploads
  rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
  Drop the "Sliced for " prefix so both render as a uniform
  "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
  regardless of provenance.

  Backend: new bed_type column on print_archives (idempotent ALTER TABLE
  migration; SQLite + Postgres safe). Populated from curr_bed_type in
  Metadata/slice_info.config (per-plate, authoritative -- that's what
  got sent to the printer for the exported plate) with a fallback to
  project_settings.config for older 3MF shapes. Wired through both
  archive_to_response() (the hand-rolled dict converter that bypasses
  from_attributes -- easy to miss) and the /rescan endpoint, so old
  archives can be re-parsed via the existing per-archive Rescan button.

  Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
  supported) re-opens every NULL archive's 3MF on disk to populate the
  column. Auto-loads .env from project root before importing backend
  modules (config.py reads DATABASE_URL from os.environ at import time,
  not from pydantic-settings at Settings() time) and prints the resolved
  DB URL with credentials redacted, so operators can confirm they're
  hitting the intended database -- Postgres or SQLite.

  Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
  under /img/ because that path is already statically mounted; a
  toplevel /bed-icons/ tried first hit the SPA catch-all and returned
  index.html as text/html. New utils/bedType.ts maps slicer strings
  case-insensitively, covering both Bambu Studio and OrcaSlicer naming
  variants for the same physical plate. Unmapped or NULL bed_type
  simply omits the icon, so cards stay clean for pre-feature archives.
maziggy 2 weeks ago
parent
commit
79d54a8d53

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 3 - 0
backend/app/api/routes/archives.py

@@ -128,6 +128,7 @@ def archive_to_response(
         "total_layers": archive.total_layers,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
+        "bed_type": archive.bed_type,
         "nozzle_temperature": archive.nozzle_temperature,
         "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
@@ -1220,6 +1221,8 @@ async def rescan_archive(
         archive.nozzle_diameter = metadata["nozzle_diameter"]
     if metadata.get("bed_temperature"):
         archive.bed_temperature = metadata["bed_temperature"]
+    if metadata.get("bed_type"):
+        archive.bed_type = metadata["bed_type"]
     if metadata.get("nozzle_temperature"):
         archive.nozzle_temperature = metadata["nozzle_temperature"]
     if metadata.get("makerworld_url"):

+ 5 - 0
backend/app/core/database.py

@@ -1814,6 +1814,11 @@ async def run_migrations(conn):
     # stale-cancel + new-archive, losing started_at continuity.
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN subtask_id VARCHAR(64)")
 
+    # Migration: Add bed_type to print_archives (#1253)
+    # Build plate type extracted from 3MF (curr_bed_type), drives the bed icon
+    # rendered on archive cards.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
+
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # "total consumption" energy mode can compute (last - first) deltas.

+ 1 - 0
backend/app/models/archive.py

@@ -33,6 +33,7 @@ class PrintArchive(Base):
     total_layers: Mapped[int | None] = mapped_column(Integer)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
+    bed_type: Mapped[str | None] = mapped_column(String(64))  # e.g. "Cool Plate", "Textured PEI Plate"
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
 
     # Printer model this file was sliced for (extracted from 3MF metadata)

+ 1 - 0
backend/app/schemas/archive.py

@@ -66,6 +66,7 @@ class ArchiveResponse(BaseModel):
     total_layers: int | None = None
     nozzle_diameter: float | None
     bed_temperature: int | None
+    bed_type: str | None = None  # e.g. "Cool Plate", "Textured PEI Plate" (from 3MF curr_bed_type)
     nozzle_temperature: int | None
 
     sliced_for_model: str | None = None  # Printer model this file was sliced for

+ 10 - 0
backend/app/services/archive.py

@@ -210,6 +210,8 @@ class ThreeMFParser:
                             self.metadata["print_time_seconds"] = int(value)
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)
+                        elif key == "curr_bed_type" and value:
+                            self.metadata["bed_type"] = value
 
                     # Extract printable objects for skip object functionality
                     # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
@@ -411,6 +413,13 @@ class ThreeMFParser:
                 from backend.app.utils.printer_models import normalize_printer_model
 
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
+
+            # Build plate type — only set from project_settings if slice_info didn't already
+            # provide it (slice_info is more authoritative as it reflects the exported plate).
+            if "bed_type" not in self.metadata and "curr_bed_type" in data:
+                val = data["curr_bed_type"]
+                if isinstance(val, str) and val.strip():
+                    self.metadata["bed_type"] = val.strip()
         except Exception:
             pass  # Print settings are optional; missing values are left unset
 
@@ -1120,6 +1129,7 @@ class ArchiveService:
             total_layers=metadata.get("total_layers"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
+            bed_type=metadata.get("bed_type"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
             sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),

BIN
frontend/public/img/bed/bed_cool.png


BIN
frontend/public/img/bed/bed_cool_supertack.png


BIN
frontend/public/img/bed/bed_engineering.png


BIN
frontend/public/img/bed/bed_high_templ.png


BIN
frontend/public/img/bed/bed_pei.png


BIN
frontend/public/img/bed/bed_pei_cool.png


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

@@ -453,6 +453,7 @@ export interface Archive {
   total_layers: number | null;
   nozzle_diameter: number | null;
   bed_temperature: number | null;
+  bed_type: string | null;  // Build plate type from 3MF (e.g. "Cool Plate", "Textured PEI Plate")
   nozzle_temperature: number | null;
   sliced_for_model: string | null;  // Printer model this file was sliced for
   status: string;

+ 15 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -60,6 +60,7 @@ import { SliceModal } from '../components/SliceModal';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
+import { getBedTypeInfo } from '../utils/bedType';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -926,6 +927,12 @@ function ArchiveCard({
         </div>
         <div className="flex items-center gap-2 mb-3 flex-wrap">
           <p className="text-xs text-bambu-gray">{printerName}</p>
+          {(() => {
+            const bed = getBedTypeInfo(archive.bed_type);
+            return bed ? (
+              <img src={bed.icon} alt={bed.label} title={bed.label} className="w-4 h-4 flex-shrink-0" />
+            ) : null;
+          })()}
           {/* File type badge */}
           <span
             className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
@@ -2030,6 +2037,12 @@ function ArchiveListRow({
                   {archive.sliced_for_model}
                 </span>
               )}
+              {(() => {
+                const bed = getBedTypeInfo(archive.bed_type);
+                return bed ? (
+                  <img src={bed.icon} alt={bed.label} title={bed.label} className="w-3.5 h-3.5 flex-shrink-0" />
+                ) : null;
+              })()}
               {archive.sliced_for_model && archive.filament_type && (
                 <span className="text-bambu-gray/50">·</span>
               )}
@@ -3463,7 +3476,7 @@ export function ArchivesPage() {
               <ArchiveCard
                 key={archive.id}
                 archive={archive}
-                printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
+                printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}
@@ -3506,7 +3519,7 @@ export function ArchivesPage() {
                 <ArchiveListRow
                   key={archive.id}
                   archive={archive}
-                  printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
+                  printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
                   isSelected={selectedIds.has(archive.id)}
                   onSelect={toggleSelect}
                   selectionMode={selectionMode}

+ 30 - 0
frontend/src/utils/bedType.ts

@@ -0,0 +1,30 @@
+// Map slicer bed-type strings to icon assets shipped under /bed-icons/.
+// Source values come straight from the 3MF (curr_bed_type in slice_info.config
+// or project_settings.config). BambuStudio and OrcaSlicer disagree on a few
+// labels for the same physical plate, so this map normalises both spellings
+// to a single icon.
+
+const ICON_BASE = '/img/bed';
+
+interface BedTypeInfo {
+  icon: string;
+  label: string;
+}
+
+const BED_TYPE_MAP: Record<string, BedTypeInfo> = {
+  'cool plate': { icon: `${ICON_BASE}/bed_cool.png`, label: 'Cool Plate' },
+  'pc plate': { icon: `${ICON_BASE}/bed_cool.png`, label: 'Cool Plate' },
+  'cool plate (supertack)': { icon: `${ICON_BASE}/bed_cool_supertack.png`, label: 'Cool Plate SuperTack' },
+  'supertack plate': { icon: `${ICON_BASE}/bed_cool_supertack.png`, label: 'Cool Plate SuperTack' },
+  'bambu cool plate supertack': { icon: `${ICON_BASE}/bed_cool_supertack.png`, label: 'Cool Plate SuperTack' },
+  'engineering plate': { icon: `${ICON_BASE}/bed_engineering.png`, label: 'Engineering Plate' },
+  'high temp plate': { icon: `${ICON_BASE}/bed_high_templ.png`, label: 'High Temp Plate' },
+  'textured pei plate': { icon: `${ICON_BASE}/bed_pei.png`, label: 'Textured PEI Plate' },
+  'pei plate': { icon: `${ICON_BASE}/bed_pei.png`, label: 'PEI Plate' },
+  'smooth pei plate': { icon: `${ICON_BASE}/bed_pei_cool.png`, label: 'Smooth PEI Plate' },
+};
+
+export function getBedTypeInfo(bedType: string | null | undefined): BedTypeInfo | null {
+  if (!bedType) return null;
+  return BED_TYPE_MAP[bedType.trim().toLowerCase()] ?? null;
+}

+ 175 - 0
scripts/backfill_archive_bed_type.py

@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+"""Backfill bed_type on existing archives from their 3MF files.
+
+Newly-ingested archives capture curr_bed_type at parse time. Archives created
+before this column existed have bed_type=NULL — this script re-opens each
+archive's 3MF on disk and populates bed_type from slice_info.config (and
+project_settings.config as a fallback).
+
+Usage:
+    # From the bambuddy directory:
+    python scripts/backfill_archive_bed_type.py
+
+    # Or via docker:
+    docker exec -it bambuddy python scripts/backfill_archive_bed_type.py
+
+    # Preview without writing:
+    python scripts/backfill_archive_bed_type.py --dry-run
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+import zipfile
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+
+def _load_dotenv() -> Path | None:
+    """Populate os.environ from project-root .env so the script picks up the
+    same DATABASE_URL / DATA_DIR the backend uses, regardless of how the shell
+    was launched. Backend's config.py reads DATABASE_URL at import time, so we
+    must do this BEFORE importing anything from backend.app."""
+    env_file = PROJECT_ROOT / ".env"
+    if not env_file.exists():
+        return None
+    for raw in env_file.read_text().splitlines():
+        line = raw.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, _, value = line.partition("=")
+        key = key.strip()
+        value = value.strip().strip('"').strip("'")
+        # Don't override values already set in the shell (matches docker / systemd
+        # convention: explicit env wins over .env file).
+        os.environ.setdefault(key, value)
+    return env_file
+
+
+_loaded_env = _load_dotenv()
+
+from sqlalchemy import select  # noqa: E402
+
+from backend.app.core.config import settings  # noqa: E402
+from backend.app.core.database import async_session, init_db  # noqa: E402
+from backend.app.models.archive import PrintArchive  # noqa: E402
+
+
+def _describe_db() -> str:
+    """Redact credentials from a DATABASE_URL for safe display."""
+    url = settings.database_url
+    if "://" not in url:
+        return url
+    scheme, rest = url.split("://", 1)
+    if "@" in rest:
+        creds, host = rest.split("@", 1)
+        user = creds.split(":", 1)[0]
+        return f"{scheme}://{user}:***@{host}"
+    return url
+
+
+def extract_bed_type(file_path: Path) -> str | None:
+    """Pull curr_bed_type from a 3MF file. Returns the raw slicer string."""
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+
+            # Primary source: slice_info.config (XML)
+            if "Metadata/slice_info.config" in names:
+                try:
+                    root = ET.fromstring(zf.read("Metadata/slice_info.config").decode())
+                    plate = root.find(".//plate")
+                    if plate is not None:
+                        for meta in plate.findall("metadata"):
+                            if meta.get("key") == "curr_bed_type":
+                                value = meta.get("value")
+                                if value:
+                                    return value.strip()
+                except ET.ParseError:
+                    pass
+
+            # Fallback: project_settings.config (JSON)
+            if "Metadata/project_settings.config" in names:
+                try:
+                    data = json.loads(zf.read("Metadata/project_settings.config").decode())
+                    val = data.get("curr_bed_type")
+                    if isinstance(val, str) and val.strip():
+                        return val.strip()
+                except json.JSONDecodeError:
+                    pass
+    except (zipfile.BadZipFile, OSError):
+        return None
+    return None
+
+
+async def backfill(dry_run: bool = False):
+    print("=" * 60)
+    print("Archive bed_type backfill")
+    print("=" * 60)
+    print(f"Database: {_describe_db()}")
+    print(f".env loaded: {_loaded_env}" if _loaded_env else ".env: not found (using shell env only)")
+    print()
+    if dry_run:
+        print("DRY RUN MODE - No changes will be written")
+        print()
+
+    # Ensure the bed_type column exists. Safe to run against a live DB —
+    # init_db() is idempotent and is what the backend runs at every startup.
+    await init_db()
+
+    async with async_session() as db:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.bed_type.is_(None)))
+        archives = result.scalars().all()
+        print(f"Found {len(archives)} archives with bed_type=NULL")
+        print()
+
+        updated = 0
+        skipped_missing = 0
+        skipped_no_value = 0
+
+        for archive in archives:
+            if not archive.file_path:
+                skipped_missing += 1
+                continue
+            file_path = settings.base_dir / archive.file_path
+            if not file_path.exists():
+                skipped_missing += 1
+                continue
+
+            bed_type = extract_bed_type(file_path)
+            if not bed_type:
+                skipped_no_value += 1
+                continue
+
+            print(f"  [{archive.id}] {archive.print_name or archive.filename}: -> {bed_type}")
+            if not dry_run:
+                archive.bed_type = bed_type
+            updated += 1
+
+        if not dry_run:
+            await db.commit()
+
+        print()
+        print("-" * 60)
+        print(f"Updated: {updated}")
+        print(f"Skipped (file missing): {skipped_missing}")
+        print(f"Skipped (no bed_type in 3MF): {skipped_no_value}")
+        if dry_run and updated:
+            print()
+            print("Run without --dry-run to apply.")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Backfill bed_type on existing archives")
+    parser.add_argument("--dry-run", action="store_true", help="Show changes without writing")
+    args = parser.parse_args()
+    asyncio.run(backfill(dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+    main()

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CwfrW2a1.js


BIN
static/img/bed/bed_cool.png


BIN
static/img/bed/bed_cool_supertack.png


BIN
static/img/bed/bed_engineering.png


BIN
static/img/bed/bed_high_templ.png


BIN
static/img/bed/bed_pei.png


BIN
static/img/bed/bed_pei_cool.png


+ 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-CqLSsmpD.js"></script>
+    <script type="module" crossorigin src="/assets/index-CwfrW2a1.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff