Explorar el Código

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 hace 3 semanas
padre
commit
9b2bd911d8

+ 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 ? (

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio