Browse Source

Open multi-plate G-code on the plate that was asked for

Previewing a sliced multi-plate 3MF from the File Manager showed a plate
nobody picked. The library route took no plate parameter at all, so the
one the viewer has always put in the URL was dropped -- FastAPI discards
unknown query parameters silently. Both routes then fell back to the
first .gcode member of the zip, and member order is whatever the slicer
wrote: the reported file stores plate_2.gcode ahead of plate_1.gcode.
Nothing that opens the viewer from the File Manager passes a plate, so
there was no way to ask for another one either.

Plate resolution now lives in threemf_tools and both routes share it.
select_plate_gcode_name() returns the named plate or None, so a caller
serving an explicit choice can 404 instead of rendering something else;
default_plate_gcode_name() returns the lowest-numbered plate. The viewer
gained a plate switcher, and keeps the choice in its URL so a link to one
plate survives a reload. Filament colours follow it too -- they were
taken from the first plate regardless of which one was on screen.

G-code injection and the finish-photo max_z_height read shared the old
first-member fallback and now resolve the lowest plate as well.
maziggy 3 weeks ago
parent
commit
0229d61cea

+ 1 - 0
CHANGELOG.md

@@ -28,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
 
 ### Fixed
+- **The G-code viewer opened a multi-plate file on the wrong plate, with no way to reach the others** — Previewing a sliced multi-plate 3MF from the File Manager showed a plate nobody had asked for, and nothing on screen said which one it was or offered another. Three things were behind it. The route that serves a library file's G-code took no plate parameter at all, so the plate the viewer had always been putting in the URL was discarded — an unknown query parameter is dropped without a word. With nothing to go on, both this route and the archive one fell back to the first `.gcode` member in the file, and a 3MF's member order is whatever the slicer wrote rather than plate order: the two-plate file this was reported on stores plate 2 ahead of plate 1, which is exactly the plate that kept appearing. And no path into the viewer from the File Manager passes a plate in the first place, so even once the parameter worked there was no way to ask for plate 1. All three are fixed: the library route accepts `plate` exactly as the archive route does, an unqualified request returns the lowest-numbered plate rather than whichever came first in the zip, and the viewer has a plate switcher in its header for any file with more than one. The plate lives in the viewer's URL, so a link to one particular plate keeps working and a reload stays put. Filament colours follow the switcher too, which they previously did not — plate 2's toolpath could be drawn in plate 1's colours. The same first-member assumption sat under G-code injection and the finish-photo bed-drop height, both of which now resolve the lowest plate when no plate is named. Covered by backend and frontend tests, pinned to the member order of a real Bambu Studio export.
 - **A print was refused for lack of filament that AMS Filament Backup had covered all along** — The print dialog weighs each plate against the spool assigned to the slot it maps to, adding up the plates that draw on the same slot before it weighs any of them. What it did not know about was AMS Filament Backup: with that on, the firmware switches to another slot holding the same material once one runs out, so what a print can draw on is the whole matching pool and not the one slot. Queueing both plates of a job needing 1441 g of ABS was refused with "A3: needs 1441g, remaining 1000g" while the identical full spool in A4 sat next to it, doing nothing. The queue dispatcher has pooled matching spools since #1762 and would have started this print without complaint — the dialog was the only thing in the way, and "Print anyway" was always the correct answer to it. The dialog now pools the same way the dispatcher does: same filament profile and colour, and only within one nozzle's reach on the dual-extruder machines, since the firmware cannot cross nozzles even with backup on. Where a pool really is too small the warning quotes the pooled figures instead of the mapped slot's, because "needs 1441g, remaining 1000g" written next to a second full spool reads as a contradiction. Which spools back each other up is now worked out in one place on the server, by the code the dispatcher itself uses, and handed to the browser rather than decided a second time there — the two answers drifting apart is the whole of this bug. Doing it that way also gives the warning to Spoolman users for the first time: it used to resolve spools through the internal inventory only, so in Spoolman mode it had quietly approved everything, including prints that were genuinely short. Covered by backend and frontend tests.
 - **Slicing a model stored on a network share wrote the result into Bambuddy's internal library instead of onto the share (#2810, reported by @zevulos)** — Server-side slicing files kept in an external folder produced a `.gcode.3mf` that appeared in the correct folder in the File Manager but never arrived in the folder itself, so it could not be opened from any other machine on the share. The output was always written to Bambuddy's own storage while the new entry was filed under the external folder, which is why the file looked present in the web interface and was missing everywhere else — and why it could not be reproduced without looking at the mount from outside Bambuddy. Uploads and moves already wrote to external folders correctly; slicing was the last path that did not, which is why moving a sliced file to another folder on the same share made it appear there. The result is now written next to its source, under a name that is readable on the share rather than an internal identifier. An existing file of the same name is never overwritten — a re-slice becomes `Model (2).gcode.3mf` — because the target is somebody's NAS and the file being replaced may not be Bambuddy's. If the external folder cannot accept the file at all, because it is mounted read-only, unreachable or not writable, the slice is kept rather than thrown away: it goes to the internal library as before, and now says so with a warning, since a file quietly filed somewhere you are not looking is the whole of this bug.
 - **A virtual printer's address spilled outside its card on the Virtual Printer page (#2808, reported by @underscan)** — The collapsed header of each virtual-printer card lays its details out in one row: name, mode, model, the printer it proxies to, and the IP addresses it is bound to. Every item in that row was marked as not shrinkable, so the row was always as wide as its contents, and the card does not clip what does not fit — the last IP address and the enable toggle were drawn outside the card's border, on the page background. The name was supposed to shorten with an ellipsis to prevent this, but could not: an item in a row like that will not shrink below its own text unless it is explicitly allowed to, so the ellipsis never appeared. It takes three things at once to run out of room, which is why it took a report to surface: both address fields are filled in only when Bambuddy and the printer are on different subnets (the reporter runs two VLANs), a printer adopted without a name is called "Printer at <address>", and the cards sit three to a row on a wide screen. The details now wrap onto a second line inside the card when they do not fit. Nothing is hidden or shortened away — these are the addresses an operator opens the page to check — and the card clips at its own border as a backstop.

