Browse Source

refactor(gcode-viewer): archive-scoped previews, bed from capabilities, plate picker

  Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
  archive-preview tool, matching Bambuddy's data model instead of the
  OctoPrint-style "connected-printer + library file picker" flow it shipped
  with. Reached only from the Archives page 3D-preview button; URL
  /gcode-viewer?archive=<id>[&plate=<N>].

  Backend:
  - /archives/{id}/gcode accepts ?plate=N and resolves the filename by
    parsing the suffix as int, so zero-padded names like plate_01.gcode
    are found when the plates endpoint reports index 1.
  - /archives/{id}/plates gains top-level has_gcode: bool. Source-only
    3MFs (PNG/JSON fallback path) surface the flag so the frontend can
    skip the picker instead of sending the user into a dead viewer.
  - printer_state_to_dict injects name + model into every WS snapshot so
    consumers render proper labels on the initial tick without racing a
    separate /printers fetch.
  - /gcode-viewer (no trailing slash) dropped from the backend so reloads
    fall through to the SPA catch-all and keep the layout shell; only
    /gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
    the iframe + static assets.

  Frontend:
  - PlatePickerModal shown only for multi-plate archives with sliced
    gcode, grid layout with thumbnails matching the Re-print modal.
  - Source-only archives show a noGcode toast instead of the empty
    viewer.
  - ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
    no trailing slash; GCodeViewerPage iframe forwards
    window.location.search so the archive reference survives both the
    initial navigate and a full-page reload.
  - Viewer iframe's auth path: fetch intercept injects Bearer; a 401
    redirects to / so the SPA handles login.

  Viewer adapter:
  - Stripped the printer selector, WebSocket subscription, library file
    picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
    Selector. The viewer no longer observes live printer state.
  - Bed size derived from /archives/{id}/capabilities.build_volume
    (extracted from the 3MF's printable_area/printable_height), so H2D,
    H-family, and any future printer render on the correct bed without
    a hardcoded map.
  - loadArchiveById accepts a plate param; fetch intercept rewrites
    __bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].

  Nav + locale cleanup:
  - Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
    now, not a destination page).
  - 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
  - platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
    added in all 8 locales.

  ArchivesPage: the now-unreachable ModelViewerModal render paths + its
  showViewer state removed. ModelViewerModal itself stays — File Manager
  still uses it for library file previews (plate picker + .3mf 3D model).

  pre-commit:
  - gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
    so vendored third-party JS libs don't drift away from upstream.

  Incidental sweeps picked up by pre-commit and kept (unrelated but
  benign):
  - NotificationsPage.tsx: single trailing-whitespace line removed.
  - spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
    the pn5180 driver module imported at line 27 does its own
    `import gpiod` and `gpiod.Chip()` calls, so the diag script's
    top-level import was never referenced.

  Tests:
  - 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
    behaviour (plate=N resolution, zero-padded filenames, missing-plate
    404, no-plate fallback, plate=0 rejection, has_gcode true/false).
  - 3 new cases in test_printer_manager.py for name/model WS injection.
  - PlatePickerModal.test.tsx — 6 frontend cases covering render,
    plate-name composition, onSelect payload, backdrop close, and
    thumbnail fallback.
maziggy 1 month ago
parent
commit
c44b62195a

+ 5 - 2
.pre-commit-config.yaml

@@ -19,9 +19,12 @@ repos:
     rev: v5.0.0
     rev: v5.0.0
     hooks:
     hooks:
       - id: trailing-whitespace
       - id: trailing-whitespace
-        exclude: ^static/
+        # Exclude static/ (build output) and gcode_viewer/ (vendored third-party
+        # assets — see gcode_viewer/VENDORED.md) so whitespace normalisation
+        # doesn't drift the files away from upstream.
+        exclude: ^(static/|gcode_viewer/)
       - id: end-of-file-fixer
       - id: end-of-file-fixer
-        exclude: ^static/
+        exclude: ^(static/|gcode_viewer/)
       - id: check-yaml
       - id: check-yaml
       - id: check-json
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)
         exclude: ^(static/|frontend/tsconfig\.)

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


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

@@ -2568,10 +2568,17 @@ async def get_archive_capabilities(
 @router.get("/{archive_id}/gcode")
 @router.get("/{archive_id}/gcode")
 async def get_gcode(
 async def get_gcode(
     archive_id: int,
     archive_id: int,
+    plate: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
-    """Extract and return G-code from the 3MF file."""
+    """Extract and return G-code from the 3MF file.
+
+    When *plate* is provided, returns the G-code for that specific plate
+    (e.g. ``?plate=2`` returns ``Metadata/plate_2.gcode``). If omitted, falls
+    back to the first plate found in the archive (preserving the original
+    behaviour for callers that predate the multi-plate viewer).
+    """
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
     if not archive:
     if not archive:
@@ -2581,6 +2588,9 @@ async def get_gcode(
     if not file_path.is_file():
     if not file_path.is_file():
         raise HTTPException(404, "File not found")
         raise HTTPException(404, "File not found")
 
 
+    if plate is not None and plate < 1:
+        raise HTTPException(400, "Plate index must be >= 1")
+
     try:
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
         with zipfile.ZipFile(file_path, "r") as zf:
             # Bambu 3MF files store G-code in Metadata/plate_X.gcode
             # Bambu 3MF files store G-code in Metadata/plate_X.gcode
@@ -2591,8 +2601,28 @@ async def get_gcode(
                     "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.",
                     "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.",
                 )
                 )
 
 
-            # Get the first plate's G-code (usually plate_1.gcode)
-            gcode_content = zf.read(gcode_files[0]).decode("utf-8")
+            if plate is not None:
+                # Resolve plate → filename via the same parsing the plates
+                # endpoint uses (int() on the suffix), so zero-padded names
+                # like plate_01.gcode are found when the plates endpoint
+                # reported index 1.
+                selected = None
+                for gf in gcode_files:
+                    if not gf.startswith("Metadata/plate_"):
+                        continue
+                    suffix = gf[len("Metadata/plate_") : -len(".gcode")]
+                    try:
+                        if int(suffix) == plate:
+                            selected = gf
+                            break
+                    except ValueError:
+                        continue
+                if selected is None:
+                    raise HTTPException(404, f"Plate {plate} not found in this archive")
+            else:
+                selected = gcode_files[0]
+
+            gcode_content = zf.read(selected).decode("utf-8")
             return Response(content=gcode_content, media_type="text/plain")
             return Response(content=gcode_content, media_type="text/plain")
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
         raise HTTPException(400, "Invalid 3MF file")
         raise HTTPException(400, "Invalid 3MF file")
@@ -3028,11 +3058,17 @@ async def get_archive_plates(
     except Exception as e:
     except Exception as e:
         logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
         logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
 
 
+    # Has gcode iff the plate list was built from .gcode filenames (as opposed
+    # to the JSON/PNG fallback for source-only 3MF projects). Callers that need
+    # to preview gcode — the viewer, skip-objects — can gate on this instead of
+    # 404-ing on every plate request.
+    has_gcode = bool(gcode_files)
     return {
     return {
         "archive_id": archive_id,
         "archive_id": archive_id,
         "filename": archive.filename,
         "filename": archive.filename,
         "plates": plates,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
         "is_multi_plate": len(plates) > 1,
+        "has_gcode": has_gcode,
     }
     }
 
 
 
 

+ 4 - 1
backend/app/main.py

@@ -4628,9 +4628,12 @@ def _gcode_viewer_response(rel: str) -> FileResponse:
     raise _HTTPException(status_code=404)
     raise _HTTPException(status_code=404)
 
 
 
 
-@app.get("/gcode-viewer")
 @app.get("/gcode-viewer/")
 @app.get("/gcode-viewer/")
 async def serve_gcode_viewer_index() -> FileResponse:
 async def serve_gcode_viewer_index() -> FileResponse:
+    """Raw PrettyGCode viewer for the iframe. The bare ``/gcode-viewer``
+    (no trailing slash) intentionally falls through to the SPA catch-all so a
+    full-page reload re-enters the React layout instead of serving the iframe
+    contents standalone."""
     return _gcode_viewer_response("index.html")
     return _gcode_viewer_response("index.html")
 
 
 
 

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

@@ -872,6 +872,16 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
     else:
     else:
         result["cover_url"] = None
         result["cover_url"] = None
+    # Surface the display name + model so WS consumers (gcode viewer printer
+    # selector) can render proper labels on the initial snapshot without racing
+    # a separate /api/v1/printers fetch (#963 follow-up). PrinterInfo only
+    # carries name/serial_number; the model comes through via the `model` arg.
+    if printer_id:
+        _printer_info = printer_manager.get_printer(printer_id)
+        if _printer_info is not None:
+            result["name"] = _printer_info.name
+    if model:
+        result["model"] = model
     return result
     return result
 
 
 
 

+ 243 - 9
backend/tests/integration/test_gcode_viewer.py

@@ -8,8 +8,15 @@ Covers two behaviours added by the GCode viewer PR:
 
 
 2. Path-traversal guard — requests for paths that escape gcode_viewer/
 2. Path-traversal guard — requests for paths that escape gcode_viewer/
    (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
    (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
+
+Plus tests for the archive G-code endpoint behaviour the viewer depends on:
+``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
+flag on the plates endpoint that gates the frontend plate picker.
 """
 """
 
 
+import zipfile
+from pathlib import Path
+
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
@@ -19,9 +26,7 @@ class TestGCodeViewerRouteOrdering:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_gcode_viewer_index_does_not_fall_through_to_spa(
-        self, async_client: AsyncClient
-    ):
+    async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
         """GET /gcode-viewer/ must not return the React SPA index.html.
         """GET /gcode-viewer/ must not return the React SPA index.html.
 
 
         If route ordering is broken the SPA catch-all returns 200 with
         If route ordering is broken the SPA catch-all returns 200 with
@@ -38,13 +43,23 @@ class TestGCodeViewerRouteOrdering:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_gcode_viewer_no_trailing_slash_redirects_or_responds(
-        self, async_client: AsyncClient
-    ):
-        """GET /gcode-viewer (no trailing slash) is handled by the explicit route."""
-        response = await async_client.get("/gcode-viewer", follow_redirects=True)
+    async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
+        """GET /gcode-viewer (no trailing slash) must fall through to the SPA.
+
+        Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
+        form is what the iframe in GCodeViewerPage requests. The bare path is
+        the SPA route the user navigates to; reloading it must re-enter the
+        React layout rather than serve the iframe contents standalone.
+        """
+        response = await async_client.get("/gcode-viewer", follow_redirects=False)
+        # SPA catch-all serves 200 with the React index.html (which contains
+        # <div id="root">). If the build output isn't present the catch-all
+        # may 404 — both outcomes are acceptable here; the key invariant is
+        # that we do NOT serve the standalone PrettyGCode index.html (which
+        # starts with <!doctype html> and contains "PrettyGCode").
         assert response.status_code in (200, 404)
         assert response.status_code in (200, 404)
-        assert b'<div id="root">' not in response.content
+        if response.status_code == 200:
+            assert b"PrettyGCode" not in response.content
 
 
 
 
 class TestGCodeViewerPathTraversal:
 class TestGCodeViewerPathTraversal:
@@ -83,3 +98,222 @@ class TestGCodeViewerPathTraversal:
         """A safe but nonexistent path returns 404, not 403."""
         """A safe but nonexistent path returns 404, not 403."""
         response = await async_client.get("/gcode-viewer/does-not-exist.js")
         response = await async_client.get("/gcode-viewer/does-not-exist.js")
         assert response.status_code == 404
         assert response.status_code == 404
+
+
+def _write_3mf(
+    path: Path,
+    plate_gcode: dict[int, str] | None = None,
+    plate_filenames: dict[int, str] | None = None,
+    include_png_for: list[int] | None = None,
+) -> None:
+    """Write a synthetic Bambu-style 3MF zip at *path*.
+
+    Parameters let a single test pin one specific shape:
+
+    - ``plate_gcode`` — {plate_index: gcode_text} written at
+      ``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
+    - ``plate_filenames`` — {plate_index: custom_filename} written with the
+      raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
+    - ``include_png_for`` — plate indices to add PNG stubs for. Use to
+      simulate source-only archives (PNG/JSON present, no .gcode).
+
+    Leaving all three empty produces an archive that the plates endpoint
+    will parse as empty (no plates).
+    """
+    plate_gcode = plate_gcode or {}
+    plate_filenames = plate_filenames or {}
+    include_png_for = include_png_for or []
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        for idx, text in plate_gcode.items():
+            zf.writestr(f"Metadata/plate_{idx}.gcode", text)
+        for idx, filename in plate_filenames.items():
+            zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
+        for idx in include_png_for:
+            zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
+            zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')
+
+
+@pytest.fixture
+def _patch_archive_base_dir(monkeypatch, tmp_path):
+    """Point archive file_path resolution at *tmp_path* for this test."""
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+class TestArchiveGcodePlateParam:
+    """The viewer passes ``?plate=N`` for multi-plate archives."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_param_returns_that_plate(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "multi.3mf"
+        _write_3mf(
+            threemf,
+            plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
+        )
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
+
+        assert response.status_code == 200
+        assert "plate 2" in response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_param_zero_padded_filename_resolves(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.
+
+        Regression: the original exact-string match on ``Metadata/plate_1.gcode``
+        missed zero-padded filenames exported by some slicers, so the picker
+        showed plate 1 as selectable but the viewer 404'd on selection.
+        """
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "padded.3mf"
+        with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
+
+        assert response.status_code == 200
+        assert "padded plate" in response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_missing_plate_returns_404(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """Requesting a plate index the archive doesn't contain returns 404."""
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "only_plate_2.3mf"
+        _write_3mf(threemf, plate_gcode={2: "G0\n"})
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_plate_param_returns_first_plate(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """Omitting ?plate falls back to the first gcode in the archive.
+
+        Preserves the pre-plate-param behaviour — existing callers that don't
+        know about plates still get something sensible back.
+        """
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "single.3mf"
+        _write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
+
+        assert response.status_code == 200
+        assert "only plate" in response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_param_rejects_zero_and_negative(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """``?plate=0`` or negative must 400 — not silently fall through."""
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "any.3mf"
+        _write_3mf(threemf, plate_gcode={1: "G0\n"})
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
+
+        assert response.status_code == 400
+
+
+class TestArchivePlatesHasGcode:
+    """The ``has_gcode`` flag on /plates gates the frontend plate picker."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_has_gcode_true_when_gcode_files_present(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """Sliced multi-plate 3MF → has_gcode=true."""
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "sliced.3mf"
+        _write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["has_gcode"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_has_gcode_false_for_source_only_archive(
+        self,
+        async_client: AsyncClient,
+        archive_factory,
+        printer_factory,
+        _patch_archive_base_dir,
+    ):
+        """Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.
+
+        Regression for the archive-69 bug: the PNG/JSON fallback path made the
+        plates endpoint report plate indices that the gcode endpoint couldn't
+        actually serve, so every viewer preview 404'd. The frontend now uses
+        has_gcode to suppress the picker + show a toast instead.
+        """
+        tmp = _patch_archive_base_dir
+        threemf = tmp / "project.3mf"
+        _write_3mf(threemf, include_png_for=[1, 2, 3])  # no .gcode at all
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["has_gcode"] is False
+        # The endpoint still reports plates (from JSON/PNG) — the flag is what
+        # the frontend keys on, not an empty plate list.
+        assert len(data["plates"]) == 3

+ 45 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -986,6 +986,51 @@ class TestPrinterStateToDict:
         finally:
         finally:
             printer_manager.set_awaiting_plate_clear(12345, False)
             printer_manager.set_awaiting_plate_clear(12345, False)
 
 
+    def test_name_and_model_surfaced_when_registered(self, mock_state):
+        """Registered PrinterInfo name + model arg should land in the WS payload.
+
+        Regression for #963 follow-up: without this, the gcode viewer's printer
+        selector had to wait on a /printers fetch before it could render real
+        names, and the initial WS snapshot showed "Printer 1" fallbacks.
+        """
+        from backend.app.services.printer_manager import PrinterInfo, printer_manager
+
+        # Register a stub PrinterInfo; the real manager writes this on connect.
+        printer_manager._printer_info[98765] = PrinterInfo(name="My X1C", serial_number="01S00-0")
+        try:
+            result = printer_state_to_dict(mock_state, printer_id=98765, model="X1C")
+            assert result["name"] == "My X1C"
+            assert result["model"] == "X1C"
+        finally:
+            printer_manager._printer_info.pop(98765, None)
+
+    def test_name_and_model_absent_when_no_printer_id(self, mock_state):
+        """Without a printer_id (unusual callsites), name/model keys stay absent.
+
+        The consumers (gcode viewer, frontend card) tolerate missing keys; what
+        they can't tolerate is an unrelated printer's name accidentally leaking
+        into a status meant for a different one.
+        """
+        result = printer_state_to_dict(mock_state)
+        assert "name" not in result
+        assert "model" not in result
+
+    def test_model_absent_when_arg_is_none(self, mock_state):
+        """`model` arg=None must not plant a `model` key at all.
+
+        If the arg is None, callers didn't know the model yet; emitting a
+        `model: null` field would overwrite a good value cached client-side.
+        """
+        from backend.app.services.printer_manager import PrinterInfo, printer_manager
+
+        printer_manager._printer_info[55555] = PrinterInfo(name="N", serial_number="S")
+        try:
+            result = printer_state_to_dict(mock_state, printer_id=55555, model=None)
+            assert "model" not in result
+            assert result["name"] == "N"
+        finally:
+            printer_manager._printer_info.pop(55555, None)
+
 
 
 class TestStatusKeyDryingDedup:
 class TestStatusKeyDryingDedup:
     """Regression tests for WebSocket dedup including drying fields.
     """Regression tests for WebSocket dedup including drying fields.

+ 100 - 0
frontend/src/__tests__/components/PlatePickerModal.test.tsx

@@ -0,0 +1,100 @@
+/**
+ * Tests for PlatePickerModal.
+ *
+ * The modal lets the user pick a plate before the GCode viewer opens.
+ * Only shown for multi-plate archives with sliced gcode.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PlatePickerModal } from '../../components/PlatePickerModal';
+import type { PlateMetadata } from '../../types/plates';
+
+const makePlate = (overrides: Partial<PlateMetadata>): PlateMetadata => ({
+  index: 1,
+  name: null,
+  objects: [],
+  object_count: 0,
+  has_thumbnail: false,
+  thumbnail_url: null,
+  print_time_seconds: null,
+  filament_used_grams: null,
+  filaments: [],
+  ...overrides,
+});
+
+describe('PlatePickerModal', () => {
+  it('renders one row per plate with the plate label', () => {
+    const plates = [makePlate({ index: 1 }), makePlate({ index: 2 }), makePlate({ index: 3 })];
+    render(<PlatePickerModal plates={plates} onSelect={() => {}} onClose={() => {}} />);
+
+    // Each plate index gets its own row — check all three are present.
+    expect(screen.getByText(/plate 1/i)).toBeInTheDocument();
+    expect(screen.getByText(/plate 2/i)).toBeInTheDocument();
+    expect(screen.getByText(/plate 3/i)).toBeInTheDocument();
+  });
+
+  it('renders the plate name alongside the index when set', () => {
+    const plates = [makePlate({ index: 4, name: 'Spinner Nose' })];
+    render(<PlatePickerModal plates={plates} onSelect={() => {}} onClose={() => {}} />);
+
+    // Label combines the plate number with the user-defined name.
+    expect(screen.getByText(/spinner nose/i)).toBeInTheDocument();
+  });
+
+  it('passes the clicked plate index to onSelect', async () => {
+    const user = userEvent.setup();
+    const onSelect = vi.fn();
+    const plates = [makePlate({ index: 7 }), makePlate({ index: 12 })];
+    render(<PlatePickerModal plates={plates} onSelect={onSelect} onClose={() => {}} />);
+
+    await user.click(screen.getByText(/plate 12/i));
+
+    // The handler receives the raw plate index — that's what the URL param
+    // needs (so `?plate=12` maps to the archive's plate_12.gcode).
+    expect(onSelect).toHaveBeenCalledWith(12);
+  });
+
+  it('calls onClose when the backdrop is clicked', async () => {
+    const user = userEvent.setup();
+    const onClose = vi.fn();
+    render(<PlatePickerModal plates={[makePlate({})]} onSelect={() => {}} onClose={onClose} />);
+
+    // The outermost div is the backdrop; clicking it fires onClose.
+    // Plate rows stop propagation so they can't accidentally close the modal.
+    const backdrop = document.querySelector('[class*="fixed"]') as HTMLElement;
+    expect(backdrop).toBeTruthy();
+    await user.click(backdrop);
+
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('falls back to a layer-icon placeholder when a plate has no thumbnail', () => {
+    const plates = [makePlate({ index: 1, has_thumbnail: false, thumbnail_url: null })];
+    render(<PlatePickerModal plates={plates} onSelect={() => {}} onClose={() => {}} />);
+
+    // No <img> rendered for the thumbnail; the placeholder div takes its slot.
+    // This guards against a regression where a missing-thumbnail plate
+    // accidentally renders a broken-image icon instead of the fallback.
+    expect(screen.queryByRole('img')).not.toBeInTheDocument();
+  });
+
+  it('shows the thumbnail image when the plate has one', () => {
+    const plates = [
+      makePlate({
+        index: 1,
+        has_thumbnail: true,
+        thumbnail_url: '/api/v1/archives/42/plate-thumbnail/1',
+      }),
+    ];
+    render(<PlatePickerModal plates={plates} onSelect={() => {}} onClose={() => {}} />);
+
+    // The <img> is present and its src was transformed by withStreamToken,
+    // which appends ?token=... even on a bare placeholder — we just want the
+    // base path preserved.
+    const img = screen.getByAltText(/plate 1/i) as HTMLImageElement;
+    expect(img.src).toContain('/api/v1/archives/42/plate-thumbnail/1');
+  });
+});

+ 1 - 2
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Layers, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -36,7 +36,6 @@ export const defaultNavItems: NavItem[] = [
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
-  { id: 'gcode-viewer', to: '/gcode-viewer', icon: Layers, labelKey: 'nav.gcodeViewer' },
   // User-account features: kept adjacent to Settings intentionally
   // User-account features: kept adjacent to Settings intentionally
   { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },

+ 82 - 0
frontend/src/components/PlatePickerModal.tsx

@@ -0,0 +1,82 @@
+import { Layers, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { PlateMetadata } from '../types/plates';
+import { withStreamToken } from '../api/client';
+import { formatDuration } from '../utils/date';
+
+interface PlatePickerModalProps {
+  plates: PlateMetadata[];
+  onSelect: (plateIndex: number) => void;
+  onClose: () => void;
+}
+
+export function PlatePickerModal({ plates, onSelect, onClose }: PlatePickerModalProps) {
+  const { t } = useTranslation();
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
+          <div className="min-w-0">
+            <h3 className="text-white font-medium">{t('archives.platePicker.title')}</h3>
+            <p className="text-xs text-bambu-gray mt-1">{t('archives.platePicker.hint')}</p>
+          </div>
+          <button
+            onClick={onClose}
+            className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close', 'Close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        {/* Grid */}
+        <div className="flex-1 overflow-y-auto p-4">
+          <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
+            {plates.map((plate) => (
+              <button
+                key={plate.index}
+                type="button"
+                onClick={() => onSelect(plate.index)}
+                className="flex items-center gap-2 p-2 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray transition-colors text-left"
+              >
+                {plate.has_thumbnail && plate.thumbnail_url != null ? (
+                  <img
+                    src={withStreamToken(plate.thumbnail_url)}
+                    alt={`Plate ${plate.index}`}
+                    className="w-12 h-12 rounded object-cover bg-bambu-dark-tertiary flex-shrink-0"
+                  />
+                ) : (
+                  <div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center flex-shrink-0">
+                    <Layers className="w-5 h-5 text-bambu-gray" />
+                  </div>
+                )}
+                <div className="min-w-0 flex-1">
+                  <p className="text-sm text-white font-medium truncate">
+                    {plate.name
+                      ? `${t('archives.platePicker.plateLabel', { index: plate.index })} — ${plate.name}`
+                      : t('archives.platePicker.plateLabel', { index: plate.index })}
+                  </p>
+                  <p className="text-xs text-bambu-gray truncate">
+                    {plate.objects.length > 0
+                      ? plate.objects.slice(0, 3).join(', ') +
+                        (plate.objects.length > 3 ? '…' : '')
+                      : plate.object_count != null && plate.object_count > 0
+                      ? t('archives.platePicker.objectCount', { count: plate.object_count })
+                      : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                    {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
+                  </p>
+                </div>
+              </button>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 8 - 4
frontend/src/i18n/locales/de.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'Projekte',
     projects: 'Projekte',
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Dateimanager',
     files: 'Dateimanager',
-    gcodeViewer: 'GCode-Viewer',
     notifications: 'Benachrichtigungen',
     notifications: 'Benachrichtigungen',
     settings: 'Einstellungen',
     settings: 'Einstellungen',
     system: 'System',
     system: 'System',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     // Files
     // Files
     files: 'Dateien',
     files: 'Dateien',
-    gcodeViewer: 'GCode-Viewer',
     browseFiles: 'Druckerdateien durchsuchen',
     browseFiles: 'Druckerdateien durchsuchen',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@@ -736,6 +734,14 @@ export default {
       noDelete: 'Sie haben keine Berechtigung, dieses Archiv zu löschen',
       noDelete: 'Sie haben keine Berechtigung, dieses Archiv zu löschen',
       noCreate: 'Sie haben keine Berechtigung, Archive zu erstellen',
       noCreate: 'Sie haben keine Berechtigung, Archive zu erstellen',
     },
     },
+    platePicker: {
+      title: 'Platte zur Vorschau auswählen',
+      hint: 'Dieses Archiv enthält mehrere Platten. Wähle eine, um sie im GCode-Viewer zu öffnen.',
+      plateLabel: 'Platte {{index}}',
+      objectCount: '{{count}} Objekt',
+      objectCount_plural: '{{count}} Objekte',
+      noGcode: 'Dieses Archiv enthält keinen geschnittenen G-Code zur Vorschau. Öffne es zuerst in Bambu Studio zum Slicen.',
+    },
     card: {
     card: {
       previousPlate: 'Vorherige Platte',
       previousPlate: 'Vorherige Platte',
       nextPlate: 'Nächste Platte',
       nextPlate: 'Nächste Platte',
@@ -2893,7 +2899,6 @@ export default {
     lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
     lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
     lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
     lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
     files: 'Dateien',
     files: 'Dateien',
-    gcodeViewer: 'GCode-Viewer',
     folders: 'Ordner',
     folders: 'Ordner',
     size: 'Größe',
     size: 'Größe',
     free: 'Frei',
     free: 'Frei',
@@ -2997,7 +3002,6 @@ export default {
     createFirstButton: 'Erstes Projekt erstellen',
     createFirstButton: 'Erstes Projekt erstellen',
     create: 'Erstellen',
     create: 'Erstellen',
     files: 'Dateien',
     files: 'Dateien',
-    gcodeViewer: 'GCode-Viewer',
     prints: 'Drucke',
     prints: 'Drucke',
     plates: 'Platten',
     plates: 'Platten',
     parts: 'Teile',
     parts: 'Teile',

+ 8 - 4
frontend/src/i18n/locales/en.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'Projects',
     projects: 'Projects',
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'File Manager',
     files: 'File Manager',
-    gcodeViewer: 'GCode Viewer',
     notifications: 'Notifications',
     notifications: 'Notifications',
     settings: 'Settings',
     settings: 'Settings',
     system: 'System',
     system: 'System',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: 'Turn off chamber light',
     chamberLightOff: 'Turn off chamber light',
     // Files
     // Files
     files: 'Files',
     files: 'Files',
-    gcodeViewer: 'GCode Viewer',
     browseFiles: 'Browse printer files',
     browseFiles: 'Browse printer files',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Auto power-off after print',
     autoOffAfterPrint: 'Auto power-off after print',
@@ -736,6 +734,14 @@ export default {
       noDelete: 'You do not have permission to delete this archive',
       noDelete: 'You do not have permission to delete this archive',
       noCreate: 'You do not have permission to create archives',
       noCreate: 'You do not have permission to create archives',
     },
     },
+    platePicker: {
+      title: 'Select plate to preview',
+      hint: 'This archive has multiple plates. Pick one to open in the GCode viewer.',
+      plateLabel: 'Plate {{index}}',
+      objectCount: '{{count}} object',
+      objectCount_plural: '{{count}} objects',
+      noGcode: 'This archive has no sliced G-code to preview. Open it in Bambu Studio to slice first.',
+    },
     card: {
     card: {
       previousPlate: 'Previous plate',
       previousPlate: 'Previous plate',
       nextPlate: 'Next plate',
       nextPlate: 'Next plate',
@@ -2896,7 +2902,6 @@ export default {
     lowDiskSpaceWarning: 'Low disk space warning',
     lowDiskSpaceWarning: 'Low disk space warning',
     lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
     lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
     files: 'Files',
     files: 'Files',
-    gcodeViewer: 'GCode Viewer',
     folders: 'Folders',
     folders: 'Folders',
     size: 'Size',
     size: 'Size',
     free: 'Free',
     free: 'Free',
@@ -3000,7 +3005,6 @@ export default {
     createFirstButton: 'Create Your First Project',
     createFirstButton: 'Create Your First Project',
     create: 'Create',
     create: 'Create',
     files: 'Files',
     files: 'Files',
-    gcodeViewer: 'GCode Viewer',
     prints: 'Prints',
     prints: 'Prints',
     plates: 'plates',
     plates: 'plates',
     parts: 'parts',
     parts: 'parts',

+ 8 - 4
frontend/src/i18n/locales/fr.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'Projets',
     projects: 'Projets',
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Gestionnaire de fichiers',
     files: 'Gestionnaire de fichiers',
-    gcodeViewer: 'Visionneuse GCode',
     notifications: 'Notifications',
     notifications: 'Notifications',
     settings: 'Paramètres',
     settings: 'Paramètres',
     system: 'Système',
     system: 'Système',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: 'Éteindre la lumière de la chambre',
     chamberLightOff: 'Éteindre la lumière de la chambre',
     // Files
     // Files
     files: 'Fichiers',
     files: 'Fichiers',
-    gcodeViewer: 'Visionneuse GCode',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Extinction auto après impression',
     autoOffAfterPrint: 'Extinction auto après impression',
@@ -729,6 +727,14 @@ export default {
       noDelete: 'Pas d\'autorisation de suppression',
       noDelete: 'Pas d\'autorisation de suppression',
       noCreate: 'Pas d\'autorisation de création',
       noCreate: 'Pas d\'autorisation de création',
     },
     },
+    platePicker: {
+      title: 'Choisir une plaque à prévisualiser',
+      hint: 'Cette archive contient plusieurs plaques. Sélectionnez-en une pour l\'ouvrir dans la visionneuse GCode.',
+      plateLabel: 'Plaque {{index}}',
+      objectCount: '{{count}} objet',
+      objectCount_plural: '{{count}} objets',
+      noGcode: 'Cette archive ne contient pas de G-code généré à prévisualiser. Ouvrez-la d\'abord dans Bambu Studio pour la trancher.',
+    },
     card: {
     card: {
       previousPlate: 'Plateau précédent',
       previousPlate: 'Plateau précédent',
       nextPlate: 'Plateau suivant',
       nextPlate: 'Plateau suivant',
@@ -2815,7 +2821,6 @@ export default {
     lowDiskSpaceWarning: 'Espace disque faible',
     lowDiskSpaceWarning: 'Espace disque faible',
     lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
     lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
     files: 'Fichiers',
     files: 'Fichiers',
-    gcodeViewer: 'Visionneuse GCode',
     folders: 'Dossiers',
     folders: 'Dossiers',
     size: 'Taille',
     size: 'Taille',
     free: 'Libre',
     free: 'Libre',
@@ -2919,7 +2924,6 @@ export default {
     createFirstButton: 'Créer votre premier projet',
     createFirstButton: 'Créer votre premier projet',
     create: 'Créer',
     create: 'Créer',
     files: 'Fichiers',
     files: 'Fichiers',
-    gcodeViewer: 'Visionneuse GCode',
     prints: 'Impressions',
     prints: 'Impressions',
     plates: 'plateaux',
     plates: 'plateaux',
     parts: 'pièces',
     parts: 'pièces',

+ 8 - 4
frontend/src/i18n/locales/it.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'Progetti',
     projects: 'Progetti',
     inventory: 'Filamento',
     inventory: 'Filamento',
     files: 'File',
     files: 'File',
-    gcodeViewer: 'Visualizzatore GCode',
     notifications: 'Notifiche',
     notifications: 'Notifiche',
     settings: 'Impostazioni',
     settings: 'Impostazioni',
     system: 'Sistema',
     system: 'Sistema',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: 'Spegni luce camera',
     chamberLightOff: 'Spegni luce camera',
     // Files
     // Files
     files: 'File',
     files: 'File',
-    gcodeViewer: 'Visualizzatore GCode',
     browseFiles: 'Sfoglia file stampante',
     browseFiles: 'Sfoglia file stampante',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@@ -729,6 +727,14 @@ export default {
       noDelete: 'Non hai il permesso di eliminare questo archivio',
       noDelete: 'Non hai il permesso di eliminare questo archivio',
       noCreate: 'Non hai il permesso di creare archivi',
       noCreate: 'Non hai il permesso di creare archivi',
     },
     },
+    platePicker: {
+      title: 'Seleziona la piastra da visualizzare',
+      hint: 'Questo archivio contiene più piastre. Scegline una da aprire nel visualizzatore GCode.',
+      plateLabel: 'Piastra {{index}}',
+      objectCount: '{{count}} oggetto',
+      objectCount_plural: '{{count}} oggetti',
+      noGcode: 'Questo archivio non contiene G-code generato da visualizzare. Aprilo prima in Bambu Studio per sezionarlo.',
+    },
     card: {
     card: {
       previousPlate: 'Piatto precedente',
       previousPlate: 'Piatto precedente',
       nextPlate: 'Piatto successivo',
       nextPlate: 'Piatto successivo',
@@ -2814,7 +2820,6 @@ export default {
     lowDiskSpaceWarning: 'Avviso spazio disco basso',
     lowDiskSpaceWarning: 'Avviso spazio disco basso',
     lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
     lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
     files: 'File',
     files: 'File',
-    gcodeViewer: 'Visualizzatore GCode',
     folders: 'Cartelle',
     folders: 'Cartelle',
     size: 'Dimensione',
     size: 'Dimensione',
     free: 'Libero',
     free: 'Libero',
@@ -2918,7 +2923,6 @@ export default {
     createFirstButton: 'Crea il tuo primo progetto',
     createFirstButton: 'Crea il tuo primo progetto',
     create: 'Crea',
     create: 'Crea',
     files: 'File',
     files: 'File',
-    gcodeViewer: 'Visualizzatore GCode',
     prints: 'Stampe',
     prints: 'Stampe',
     plates: 'piatti',
     plates: 'piatti',
     parts: 'parti',
     parts: 'parti',

+ 8 - 4
frontend/src/i18n/locales/ja.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'プロジェクト',
     projects: 'プロジェクト',
     inventory: 'フィラメント',
     inventory: 'フィラメント',
     files: 'ファイル管理',
     files: 'ファイル管理',
-    gcodeViewer: 'GCodeビューア',
     notifications: '通知',
     notifications: '通知',
     settings: '設定',
     settings: '設定',
     system: 'システム',
     system: 'システム',
@@ -213,7 +212,6 @@ export default {
     chamberLightOff: 'チャンバーライトをオフにしました',
     chamberLightOff: 'チャンバーライトをオフにしました',
     // Files
     // Files
     files: 'ファイル',
     files: 'ファイル',
-    gcodeViewer: 'GCodeビューア',
     browseFiles: 'プリンターのファイルを参照',
     browseFiles: 'プリンターのファイルを参照',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: '印刷後に自動電源オフ',
     autoOffAfterPrint: '印刷後に自動電源オフ',
@@ -728,6 +726,14 @@ export default {
       noDelete: 'このアーカイブを削除する権限がありません',
       noDelete: 'このアーカイブを削除する権限がありません',
       noCreate: 'アーカイブを作成する権限がありません',
       noCreate: 'アーカイブを作成する権限がありません',
     },
     },
+    platePicker: {
+      title: 'プレビューするプレートを選択',
+      hint: 'このアーカイブには複数のプレートがあります。GCodeビューアで開くプレートを選択してください。',
+      plateLabel: 'プレート {{index}}',
+      objectCount: '{{count}} オブジェクト',
+      objectCount_plural: '{{count}} オブジェクト',
+      noGcode: 'このアーカイブにはプレビュー可能なスライス済みGコードがありません。まずBambu Studioで開いてスライスしてください。',
+    },
     card: {
     card: {
       previousPlate: '前のプレート',
       previousPlate: '前のプレート',
       nextPlate: '次のプレート',
       nextPlate: '次のプレート',
@@ -2853,7 +2859,6 @@ export default {
     lowDiskSpaceWarning: 'ディスク容量不足の警告',
     lowDiskSpaceWarning: 'ディスク容量不足の警告',
     lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
     lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
     files: 'ファイル',
     files: 'ファイル',
-    gcodeViewer: 'GCodeビューア',
     folders: 'フォルダ',
     folders: 'フォルダ',
     size: 'サイズ',
     size: 'サイズ',
     free: '空き:',
     free: '空き:',
@@ -2957,7 +2962,6 @@ export default {
     createFirstButton: '最初のプロジェクトを作成',
     createFirstButton: '最初のプロジェクトを作成',
     create: '作成',
     create: '作成',
     files: 'ファイル',
     files: 'ファイル',
-    gcodeViewer: 'GCodeビューア',
     prints: '印刷',
     prints: '印刷',
     plates: 'プレート',
     plates: 'プレート',
     parts: 'パーツ',
     parts: 'パーツ',

+ 8 - 4
frontend/src/i18n/locales/pt-BR.ts

@@ -10,7 +10,6 @@ export default {
     projects: 'Projetos',
     projects: 'Projetos',
     inventory: 'Inventário',
     inventory: 'Inventário',
     files: 'Gerenciador de Arquivos',
     files: 'Gerenciador de Arquivos',
-    gcodeViewer: 'Visualizador GCode',
     notifications: 'Notificações',
     notifications: 'Notificações',
     settings: 'Configurações',
     settings: 'Configurações',
     system: 'Sistema',
     system: 'Sistema',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: 'Desligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',
     // Files
     // Files
     files: 'Arquivos',
     files: 'Arquivos',
-    gcodeViewer: 'Visualizador GCode',
     browseFiles: 'Procurar arquivos da impressora',
     browseFiles: 'Procurar arquivos da impressora',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Desligamento automático após impressão',
     autoOffAfterPrint: 'Desligamento automático após impressão',
@@ -729,6 +727,14 @@ export default {
       noDelete: 'Você não tem permissão para excluir este arquivo',
       noDelete: 'Você não tem permissão para excluir este arquivo',
       noCreate: 'Você não tem permissão para criar arquivos',
       noCreate: 'Você não tem permissão para criar arquivos',
     },
     },
+    platePicker: {
+      title: 'Selecione a placa para visualizar',
+      hint: 'Este arquivo contém várias placas. Escolha uma para abrir no visualizador GCode.',
+      plateLabel: 'Placa {{index}}',
+      objectCount: '{{count}} objeto',
+      objectCount_plural: '{{count}} objetos',
+      noGcode: 'Este arquivo não contém G-code fatiado para visualizar. Abra-o no Bambu Studio para fatiar primeiro.',
+    },
     card: {
     card: {
       previousPlate: 'Placa anterior',
       previousPlate: 'Placa anterior',
       nextPlate: 'Próxima placa',
       nextPlate: 'Próxima placa',
@@ -2828,7 +2834,6 @@ export default {
     lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
     lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
     lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
     lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
     files: 'Arquivos',
     files: 'Arquivos',
-    gcodeViewer: 'Visualizador GCode',
     folders: 'Pastas',
     folders: 'Pastas',
     size: 'Tamanho',
     size: 'Tamanho',
     free: 'Livre',
     free: 'Livre',
@@ -2932,7 +2937,6 @@ export default {
     createFirstButton: 'Crie Seu Primeiro Projeto',
     createFirstButton: 'Crie Seu Primeiro Projeto',
     create: 'Criar',
     create: 'Criar',
     files: 'Arquivos',
     files: 'Arquivos',
-    gcodeViewer: 'Visualizador GCode',
     prints: 'Impressões',
     prints: 'Impressões',
     plates: 'Placas',
     plates: 'Placas',
     parts: 'Peças',
     parts: 'Peças',

+ 8 - 4
frontend/src/i18n/locales/zh-CN.ts

@@ -10,7 +10,6 @@ export default {
     projects: '项目',
     projects: '项目',
     inventory: '耗材',
     inventory: '耗材',
     files: '文件管理器',
     files: '文件管理器',
-    gcodeViewer: 'GCode查看器',
     notifications: '通知',
     notifications: '通知',
     settings: '设置',
     settings: '设置',
     system: '系统',
     system: '系统',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: '关闭腔室灯',
     chamberLightOff: '关闭腔室灯',
     // Files
     // Files
     files: '文件',
     files: '文件',
-    gcodeViewer: 'GCode查看器',
     browseFiles: '浏览打印机文件',
     browseFiles: '浏览打印机文件',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: '打印后自动关机',
     autoOffAfterPrint: '打印后自动关机',
@@ -736,6 +734,14 @@ export default {
       noDelete: '您没有删除此归档的权限',
       noDelete: '您没有删除此归档的权限',
       noCreate: '您没有创建归档的权限',
       noCreate: '您没有创建归档的权限',
     },
     },
+    platePicker: {
+      title: '选择要预览的打印板',
+      hint: '此存档包含多个打印板。选择一个在 GCode 查看器中打开。',
+      plateLabel: '打印板 {{index}}',
+      objectCount: '{{count}} 个对象',
+      objectCount_plural: '{{count}} 个对象',
+      noGcode: '此存档没有可预览的已切片 G 代码。请先在 Bambu Studio 中打开并切片。',
+    },
     card: {
     card: {
       previousPlate: '上一个板',
       previousPlate: '上一个板',
       nextPlate: '下一个板',
       nextPlate: '下一个板',
@@ -2880,7 +2886,6 @@ export default {
     lowDiskSpaceWarning: '磁盘空间不足警告',
     lowDiskSpaceWarning: '磁盘空间不足警告',
     lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
     lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
     files: '文件',
     files: '文件',
-    gcodeViewer: 'GCode查看器',
     folders: '文件夹',
     folders: '文件夹',
     size: '大小',
     size: '大小',
     free: '剩余',
     free: '剩余',
@@ -2984,7 +2989,6 @@ export default {
     createFirstButton: '创建您的第一个项目',
     createFirstButton: '创建您的第一个项目',
     create: '创建',
     create: '创建',
     files: '文件',
     files: '文件',
-    gcodeViewer: 'GCode查看器',
     prints: '打印',
     prints: '打印',
     plates: '板',
     plates: '板',
     parts: '零件',
     parts: '零件',

+ 8 - 4
frontend/src/i18n/locales/zh-TW.ts

@@ -10,7 +10,6 @@ export default {
     projects: '專案',
     projects: '專案',
     inventory: '耗材',
     inventory: '耗材',
     files: '檔案管理器',
     files: '檔案管理器',
-    gcodeViewer: 'GCode 檢視器',
     notifications: '通知',
     notifications: '通知',
     settings: '設定',
     settings: '設定',
     system: '系統',
     system: '系統',
@@ -214,7 +213,6 @@ export default {
     chamberLightOff: '關閉腔室燈',
     chamberLightOff: '關閉腔室燈',
     // Files
     // Files
     files: '檔案',
     files: '檔案',
-    gcodeViewer: 'GCode 檢視器',
     browseFiles: '瀏覽印表機檔案',
     browseFiles: '瀏覽印表機檔案',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: '列印後自動關機',
     autoOffAfterPrint: '列印後自動關機',
@@ -736,6 +734,14 @@ export default {
       noDelete: '您沒有刪除此歸檔的權限',
       noDelete: '您沒有刪除此歸檔的權限',
       noCreate: '您沒有建立歸檔的權限',
       noCreate: '您沒有建立歸檔的權限',
     },
     },
+    platePicker: {
+      title: '選擇要預覽的列印板',
+      hint: '此存檔包含多個列印板。選擇一個在 GCode 檢視器中開啟。',
+      plateLabel: '列印板 {{index}}',
+      objectCount: '{{count}} 個物件',
+      objectCount_plural: '{{count}} 個物件',
+      noGcode: '此存檔沒有可預覽的已切片 G 代碼。請先在 Bambu Studio 中開啟並切片。',
+    },
     card: {
     card: {
       previousPlate: '上一個板',
       previousPlate: '上一個板',
       nextPlate: '下一個板',
       nextPlate: '下一個板',
@@ -2880,7 +2886,6 @@ export default {
     lowDiskSpaceWarning: '磁碟空間不足警告',
     lowDiskSpaceWarning: '磁碟空間不足警告',
     lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
     lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
     files: '檔案',
     files: '檔案',
-    gcodeViewer: 'GCode 檢視器',
     folders: '資料夾',
     folders: '資料夾',
     size: '大小',
     size: '大小',
     free: '剩餘',
     free: '剩餘',
@@ -2984,7 +2989,6 @@ export default {
     createFirstButton: '建立您的第一個項目',
     createFirstButton: '建立您的第一個項目',
     create: '建立',
     create: '建立',
     files: '檔案',
     files: '檔案',
-    gcodeViewer: 'GCode 檢視器',
     prints: '列印',
     prints: '列印',
     plates: '板',
     plates: '板',
     parts: '零件',
     parts: '零件',

+ 66 - 30
frontend/src/pages/ArchivesPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, useCallback } from 'react';
 import { useState, useRef, useEffect, useCallback } from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
@@ -62,7 +62,6 @@ import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
-import { ModelViewerModal } from '../components/ModelViewerModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { UploadModal } from '../components/UploadModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -78,6 +77,8 @@ import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { TagManagementModal } from '../components/TagManagementModal';
+import { PlatePickerModal } from '../components/PlatePickerModal';
+import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
@@ -101,15 +102,6 @@ function isSlicedFile(archive: { filename?: string | null; total_layers?: number
   return false;
   return false;
 }
 }
 
 
-function getArchiveFileType(filename: string | null | undefined): string | undefined {
-  if (!filename) return undefined;
-  const lower = filename.toLowerCase();
-  if (lower.endsWith('.3mf')) return '3mf';
-  if (lower.endsWith('.stl')) return 'stl';
-  if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
-  return lower.split('.').pop();
-}
-
 // formatDate imported from '../utils/date' - handles UTC conversion
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
 /**
 /**
@@ -178,7 +170,7 @@ function ArchiveCard({
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
-  const [showViewer, setShowViewer] = useState(false);
+  const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
@@ -195,6 +187,7 @@ function ArchiveCard({
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
   const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
   const [showPlateNav, setShowPlateNav] = useState(false);
   const [showPlateNav, setShowPlateNav] = useState(false);
+  const [platePickerPlates, setPlatePickerPlates] = useState<PlateMetadata[] | null>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const timelapseInputRef = useRef<HTMLInputElement>(null);
   const timelapseInputRef = useRef<HTMLInputElement>(null);
@@ -215,6 +208,29 @@ function ArchiveCard({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const displayPlateIndex = currentPlateIndex ?? 0;
   const displayPlateIndex = currentPlateIndex ?? 0;
 
 
+  // 3D Preview click handler. Multi-plate archives show the plate picker
+  // first; single-plate archives navigate straight into the viewer. Source-
+  // only archives (no sliced gcode, e.g. pure project 3MFs from BambuStudio
+  // that carry only plate PNG/JSON metadata) get a toast — there's nothing
+  // the gcode viewer can render for them.
+  const openGcodeViewer = async () => {
+    try {
+      const resp = await api.getArchivePlates(archive.id);
+      if (resp.has_gcode === false) {
+        showToast(t('archives.platePicker.noGcode'), 'info');
+        return;
+      }
+      if (resp.is_multi_plate && resp.plates.length > 1) {
+        setPlatePickerPlates(resp.plates);
+        return;
+      }
+    } catch {
+      // Swallow — fall through to the no-plate navigate below so the viewer
+      // still opens on the first plate (the backend's default).
+    }
+    navigate(`/gcode-viewer?archive=${archive.id}`);
+  };
+
   const timelapseDeleteMutation = useMutation({
   const timelapseDeleteMutation = useMutation({
     mutationFn: () => api.deleteArchiveTimelapse(archive.id),
     mutationFn: () => api.deleteArchiveTimelapse(archive.id),
     onSuccess: () => {
     onSuccess: () => {
@@ -410,7 +426,7 @@ function ArchiveCard({
     {
     {
       label: t('archives.menu.preview3d'),
       label: t('archives.menu.preview3d'),
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
-      onClick: () => setShowViewer(true),
+      onClick: () => { openGcodeViewer(); },
     },
     },
     {
     {
       label: t('archives.menu.viewTimelapse'),
       label: t('archives.menu.viewTimelapse'),
@@ -822,7 +838,7 @@ function ArchiveCard({
           className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
           className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
-            setShowViewer(true);
+            openGcodeViewer();
           }}
           }}
           title={t('archives.card.preview3d')}
           title={t('archives.card.preview3d')}
         >
         >
@@ -1167,13 +1183,15 @@ function ArchiveCard({
         />
         />
       )}
       )}
 
 
-      {/* 3D Viewer Modal */}
-      {showViewer && (
-        <ModelViewerModal
-          archiveId={archive.id}
-          title={archive.print_name || archive.filename}
-          fileType={getArchiveFileType(archive.filename)}
-          onClose={() => setShowViewer(false)}
+      {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
+      {platePickerPlates && (
+        <PlatePickerModal
+          plates={platePickerPlates}
+          onSelect={(plateIndex) => {
+            setPlatePickerPlates(null);
+            navigate(`/gcode-viewer?archive=${archive.id}&plate=${plateIndex}`);
+          }}
+          onClose={() => setPlatePickerPlates(null)}
         />
         />
       )}
       )}
 
 
@@ -1448,9 +1466,9 @@ function ArchiveListRow({
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
-  const [showViewer, setShowViewer] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
@@ -1464,11 +1482,27 @@ function ArchiveListRow({
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const timelapseInputRef = useRef<HTMLInputElement>(null);
   const timelapseInputRef = useRef<HTMLInputElement>(null);
+  const [platePickerPlates, setPlatePickerPlates] = useState<PlateMetadata[] | null>(null);
 
 
   // Use pre-computed duplicate sequence and original archive ID from list response
   // Use pre-computed duplicate sequence and original archive ID from list response
   const duplicateSequence = archive.duplicate_sequence ?? 0;
   const duplicateSequence = archive.duplicate_sequence ?? 0;
   const originalArchiveId = archive.original_archive_id ?? null;
   const originalArchiveId = archive.original_archive_id ?? null;
 
 
+  // 3D Preview click handler. Multi-plate archives show the plate picker
+  // first; single-plate archives navigate straight into the viewer.
+  const openGcodeViewer = async () => {
+    try {
+      const resp = await api.getArchivePlates(archive.id);
+      if (resp.is_multi_plate && resp.plates.length > 1) {
+        setPlatePickerPlates(resp.plates);
+        return;
+      }
+    } catch {
+      // Swallow — fall through to navigate on the first-plate default.
+    }
+    navigate(`/gcode-viewer?archive=${archive.id}`);
+  };
+
   const timelapseDeleteMutation = useMutation({
   const timelapseDeleteMutation = useMutation({
     mutationFn: () => api.deleteArchiveTimelapse(archive.id),
     mutationFn: () => api.deleteArchiveTimelapse(archive.id),
     onSuccess: () => {
     onSuccess: () => {
@@ -1661,7 +1695,7 @@ function ArchiveListRow({
     {
     {
       label: t('archives.menu.preview3d'),
       label: t('archives.menu.preview3d'),
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
-      onClick: () => setShowViewer(true),
+      onClick: () => { openGcodeViewer(); },
     },
     },
     {
     {
       label: t('archives.menu.viewTimelapse'),
       label: t('archives.menu.viewTimelapse'),
@@ -2075,13 +2109,15 @@ function ArchiveListRow({
         />
         />
       )}
       )}
 
 
-      {/* 3D Viewer Modal */}
-      {showViewer && (
-        <ModelViewerModal
-          archiveId={archive.id}
-          title={archive.print_name || archive.filename}
-          fileType={getArchiveFileType(archive.filename)}
-          onClose={() => setShowViewer(false)}
+      {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
+      {platePickerPlates && (
+        <PlatePickerModal
+          plates={platePickerPlates}
+          onSelect={(plateIndex) => {
+            setPlatePickerPlates(null);
+            navigate(`/gcode-viewer?archive=${archive.id}&plate=${plateIndex}`);
+          }}
+          onClose={() => setPlatePickerPlates(null)}
         />
         />
       )}
       )}
 
 

+ 8 - 1
frontend/src/pages/GCodeViewerPage.tsx

@@ -11,11 +11,18 @@ export function GCodeViewerPage() {
     );
     );
   }
   }
 
 
+  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
+  // the adapter inside can pick up the archive to load. The iframe itself must
+  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
+  // the outer SPA URL uses no trailing slash so a reload falls through to the
+  // SPA catch-all and keeps the Bambuddy layout shell.
+  const iframeSrc = `/gcode-viewer/${window.location.search}`;
+
   return (
   return (
     // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
     // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
     // Subtracting it prevents a double scrollbar inside the layout shell.
     // Subtracting it prevents a double scrollbar inside the layout shell.
     <iframe
     <iframe
-      src="/gcode-viewer/"
+      src={iframeSrc}
       title="GCode Viewer"
       title="GCode Viewer"
       style={{
       style={{
         display: 'block',
         display: 'block',

+ 1 - 1
frontend/src/pages/NotificationsPage.tsx

@@ -34,7 +34,7 @@ export function NotificationsPage() {
     queryFn: api.getSettings,
     queryFn: api.getSettings,
     staleTime: 5 * 60 * 1000,
     staleTime: 5 * 60 * 1000,
   });
   });
-  
+
   // Fetch current preferences
   // Fetch current preferences
   const { data: preferences, isLoading } = useQuery({
   const { data: preferences, isLoading } = useQuery({
     queryKey: ['user-email-preferences'],
     queryKey: ['user-email-preferences'],

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

@@ -23,6 +23,7 @@ export interface ArchivePlatesResponse {
   filename: string;
   filename: string;
   plates: PlateMetadata[];
   plates: PlateMetadata[];
   is_multi_plate: boolean;
   is_multi_plate: boolean;
+  has_gcode?: boolean;
 }
 }
 
 
 export interface LibraryFilePlatesResponse {
 export interface LibraryFilePlatesResponse {

+ 0 - 32
gcode_viewer/index.html

@@ -33,18 +33,6 @@
       flex-shrink: 0;
       flex-shrink: 0;
       flex-wrap: wrap;
       flex-wrap: wrap;
     }
     }
-    #bb-toolbar label { color: #aaa; font-size: 12px; white-space: nowrap; }
-    #bb-printer-select {
-      background: #222; color: #ddd;
-      border: 1px solid #444; border-radius: 4px;
-      padding: 3px 6px; font-size: 12px;
-    }
-    #bb-file-btn {
-      background: #2a6496; color: #fff;
-      border: none; border-radius: 4px;
-      padding: 4px 10px; cursor: pointer; font-size: 12px;
-    }
-    #bb-file-btn:hover { background: #1c4d72; }
     #bb-current-file {
     #bb-current-file {
       color: #9ecfff; font-size: 12px;
       color: #9ecfff; font-size: 12px;
       max-width: 300px; overflow: hidden;
       max-width: 300px; overflow: hidden;
@@ -73,19 +61,6 @@
       padding: 3px 5px; font-size: 12px;
       padding: 3px 5px; font-size: 12px;
     }
     }
 
 
-    /* File picker dropdown */
-    #bb-file-picker {
-      display: none;
-      position: fixed;   /* fixed so it's never clipped by viewer overflow */
-      top: 38px; left: 10px;
-      width: 320px;
-      background: #222; border: 1px solid #444;
-      border-radius: 6px; padding: 6px;
-      z-index: 9999;
-      box-shadow: 0 4px 16px rgba(0,0,0,0.6);
-    }
-    #bb-file-picker.bb-open { display: block; }
-
     /* Main layout */
     /* Main layout */
     #bb-layout {
     #bb-layout {
       display: flex;
       display: flex;
@@ -194,10 +169,6 @@
 
 
   <!-- Toolbar -->
   <!-- Toolbar -->
   <div id="bb-toolbar">
   <div id="bb-toolbar">
-    <label for="bb-printer-select">Printer:</label>
-    <select id="bb-printer-select"><option value="">Loading…</option></select>
-
-    <button id="bb-file-btn">&#128196; Load file</button>
     <span id="bb-current-file">— no file loaded —</span>
     <span id="bb-current-file">— no file loaded —</span>
 
 
     <button id="bb-play-btn" title="Play layer animation" disabled>&#9654;</button>
     <button id="bb-play-btn" title="Play layer animation" disabled>&#9654;</button>
@@ -210,9 +181,6 @@
 
 
   </div>
   </div>
 
 
-  <!-- File picker (positioned relative to toolbar) -->
-  <div id="bb-file-picker"></div>
-
   <!-- Viewer area -->
   <!-- Viewer area -->
   <div id="bb-viewer-wrap">
   <div id="bb-viewer-wrap">
     <div class="page-container">
     <div class="page-container">

+ 81 - 373
gcode_viewer/js/bambuddy_adapter.js

@@ -114,15 +114,8 @@
     };
     };
 
 
     // Bed sizes for common Bambu models (mm)
     // Bed sizes for common Bambu models (mm)
-    var BAMBU_BED_SIZES = {
-        'X1':       { width: 256, depth: 256, height: 256 },
-        'X1C':      { width: 256, depth: 256, height: 256 },
-        'X1E':      { width: 256, depth: 256, height: 256 },
-        'P1S':      { width: 256, depth: 256, height: 256 },
-        'P1P':      { width: 256, depth: 256, height: 256 },
-        'A1':       { width: 300, depth: 300, height: 300 },
-        'A1 Mini':  { width: 180, depth: 180, height: 180 },
-    };
+    // Fallback bed size used until loadArchiveById() fetches the archive's
+    // actual build_volume from /api/v1/archives/{id}/capabilities.
     var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
     var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
 
 
     var currentBed = Object.assign({}, DEFAULT_BED);
     var currentBed = Object.assign({}, DEFAULT_BED);
@@ -177,6 +170,16 @@
                 /^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
                 /^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
                 API_BASE + '/library/files/$1/download'
                 API_BASE + '/library/files/$1/download'
             );
             );
+            // OctoPrint file download  →  Bambuddy archive gcode (specific plate)
+            newPath = newPath.replace(
+                /^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)_plate(\d+)$/,
+                API_BASE + '/archives/$1/gcode?plate=$2'
+            );
+            // OctoPrint file download  →  Bambuddy archive gcode (first plate)
+            newPath = newPath.replace(
+                /^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)$/,
+                API_BASE + '/archives/$1/gcode'
+            );
             // OctoPrint plugin static assets  →  gcode-viewer static files
             // OctoPrint plugin static assets  →  gcode-viewer static files
             newPath = newPath.replace(
             newPath = newPath.replace(
                 /^\/?plugin\/prettygcode\/static\//,
                 /^\/?plugin\/prettygcode\/static\//,
@@ -199,7 +202,7 @@
         var promise = _originalFetch(resource, init);
         var promise = _originalFetch(resource, init);
 
 
         // Tee GCode downloads to build the layer map for sync + nozzle animation
         // Tee GCode downloads to build the layer map for sync + nozzle animation
-        if (url && url.match(/\/library\/files\/\d+\/download/)) {
+        if (url && (url.match(/\/library\/files\/\d+\/download/) || url.match(/\/archives\/\d+\/gcode/))) {
             promise = promise.then(function (response) {
             promise = promise.then(function (response) {
                 var clone = response.clone();
                 var clone = response.clone();
                 clone.text().then(function (text) {
                 clone.text().then(function (text) {
@@ -333,215 +336,40 @@
     var currentFileId = null;
     var currentFileId = null;
     var currentFilename = null;
     var currentFilename = null;
     var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
     var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
-    var ws = null;
-    var wsReconnectTimer = null;
-    var printers = [];            // [{id, name, model, state, progress, subtask_name}]
-    var selectedPrinterId = null;
     var gcodeLayerMap = null;     // parsed layer data: {layerOffsets, layerCmds, totalBytes}
     var gcodeLayerMap = null;     // parsed layer data: {layerOffsets, layerCmds, totalBytes}
     var lastFedLayer = -1;        // last layer_num whose commands we fed to printHeadSim
     var lastFedLayer = -1;        // last layer_num whose commands we fed to printHeadSim
 
 
-    // -------------------------------------------------------------------------
-    // 9. Bambuddy WebSocket
-    // -------------------------------------------------------------------------
-    function connectWebSocket() {
-        var token = localStorage.getItem('auth_token');
-        var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
-        // Do NOT put the token in the URL — it would appear in server logs.
-        // The WebSocket endpoint is currently unauthenticated server-side;
-        // all sensitive calls go through authenticated fetch() instead.
-        var wsUrl = proto + '//' + location.host + API_BASE + '/ws';
-
-        ws = new WebSocket(wsUrl);
-
-        ws.onopen = function () {
-            console.log('[PrettyGCode] Connected to Bambuddy WebSocket');
-        };
-
-        ws.onmessage = function (event) {
-            try {
-                var msg = JSON.parse(event.data);
-                if (msg.type === 'printer_status') {
-                    handlePrinterStatus(msg.printer_id, msg.data);
-                }
-            } catch (e) {}
-        };
-
-        ws.onclose = function () {
-            clearTimeout(wsReconnectTimer);
-            wsReconnectTimer = setTimeout(connectWebSocket, 3000);
-        };
-
-        ws.onerror = function () {
-            ws.close();
-        };
-    }
-
-    function bambuStateToOctoState(bambuState) {
-        var map = {
-            RUNNING:  'Printing',
-            PAUSE:    'Paused',
-            FAILED:   'Error',
-            FINISH:   'Operational',
-            IDLE:     'Operational',
-        };
-        return map[bambuState] || 'Operational';
-    }
-
-    function handlePrinterStatus(printerId, data) {
-        // Update printer list entry
-        var found = false;
-        for (var i = 0; i < printers.length; i++) {
-            if (printers[i].id === printerId) {
-                // Allowlist to prevent prototype pollution from crafted WS messages
-                var allowed2 = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
-                allowed2.forEach(function (k) { if (k in data) printers[i][k] = data[k]; });
-                found = true;
-                break;
-            }
-        }
-        if (!found) {
-            // Only copy known, safe keys — avoids prototype pollution from a crafted WS message
-            var allowed = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
-            var entry = { id: printerId };
-            allowed.forEach(function (k) { if (k in data) entry[k] = data[k]; });
-            printers.push(entry);
-        }
-
-        updatePrinterSelector();
-
-        // Only feed data for the selected printer
-        if (selectedPrinterId !== null && printerId !== selectedPrinterId) return;
-        if (selectedPrinterId === null && printers.length > 0) {
-            selectedPrinterId = printers[0].id;
-        }
+    // The viewer is scoped to previewing a specific archive (/gcode-viewer?archive=<id>).
+    // It no longer observes live printer state, so the WebSocket connection, the
+    // printer selector, auto-load-currently-printing, and library file picker are all
+    // intentionally absent. Bed size is derived from the archive's sliced_for_model.
 
 
-        if (!viewModel) return;
-
-        var printer = null;
-        for (var j = 0; j < printers.length; j++) {
-            if (printers[j].id === printerId) { printer = printers[j]; break; }
-        }
-        if (!printer) return;
-
-        // Update bed size from printer model
-        var bedKey = (printer.model || '').toUpperCase();
-        for (var modelName in BAMBU_BED_SIZES) {
-            if (bedKey.indexOf(modelName.toUpperCase()) !== -1) {
-                currentBed = BAMBU_BED_SIZES[modelName];
-                break;
-            }
-        }
-        // Replace the entire profile data so the subscribe() fires
-        fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
-
-        // Auto-load currently printing file if it changed
-        var subtask = printer.subtask_name || printer.gcode_file || '';
-        if (subtask && subtask !== currentFilename) {
-            currentFilename = subtask;
-            tryAutoLoadPrintingFile(subtask);
-        }
-
-        // Update webcam URL
-        if (printer.camera_url) {
-            fakeSettings.webcam.streamUrl(printer.camera_url);
-        }
-
-        feedCurrentData(printer);
-    }
-
-    function feedCurrentData(printer) {
-        if (!viewModel || !viewModel.fromCurrentData) return;
-        var octoState = bambuStateToOctoState(printer.state || 'IDLE');
-        var isPrinting = octoState === 'Printing' || octoState === 'Paused';
-
-        // --- Layer sync via filepos -------------------------------------------
-        // prettygcode.js calls gcodeProxy.syncGcodeObjToFilePos(curPrintFilePos) each
-        // animation frame when printing + syncToProgress is on.  Pass the byte offset
-        // of the current layer so the highlight advances correctly.
-        var filepos = null;
-        var logs = [];
-
-        if (gcodeLayerMap && isPrinting) {
-            // Bambu layer_num is 1-based; our layerOffsets array is 0-based.
-            var layerIdx = Math.max(0, (printer.layer_num || 1) - 1);
-            layerIdx = Math.min(layerIdx, gcodeLayerMap.layerOffsets.length - 1);
-            filepos = gcodeLayerMap.layerOffsets[layerIdx] || 0;
-
-            // --- Nozzle animation via synthetic Send: commands -------------------
-            // PrintHeadSimulator.addCommand() expects "Send: G1 X... Y... Z..." entries.
-            // Feed the movement commands for the current layer once per layer change.
-            // The simulator interpolates them over real time, animating the nozzle model.
-            if (layerIdx !== lastFedLayer && gcodeLayerMap.layerCmds[layerIdx]) {
-                lastFedLayer = layerIdx;
-                var cmds = gcodeLayerMap.layerCmds[layerIdx];
-                // PrintHeadSimulator buffer is capped at 1000; feed at most 400 commands
-                // so there's room for the sim to drain before more arrive.
-                logs = cmds.slice(0, 400).map(function (c) { return 'Send:' + c; });
-            }
-        }
-
-        viewModel.fromCurrentData({
-            job: {
-                file: {
-                    path: currentFileId ? ('__bambuddy_file_' + currentFileId) : null,
-                    date: currentFileDate,
-                },
-                estimatedPrintTime: null,
-            },
-            state: {
-                text: octoState,
-                flags: { printing: octoState === 'Printing', paused: octoState === 'Paused' },
-            },
-            progress: {
-                filepos: filepos,
-                completion: (printer.progress || 0) / 100,
-                printTime: null,
-            },
-            currentZ: null,
-            logs: logs,
-        });
-    }
-
-    // -------------------------------------------------------------------------
-    // 8. Auto-load file when printer starts printing
-    // -------------------------------------------------------------------------
-    function tryAutoLoadPrintingFile(filename) {
-        // Search the library for a matching .gcode file
-        apiFetch('/library/files?sort_by=updated_at&sort_dir=desc', {})
-            .then(function (r) { return r.json(); })
-            .then(function (files) {
-                if (!Array.isArray(files)) return;
-                var match = files.find(function (f) {
-                    return f.filename === filename ||
-                           f.filename === filename + '.gcode' ||
-                           f.filename.replace(/\.gcode$/, '') === filename.replace(/\.gcode$/, '');
-                });
-                if (match) loadFileById(match.id, match.filename, match.file_size);
-            })
-            .catch(function () {});
+    function updateFilenameDisplay(filename) {
+        var el = document.getElementById('bb-current-file');
+        if (el) el.textContent = filename || '— no file loaded —';
     }
     }
 
 
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
-    // 9. File loading
+    // 10. Archive loader — invoked via /gcode-viewer/?archive=<id>
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
-    function loadFileById(fileId, filename, fileSize) {
-        currentFileId = fileId;
-        currentFilename = filename;
-        currentFileDate = Date.now(); // new stable date so prettygcode loads exactly once
-        gcodeLayerMap = null;   // cleared here; re-populated when fetch() intercept fires
+    function loadArchiveById(archiveId, plate) {
+        // Pretygcode downloads /downloads/files/local/__bambuddy_archive_<id>(_plate<N>)
+        // and the fetch intercept rewrites it to /api/v1/archives/<id>/gcode[?plate=N].
+        var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
+        currentFileId = 'archive_' + archiveId + plateSuffix;
+        currentFilename = 'Archive #' + archiveId + (plateSuffix ? (' (plate ' + plate + ')') : '');
+        currentFileDate = Date.now();
+        gcodeLayerMap = null;
         lastFedLayer = -1;
         lastFedLayer = -1;
         stopPlayback(true);
         stopPlayback(true);
-        updateFilenameDisplay(filename);
-        // Enable play button once a file is loaded
+        updateFilenameDisplay(currentFilename);
         var playBtn = document.getElementById('bb-play-btn');
         var playBtn = document.getElementById('bb-play-btn');
         if (playBtn) playBtn.disabled = false;
         if (playBtn) playBtn.disabled = false;
-        // Trigger prettygcode.js's updateJob — date must match currentFileDate exactly
-        // so subsequent feedCurrentData calls don't re-trigger the download
         if (viewModel && viewModel.fromCurrentData) {
         if (viewModel && viewModel.fromCurrentData) {
             viewModel.fromCurrentData({
             viewModel.fromCurrentData({
                 job: {
                 job: {
                     file: {
                     file: {
-                        path: '__bambuddy_file_' + fileId,
+                        path: '__bambuddy_archive_' + archiveId + plateSuffix,
                         date: currentFileDate,
                         date: currentFileDate,
                     },
                     },
                     estimatedPrintTime: null,
                     estimatedPrintTime: null,
@@ -552,107 +380,39 @@
                 logs: [],
                 logs: [],
             });
             });
         }
         }
-    }
-
-    function updateFilenameDisplay(filename) {
-        var el = document.getElementById('bb-current-file');
-        if (el) el.textContent = filename || '— no file loaded —';
-    }
-
-    // -------------------------------------------------------------------------
-    // 10. File picker
-    // -------------------------------------------------------------------------
-    function buildFilePicker() {
-        var container = document.getElementById('bb-file-picker');
-        if (!container) return;
-
-        var input = document.createElement('input');
-        input.type = 'text';
-        input.placeholder = 'Search .gcode files…';
-        input.className = 'bb-search';
-        input.style.cssText = 'width:100%;padding:4px 8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;margin-bottom:4px;box-sizing:border-box;';
-
-        var list = document.createElement('div');
-        list.style.cssText = 'max-height:180px;overflow-y:auto;';
-
-        container.appendChild(input);
-        container.appendChild(list);
-
-        var allFiles = [];
-
-        function render(files) {
-            list.innerHTML = '';
-            if (!files.length) {
-                list.innerHTML = '<div style="color:#888;padding:4px 6px;font-size:12px;">No .gcode files found in library</div>';
-                return;
-            }
-            files.forEach(function (f) {
-                var row = document.createElement('div');
-                row.textContent = f.filename;
-                row.title = f.filename;
-                row.style.cssText = 'padding:4px 6px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;';
-                row.addEventListener('mouseenter', function () { row.style.background = '#444'; });
-                row.addEventListener('mouseleave', function () { row.style.background = ''; });
-                row.addEventListener('click', function () {
-                    loadFileById(f.id, f.filename, f.file_size);
-                    // Close picker
-                    container.classList.toggle('bb-open', false);
-                });
-                list.appendChild(row);
-            });
-        }
 
 
-        function loadFiles() {
-            list.innerHTML = '<div style="color:#aaa;padding:4px 6px;font-size:12px;">Loading files…</div>';
-            // include_root=false returns files from ALL folders, not just root level
-            apiFetch('/library/files?include_root=false', {})
-                .then(function (r) { return r.json(); })
-                .then(function (files) {
-                    if (!Array.isArray(files)) {
-                        list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files</div>';
-                        return;
-                    }
-                    allFiles = files.filter(function (f) {
-                        return f.filename && f.filename.toLowerCase().endsWith('.gcode');
-                    });
-                    render(allFiles);
-                })
-                .catch(function () {
-                    list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files — check auth token</div>';
-                });
-        }
-
-        input.addEventListener('input', function () {
-            var q = input.value.toLowerCase();
-            render(q ? allFiles.filter(function (f) { return f.filename.toLowerCase().indexOf(q) !== -1; }) : allFiles);
-        });
-
-        loadFiles();
-    }
-
-    // -------------------------------------------------------------------------
-    // 11. Printer selector
-    // -------------------------------------------------------------------------
-    function updatePrinterSelector() {
-        var sel = document.getElementById('bb-printer-select');
-        if (!sel) return;
-        var current = sel.value;
-        sel.innerHTML = '';
-        printers.forEach(function (p) {
-            var opt = document.createElement('option');
-            opt.value = p.id;
-            opt.textContent = (p.name || ('Printer ' + p.id)) + (p.state ? ' [' + p.state + ']' : '');
-            sel.appendChild(opt);
-        });
-        if (current) sel.value = current;
-        if (!sel.value && printers.length) {
-            sel.value = printers[0].id;
-            selectedPrinterId = printers[0].id;
-        }
+        // Fetch metadata (for the filename display) and capabilities (for the
+        // bed size) in parallel. Capabilities extracts the actual build_volume
+        // from the 3MF's slicer config (printable_area / printable_height), so
+        // the bed matches whatever hardware the archive was sliced for — no
+        // hardcoded per-model map, correct for H2D (350×320×325), H-family
+        // machines, and any future model.
+        apiFetch('/archives/' + archiveId, {})
+            .then(function (r) { return r.ok ? r.json() : null; })
+            .then(function (meta) {
+                if (meta && (meta.print_name || meta.filename)) {
+                    currentFilename = (meta.print_name || meta.filename) +
+                        (plateSuffix ? (' (plate ' + plate + ')') : '');
+                    updateFilenameDisplay(currentFilename);
+                }
+            })
+            .catch(function () { /* best-effort — filename stays "Archive #N" */ });
+
+        apiFetch('/archives/' + archiveId + '/capabilities', {})
+            .then(function (r) { return r.ok ? r.json() : null; })
+            .then(function (caps) {
+                if (!caps || !caps.build_volume) return;
+                var bv = caps.build_volume;
+                if (bv.x > 0 && bv.y > 0 && bv.z > 0) {
+                    currentBed = { width: bv.x, depth: bv.y, height: bv.z };
+                    fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
+                }
+            })
+            .catch(function () { /* best-effort — default bed stays */ });
     }
     }
 
 
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
-    // 13. Initialise after DOM + scripts are ready
+    // 11. Initialise after DOM + scripts are ready
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
     function init() {
     function init() {
         // Find the ViewModel registration that prettygcode.js pushed
         // Find the ViewModel registration that prettygcode.js pushed
@@ -692,51 +452,6 @@
             }
             }
         }
         }
 
 
-        connectWebSocket();
-
-        // Wire up printer selector
-        var sel = document.getElementById('bb-printer-select');
-        if (sel) {
-            sel.addEventListener('change', function () {
-                selectedPrinterId = parseInt(sel.value, 10) || null;
-            });
-        }
-
-        // Load initial printer list
-        apiFetch('/printers', {})
-            .then(function (r) { return r.json(); })
-            .then(function (list) {
-                if (!Array.isArray(list)) return;
-                list.forEach(function (p) {
-                    // Find existing entry (WS may have pushed one before API returned)
-                    var existing = null;
-                    for (var i = 0; i < printers.length; i++) {
-                        if (printers[i].id === p.id) { existing = printers[i]; break; }
-                    }
-                    if (existing) {
-                        // Fill in name/model that WS status messages don't carry
-                        if (p.name)  existing.name  = p.name;
-                        if (p.model) existing.model = p.model;
-                    } else {
-                        printers.push({ id: p.id, name: p.name, model: p.model, state: 'IDLE', progress: 0 });
-                    }
-                });
-                updatePrinterSelector();
-                // Try to get bed size from first printer model
-                if (list.length > 0 && list[0].model) {
-                    var m = list[0].model.toUpperCase();
-                    for (var modelName in BAMBU_BED_SIZES) {
-                        if (m.indexOf(modelName.toUpperCase()) !== -1) {
-                            currentBed = BAMBU_BED_SIZES[modelName];
-                            fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
-                            break;
-                        }
-                    }
-                }
-                if (list.length > 0) selectedPrinterId = list[0].id;
-            })
-            .catch(function () {});
-
         console.log('[PrettyGCode] Bambuddy adapter initialised');
         console.log('[PrettyGCode] Bambuddy adapter initialised');
 
 
         // Wire up playback controls
         // Wire up playback controls
@@ -828,34 +543,27 @@
         if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
         if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
     }
     }
 
 
-    // Run after all scripts have loaded.
-    // buildFilePicker() runs immediately at DOM-ready — independent of viewmodel
-    // init so the file picker is always functional even if prettygcode fails.
-    // init() (viewmodel + 3D canvas) runs 200 ms later to let prettygcode.js
-    // finish its own synchronous setup first.
+    // Run after all scripts have loaded. init() (viewmodel + 3D canvas) runs
+    // 200 ms later to let prettygcode.js finish its own synchronous setup first.
     function onDomReady() {
     function onDomReady() {
-        // Wire file-picker button — MUST be here (not an inline <script>) because
-        // the CSP on this page allows script-src 'self' but NOT 'unsafe-inline',
-        // so inline <script> blocks are blocked by the browser.
-        var fileBtn = document.getElementById('bb-file-btn');
-        var picker  = document.getElementById('bb-file-picker');
-        if (fileBtn && picker) {
-            fileBtn.addEventListener('click', function (e) {
-                picker.classList.toggle('bb-open');
-                e.stopPropagation();
-            });
-            // Clicking outside the picker closes it
-            document.addEventListener('click', function () {
-                picker.classList.remove('bb-open');
-            });
-            // Clicks inside the picker don't close it
-            picker.addEventListener('click', function (e) {
-                e.stopPropagation();
-            });
-        }
-
-        buildFilePicker();
-        setTimeout(init, 200);
+        setTimeout(function () {
+            init();
+            // If the viewer was opened with ?archive=<id>[&plate=<N>], load that
+            // archive's gcode for the requested plate once the viewmodel is ready.
+            try {
+                var params = new URLSearchParams(window.location.search);
+                var archiveParam = params.get('archive');
+                var plateParam = params.get('plate');
+                if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
+                    var archiveId = parseInt(archiveParam, 10);
+                    var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
+                        ? parseInt(plateParam, 10)
+                        : undefined;
+                    // Allow a tick for init() to finish wiring viewModel.fromCurrentData
+                    setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
+                }
+            } catch (e) { /* URLSearchParams unsupported — skip */ }
+        }, 200);
     }
     }
 
 
     if (document.readyState === 'loading') {
     if (document.readyState === 'loading') {
@@ -868,7 +576,7 @@
     // Public API
     // Public API
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
     window.BambuddyPrettyGCode = {
     window.BambuddyPrettyGCode = {
-        loadFile: loadFileById,
+        loadArchive: loadArchiveById,
         getViewModel: function () { return viewModel; },
         getViewModel: function () { return viewModel; },
         play: startPlayback,
         play: startPlayback,
         stop: stopPlayback,
         stop: stopPlayback,

+ 0 - 2
spoolbuddy/scripts/pn5180_diag.py

@@ -19,8 +19,6 @@ import os
 import sys
 import sys
 import time
 import time
 
 
-import gpiod
-
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "daemon")))
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "daemon")))
 
 
 
 

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BfEnlXcp.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BoxU3Y8Y.css">
+    <script type="module" crossorigin src="/assets/index-ClHFKnUR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CiCRNaHx.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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