+ 7 - 18
backend/app/api/routes/archives.py

@@ -33,10 +33,12 @@ from backend.app.services.design_settings import overrides_from_config
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
+    default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    select_plate_gcode_name,
 )
 
 logger = logging.getLogger(__name__)
@@ -3315,8 +3317,9 @@ async def get_gcode(
 
     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).
+    back to the archive's lowest-numbered plate — not the first member in the
+    zip, which is whatever order the slicer wrote and routinely puts plate 2
+    ahead of plate 1.
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
@@ -3340,25 +3343,11 @@ async def get_gcode(
                 )
 
             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
+                selected = select_plate_gcode_name(gcode_files, plate)
                 if selected is None:
                     raise HTTPException(404, f"Plate {plate} not found in this archive")
             else:
-                selected = gcode_files[0]
+                selected = default_plate_gcode_name(gcode_files)
 
             gcode_content = zf.read(selected).decode("utf-8")
             return Response(content=gcode_content, media_type="text/plain")

+ 22 - 2
backend/app/api/routes/library.py

@@ -75,10 +75,12 @@ from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_st
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 from backend.app.utils.threemf_tools import (
+    default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    select_plate_gcode_name,
 )
 
 logger = logging.getLogger(__name__)
@@ -5020,6 +5022,7 @@ async def get_thumbnail(
 @router.get("/files/{file_id}/gcode")
 async def get_gcode(
     file_id: int,
+    plate: int | None = None,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -5028,7 +5031,15 @@ async def get_gcode(
         )
     ),
 ):
-    """Get gcode for a file (for preview)."""
+    """Get gcode for a file (for preview).
+
+    Mirrors the archive route: ``?plate=2`` returns ``Metadata/plate_2.gcode``,
+    and omitting it returns the lowest-numbered plate. The viewer has been
+    sending ``plate`` since it gained a multi-plate URL, but this route took no
+    such parameter and FastAPI drops unknown query parameters silently — so
+    every multi-plate library file opened on whichever plate the slicer wrote
+    first into the zip, which is not plate 1.
+    """
     user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
@@ -5042,13 +5053,22 @@ async def get_gcode(
     # case, so detect by suffix before checking the type column.
     is_gcode_3mf = file.file_type in ("3mf", "gcode.3mf") or file.filename.lower().endswith(".gcode.3mf")
 
+    if plate is not None and plate < 1:
+        raise HTTPException(status_code=400, detail="Plate index must be >= 1")
+
     if is_gcode_3mf:
         try:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 if not gcode_files:
                     raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
-                gcode_content = zf.read(gcode_files[0])
+                if plate is not None:
+                    selected = select_plate_gcode_name(gcode_files, plate)
+                    if selected is None:
+                        raise HTTPException(status_code=404, detail=f"Plate {plate} not found in this file")
+                else:
+                    selected = default_plate_gcode_name(gcode_files)
+                gcode_content = zf.read(selected)
                 from fastapi.responses import Response
 
                 return Response(content=gcode_content, media_type="text/plain")

+ 48 - 13
backend/app/utils/threemf_tools.py

@@ -749,21 +749,54 @@ def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
     return header
 
 
-def _select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
-    """Pick a plate's ``.gcode`` member out of a 3MF namelist.
+def _plate_number_of(name: str) -> int | None:
+    """Plate index encoded in a ``…/plate_<n>.gcode`` member, or None.
 
-    Prefers ``plate_<id>.gcode``, then falls back to the first ``.gcode``
-    member so single-plate files — and files from slicers that don't use the
-    plate naming convention — still resolve.
+    Parsed as an int rather than string-matched so a zero-padded
+    ``plate_01.gcode`` resolves to the same 1 the plates endpoint reports.
+    """
+    marker = "plate_"
+    idx = name.rfind(marker)
+    if idx < 0 or not name.endswith(".gcode"):
+        return None
+    try:
+        return int(name[idx + len(marker) : -len(".gcode")])
+    except ValueError:
+        return None
+
+
+def select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
+    """The ``.gcode`` member for exactly ``plate_id``, or None if it isn't there.
+
+    Returns None for a ``plate_id`` the file doesn't hold — callers that want a
+    fallback compose this with ``default_plate_gcode_name``; callers serving a
+    user's explicit plate choice want the None so they can 404 instead of
+    quietly rendering a different plate.
+    """
+    if plate_id is None:
+        return None
+    for name in names:
+        if name.endswith(".gcode") and _plate_number_of(name) == plate_id:
+            return name
+    return None
+
+
+def default_plate_gcode_name(names: list[str]) -> str | None:
+    """The ``.gcode`` member to show when no plate was asked for.
+
+    The lowest plate number, NOT the first member in the archive: zip order is
+    whatever the slicer happened to write, and Bambu Studio does not write
+    plates in order — a two-plate file measured here stores ``plate_2.gcode``
+    ahead of ``plate_1.gcode``, so taking the first member opened plate 2. Files
+    from slicers that don't use the plate naming convention keep the old
+    first-member behaviour, since there is no numbering to sort by.
     """
     gcodes = [n for n in names if n.endswith(".gcode")]
     if not gcodes:
         return None
-    if plate_id is not None:
-        suffix = f"plate_{plate_id}.gcode"
-        for name in gcodes:
-            if name.endswith(suffix):
-                return name
+    numbered = [(num, n) for n in gcodes if (num := _plate_number_of(n)) is not None]
+    if numbered:
+        return min(numbered)[1]
     return gcodes[0]
 
 
@@ -789,7 +822,8 @@ def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None)
     """
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
-            target = _select_plate_gcode_name(zf.namelist(), plate_id)
+            names = zf.namelist()
+            target = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
             if target is None:
                 return None
             with zf.open(target, "r") as fh:
@@ -911,8 +945,9 @@ def inject_gcode_into_3mf(
     try:
         # Find the target gcode file inside the 3MF
         with zipfile.ZipFile(source_path, "r") as zf:
-            # Plate-specific gcode first, else the first one in the file.
-            target_gcode = _select_plate_gcode_name(zf.namelist(), plate_id)
+            # Plate-specific gcode first, else the lowest-numbered plate.
+            names = zf.namelist()
+            target_gcode = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
             if target_gcode is None:
                 return None
 

+ 53 - 0
backend/tests/integration/test_archives_api.py

@@ -1597,6 +1597,59 @@ class TestArchiveF3DEndpoints:
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         assert response.status_code == 404
 
+    async def _two_plate_archive(self, archive_factory, printer_factory, tmp_path):
+        """An archive whose 3MF stores plate 2 ahead of plate 1, as Studio writes it."""
+        import zipfile
+
+        printer = await printer_factory()
+        path = tmp_path / "two_plates.gcode.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
+            zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
+        # An absolute file_path collapses `settings.base_dir / file_path` onto
+        # itself, so the route reads the file written here.
+        return await archive_factory(printer.id, file_path=str(path), filename="two_plates.gcode.3mf")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_gcode_without_a_plate_serves_the_first_plate(
+        self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
+    ):
+        """Zip order is whatever the slicer wrote, so the first member here is
+        plate 2. Callers that pass no plate must still land on plate 1."""
+        archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
+
+        assert response.status_code == 200
+        assert "plate one" in response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_gcode_serves_the_requested_plate(
+        self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
+    ):
+        archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
+
+        first = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
+        second = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
+
+        assert "plate one" in first.text
+        assert "plate two" in second.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_gcode_rejects_a_plate_the_file_does_not_hold(
+        self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
+    ):
+        archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
+
+        missing = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=3")
+        zeroth = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
+
+        assert missing.status_code == 404
+        assert zeroth.status_code == 400
+
     # ========================================================================
     # Tag Management endpoints (Issue #183)
     # ========================================================================

+ 67 - 0
backend/tests/integration/test_library_api.py

@@ -1630,6 +1630,73 @@ class TestPrintFileUploadValidation:
         # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer.
         assert not response.content.startswith(b"PK")
 
+    async def _multi_plate_file(self, db_session):
+        """A two-plate `.gcode.3mf` written plate 2 first, as Bambu Studio does.
+
+        The member order is copied from the file this was reported on — taking
+        the first `.gcode` in the zip opened plate 2.
+        """
+        from backend.app.models.library import LibraryFile
+
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
+            zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
+        with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
+            tmp.write(buf.getvalue())
+            tmp_path = tmp.name
+
+        lib_file = LibraryFile(
+            filename="two-plates.gcode.3mf",
+            file_path=tmp_path,
+            file_type="gcode.3mf",
+            file_size=Path(tmp_path).stat().st_size,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        return lib_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_gcode_serves_the_requested_plate(self, async_client: AsyncClient, db_session):
+        """The viewer has always sent ``?plate=``; this route took no such
+        parameter, and FastAPI drops unknown query parameters without a word —
+        so picking a plate did nothing at all."""
+        lib_file = await self._multi_plate_file(db_session)
+
+        first = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=1")
+        second = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=2")
+
+        assert first.status_code == 200
+        assert b"plate one" in first.content
+        assert second.status_code == 200
+        assert b"plate two" in second.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_gcode_without_a_plate_serves_the_first_plate(self, async_client: AsyncClient, db_session):
+        """Not the first member in the zip — that is plate 2 in this file, and
+        opening a multi-plate file from the File Manager passes no plate."""
+        lib_file = await self._multi_plate_file(db_session)
+
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
+
+        assert response.status_code == 200
+        assert b"plate one" in response.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_gcode_rejects_a_plate_the_file_does_not_hold(self, async_client: AsyncClient, db_session):
+        """404 rather than quietly rendering some other plate."""
+        lib_file = await self._multi_plate_file(db_session)
+
+        missing = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=3")
+        zeroth = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=0")
+
+        assert missing.status_code == 404
+        assert zeroth.status_code == 400
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):

+ 62 - 0
backend/tests/unit/test_plate_gcode_selection.py

@@ -0,0 +1,62 @@
+"""Which ``.gcode`` member of a 3MF a plate resolves to.
+
+A sliced multi-plate 3MF holds one toolpath per plate, and the order they sit
+in the zip is whatever the slicer wrote — not plate order. A real two-plate
+export measured for this fix stores ``Metadata/plate_2.gcode`` ahead of
+``Metadata/plate_1.gcode``, so every caller that took the first member was
+opening plate 2 on a file whose first plate is plate 1.
+"""
+
+from backend.app.utils.threemf_tools import (
+    default_plate_gcode_name,
+    select_plate_gcode_name,
+)
+
+# The exact member order of the reporter's AMS_Rack.gcode.3mf.
+REVERSED_ORDER = ["Metadata/plate_2.gcode", "Metadata/plate_1.gcode"]
+
+
+class TestDefaultPlateGcodeName:
+    def test_picks_the_lowest_plate_not_the_first_member(self):
+        assert default_plate_gcode_name(REVERSED_ORDER) == "Metadata/plate_1.gcode"
+
+    def test_ignores_non_gcode_members(self):
+        names = ["Metadata/plate_1.png", "Metadata/plate_2.gcode", "3D/3dmodel.model", "Metadata/plate_1.gcode"]
+        assert default_plate_gcode_name(names) == "Metadata/plate_1.gcode"
+
+    def test_a_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self):
+        # Bambu writes plate_N.gcode.md5 next to each plate; it ends in .md5,
+        # so it must not win the lowest-plate sort.
+        names = ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"]
+        assert default_plate_gcode_name(names) == "Metadata/plate_1.gcode"
+
+    def test_falls_back_to_first_member_when_nothing_is_plate_numbered(self):
+        # Slicers that don't use the convention have no numbering to sort by.
+        assert default_plate_gcode_name(["out.gcode", "other.gcode"]) == "out.gcode"
+
+    def test_double_digit_plates_sort_numerically_not_lexically(self):
+        names = ["Metadata/plate_10.gcode", "Metadata/plate_2.gcode"]
+        assert default_plate_gcode_name(names) == "Metadata/plate_2.gcode"
+
+    def test_returns_none_for_an_unsliced_file(self):
+        assert default_plate_gcode_name(["3D/3dmodel.model"]) is None
+
+
+class TestSelectPlateGcodeName:
+    def test_selects_the_named_plate_regardless_of_zip_order(self):
+        assert select_plate_gcode_name(REVERSED_ORDER, 1) == "Metadata/plate_1.gcode"
+        assert select_plate_gcode_name(REVERSED_ORDER, 2) == "Metadata/plate_2.gcode"
+
+    def test_zero_padded_names_match_the_index_the_plates_endpoint_reports(self):
+        assert select_plate_gcode_name(["Metadata/plate_01.gcode"], 1) == "Metadata/plate_01.gcode"
+
+    def test_returns_none_for_a_plate_the_file_does_not_hold(self):
+        # Never a silent fallback: the caller asked for a specific plate, and
+        # serving a different one is how the viewer showed the wrong toolpath.
+        assert select_plate_gcode_name(REVERSED_ORDER, 3) is None
+
+    def test_returns_none_without_a_plate_id(self):
+        assert select_plate_gcode_name(REVERSED_ORDER, None) is None
+
+    def test_does_not_match_a_prefix_of_a_longer_number(self):
+        assert select_plate_gcode_name(["Metadata/plate_12.gcode"], 1) is None

+ 80 - 0
frontend/src/__tests__/pages/GCodeViewerPage.test.tsx

@@ -11,6 +11,7 @@
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
@@ -32,6 +33,24 @@ function visit(search: string) {
 
 const viewerUrl = () => screen.getByTestId('toolpath-viewer').getAttribute('data-url');
 
+const plate = (index: number, color: string) => ({
+  index,
+  name: null,
+  objects: [],
+  has_thumbnail: false,
+  thumbnail_url: null,
+  print_time_seconds: null,
+  filament_used_grams: null,
+  filaments: [{ slot_id: 1, type: 'PLA', color, used_grams: 1, used_meters: 1 }],
+});
+
+const TWO_PLATES = {
+  file_id: 7,
+  filename: 'two.gcode.3mf',
+  is_multi_plate: true,
+  plates: [plate(1, '#ff0000'), plate(2, '#00ff00')],
+};
+
 describe('GCodeViewerPage', () => {
   const originalUrl = window.location.href;
 
@@ -82,6 +101,56 @@ describe('GCodeViewerPage', () => {
   });
 });
 
+describe('GCodeViewerPage — plate switcher', () => {
+  const originalUrl = window.location.href;
+  afterEach(() => window.history.pushState({}, '', originalUrl));
+
+  it('offers every plate of a multi-plate file and shows the one being previewed', async () => {
+    // Nothing that opens this page from the File Manager passes a plate, so
+    // without a switcher the other plates of a sliced multi-plate 3MF were
+    // simply unreachable.
+    server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
+
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7');
+    render(<GCodeViewerPage />);
+
+    const first = await screen.findByRole('button', { name: /Plate 1/ });
+    const second = screen.getByRole('button', { name: /Plate 2/ });
+    // No plate in the URL means the backend serves the lowest-numbered one,
+    // which is what the switcher has to agree with.
+    expect(first).toHaveAttribute('aria-pressed', 'true');
+    expect(second).toHaveAttribute('aria-pressed', 'false');
+  });
+
+  it('asks for the plate that was picked', async () => {
+    server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
+
+    const user = userEvent.setup();
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7');
+    render(<GCodeViewerPage />);
+
+    await user.click(await screen.findByRole('button', { name: /Plate 2/ }));
+
+    await waitFor(() => expect(viewerUrl()).toContain('plate=2'));
+    // The choice lives in the URL, so the view survives a reload or a share.
+    expect(window.location.search).toContain('plate=2');
+  });
+
+  it('stays out of the way for a single-plate file', async () => {
+    server.use(
+      http.get('/api/v1/library/files/:id/plates', () =>
+        HttpResponse.json({ ...TWO_PLATES, is_multi_plate: false, plates: [plate(1, '#ff0000')] }),
+      ),
+    );
+
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7');
+    render(<GCodeViewerPage />);
+
+    await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument());
+    expect(screen.queryByRole('button', { name: /Plate 1/ })).not.toBeInTheDocument();
+  });
+});
+
 describe('GCodeViewerPage — filament colours', () => {
   const originalUrl = window.location.href;
   afterEach(() => window.history.pushState({}, '', originalUrl));
@@ -123,6 +192,17 @@ describe('GCodeViewerPage — filament colours', () => {
     );
   });
 
+  it('colours from the plate being previewed, not the first one', async () => {
+    // Plate 2's toolpath rendered in plate 1's colours is the same wrong-plate
+    // mistake one layer up: both have to follow the `plate` parameter.
+    server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
+
+    window.history.pushState({}, '', '/gcode-viewer?library_file=7&plate=2');
+    render(<GCodeViewerPage />);
+
+    await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#00ff00'));
+  });
+
   it('previews without colours when the plate metadata carries none', async () => {
     server.use(
       http.get('/api/v1/library/files/:id/plates', () =>

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

@@ -7078,6 +7078,8 @@ export default {
     back: 'Zurück',
     backToArchives: 'Zurück zum Druckarchiv',
     backToFiles: 'Zurück zum Dateimanager',
+    plates: 'Platten',
+    plateN: 'Platte {{n}}',
   },
   libraryTrash: {
     title: 'Papierkorb',

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

@@ -7127,6 +7127,8 @@ export default {
     back: 'Back',
     backToArchives: 'Back to Print Archives',
     backToFiles: 'Back to File Manager',
+    plates: 'Plates',
+    plateN: 'Plate {{n}}',
   },
   libraryTrash: {
     title: 'Trash',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -7086,6 +7086,8 @@ export default {
     back: 'Atrás',
     backToArchives: 'Volver a los archivos de impresión',
     backToFiles: 'Volver al gestor de archivos',
+    plates: 'Camas',
+    plateN: 'Cama {{n}}',
   },
   libraryTrash: {
     title: 'Papelera',

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

@@ -7067,6 +7067,8 @@ export default {
     back: 'Retour',
     backToArchives: 'Retour aux archives d\'impression',
     backToFiles: 'Retour au gestionnaire de fichiers',
+    plates: 'Plateaux',
+    plateN: 'Plaque {{n}}',
   },
   libraryTrash: {
     title: 'Corbeille',

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

@@ -7066,6 +7066,8 @@ export default {
     back: 'Indietro',
     backToArchives: 'Torna agli archivi di stampa',
     backToFiles: 'Torna al gestore file',
+    plates: 'Piastre',
+    plateN: 'Piastra {{n}}',
   },
   libraryTrash: {
     title: 'Cestino',

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

@@ -7078,6 +7078,8 @@ export default {
     back: '戻る',
     backToArchives: '印刷アーカイブに戻る',
     backToFiles: 'ファイル管理に戻る',
+    plates: 'プレート',
+    plateN: 'プレート {{n}}',
   },
   libraryTrash: {
     title: 'ゴミ箱',

+ 3 - 1
frontend/src/i18n/locales/ko.ts

@@ -6533,7 +6533,9 @@ export default {
     },
     back: '뒤로',
     backToArchives: '인쇄 아카이브로 돌아가기',
-    backToFiles: '파일 관리자로 돌아가기'
+    backToFiles: '파일 관리자로 돌아가기',
+    plates: '플레이트',
+    plateN: '플레이트 {{n}}',
   },
   libraryTrash: {
     title: '휴지통',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -7066,6 +7066,8 @@ export default {
     back: 'Voltar',
     backToArchives: 'Voltar para os arquivos de impressão',
     backToFiles: 'Voltar para o gerenciador de arquivos',
+    plates: 'Placas',
+    plateN: 'Placa {{n}}',
   },
   libraryTrash: {
     title: 'Lixeira',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -6703,6 +6703,8 @@ export default {
     back: "Назад",
     backToArchives: "Вернуться в архив печати",
     backToFiles: "Вернуться в файловый менеджер",
+    plates: "Пластины",
+    plateN: "Пластина {{n}}",
   },
   libraryTrash: {
     title: "Корзина",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -7017,6 +7017,8 @@ export default {
     back: 'Geri',
     backToArchives: 'Baskı Arşivlerine Dön',
     backToFiles: 'Dosya Yöneticisine Dön',
+    plates: 'Plakalar',
+    plateN: 'Plaka {{n}}',
   },
   libraryTrash: {
     title: 'Çöp Kutusu',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -7121,6 +7121,8 @@ export default {
     back: "Назад",
     backToArchives: "Назад до друку архівів",
     backToFiles: "Назад до файлового менеджера",
+    plates: "Пластини",
+    plateN: "Пластина {{n}}",
   },
   libraryTrash: {
     title: "Кошик",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -7065,6 +7065,8 @@ export default {
     back: '返回',
     backToArchives: '返回打印归档',
     backToFiles: '返回文件管理器',
+    plates: '板',
+    plateN: '板 {{n}}',
   },
   libraryTrash: {
     title: '回收站',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -7065,6 +7065,8 @@ export default {
     back: '返回',
     backToArchives: '返回列印歸檔',
     backToFiles: '返回檔案管理器',
+    plates: '板',
+    plateN: '板 {{n}}',
   },
   libraryTrash: {
     title: '資源回收筒',

+ 61 - 6
frontend/src/pages/GCodeViewerPage.tsx

@@ -22,7 +22,7 @@ import { GcodeToolpathViewer } from '../components/GcodeToolpathViewer';
  */
 export function GCodeViewerPage() {
   const navigate = useNavigate();
-  const [searchParams] = useSearchParams();
+  const [searchParams, setSearchParams] = useSearchParams();
   const { t } = useTranslation();
 
   const archiveId = searchParams.get('archive');
@@ -50,13 +50,42 @@ export function GCodeViewerPage() {
     retry: false,
   });
 
+  const archivePlatesQuery = useQuery({
+    queryKey: ['gcode-viewer-archive-plates', archiveId],
+    queryFn: () => api.getArchivePlates(Number(archiveId)),
+    enabled: Boolean(archiveId),
+    staleTime: 5 * 60_000,
+    retry: false,
+  });
+
+  const plates = useMemo(
+    () => (archiveId ? archivePlatesQuery.data?.plates : libraryPlatesQuery.data?.plates) ?? [],
+    [archiveId, archivePlatesQuery.data, libraryPlatesQuery.data],
+  );
+
+  // Which plate the viewer is showing. Without a `plate` in the URL the backend
+  // serves the lowest-numbered one, so that is what the switcher has to mark as
+  // current — the URL stays clean until the user picks something else.
+  const activePlate = useMemo(() => {
+    if (plate) return Number(plate);
+    if (plates.length === 0) return null;
+    return Math.min(...plates.map((p) => p.index));
+  }, [plate, plates]);
+
+  const selectPlate = (index: number) => {
+    // The G-code URL is derived from this parameter, so writing the one already
+    // being shown would refetch the whole toolpath for no change.
+    if (index === activePlate) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('plate', String(index));
+    setSearchParams(next, { replace: true });
+  };
+
   const filamentColors = useMemo<string[] | undefined>(() => {
     if (archiveId) return archiveColorsQuery.data?.filament_colors;
 
-    const plates = libraryPlatesQuery.data?.plates ?? [];
     // Colours are per plate; use the one being previewed.
-    const wanted = plate ? Number(plate) : null;
-    const source = (wanted != null && plates.find((p) => p.index === wanted)) || plates[0];
+    const source = plates.find((p) => p.index === activePlate) || plates[0];
     if (!source?.filaments?.length) return undefined;
 
     // slot_id is 1-based and the G-code's tool numbers are 0-based, so index
@@ -67,7 +96,7 @@ export function GCodeViewerPage() {
       if (filament.color) colors[slot] = filament.color;
     }
     return colors.length > 0 ? colors : undefined;
-  }, [archiveId, archiveColorsQuery.data, libraryPlatesQuery.data, plate]);
+  }, [archiveId, archiveColorsQuery.data, plates, activePlate]);
 
   const gcodeUrl = useMemo(() => {
     // Multi-plate sources need the plate carried through, or the viewer shows
@@ -89,7 +118,7 @@ export function GCodeViewerPage() {
 
   return (
     <div className="flex flex-col h-full">
-      <div className="flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary">
+      <div className="flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2">
         <button
           type="button"
           onClick={handleBack}
@@ -98,6 +127,32 @@ export function GCodeViewerPage() {
           <ArrowLeft className="w-4 h-4" />
           {backLabel}
         </button>
+
+        {/* A sliced multi-plate 3MF holds one toolpath per plate, and only one
+            of them can be on screen. Without this the other plates were
+            unreachable: nothing that opens this page from the File Manager
+            passes a plate, so it showed whichever one the backend picked. */}
+        {plates.length > 1 && (
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="text-xs text-bambu-gray">{t('gcodeViewer.plates', 'Plates')}</span>
+            {plates.map((p) => (
+              <button
+                key={p.index}
+                type="button"
+                onClick={() => selectPlate(p.index)}
+                aria-pressed={p.index === activePlate}
+                title={p.name ?? undefined}
+                className={`px-2 py-0.5 rounded text-xs transition-colors ${
+                  p.index === activePlate
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
+                }`}
+              >
+                {t('gcodeViewer.plateN', 'Plate {{n}}', { n: p.index })}
+              </button>
+            ))}
+          </div>
+        )}
       </div>
 
       {gcodeUrl ? (

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


+ 1 - 1
static/index.html

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

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