Explorar o código

fix(slicer): slicing for the Cool Plate SuperTack works (issue #3359)

- The Slice dialog sent "Cool Plate (SuperTack)" and "Smooth PEI
  Plate", names neither Bambu Studio nor OrcaSlicer knows. The CLI
  then slices for the Cool Plate and refuses the filament. SuperTack
  now sends "Supertack Plate"; Smooth PEI is the slicers' High Temp
  Plate, offered once as "Smooth PEI Plate / High Temp Plate".
- The two old names are converted on slice requests and on pipeline
  create, update and read, so saved pipelines slice on the right plate.
  Any other unknown name, and "Default Plate" (no bed temperature as
  the global plate, the CLI crashes), is refused with a 422; stored
  rows are only converted, never refused.
- Bambu Studio refuses OrcaSlicer's Textured Cool Plate with a 400,
  except for "Slice as designed" on a 3MF, which ignores the plate.
- A pipeline run with an unknown stored plate fails with a message
  instead of a validation dump. Its slice request is now built inside
  the try, so the run no longer stays in "slicing".
- The dialog shows a loaded pipeline's plate it doesn't list instead
  of "Auto".
- Files sliced for the Textured Cool Plate record their bed
  temperature.
maziggy hai 1 día
pai
achega
771aad30c9

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Slicing for the Cool Plate SuperTack works (#3359, reported by @Akaviiii)** — Picking **Cool Plate SuperTack** in the Slice dialog sent a plate name neither Bambu Studio nor OrcaSlicer knows. The slicer then sliced for the Cool Plate and failed with "Filaments are not compatible with the plate type". **Smooth PEI Plate** had the same problem: the slicers call that plate High Temp Plate, so the dialog now offers it once, as **Smooth PEI Plate / High Temp Plate**. Slicer pipelines saved with either option slice on the right plate without being saved again. A plate name no slicer knows is now refused when slicing through the API or saving a pipeline, instead of being sliced as Cool Plate, and so is OrcaSlicer's Textured Cool Plate when Bambu Studio does the slicing. A pipeline holding a plate the dialog doesn't list shows that plate when it is applied, instead of showing Auto, and a run of a pipeline with a plate no slicer knows fails with a message saying so. Files sliced for OrcaSlicer's Textured Cool Plate now also record their bed temperature.
 - **The MQTT Debug log shows what the printer sent** — Each logged message was the same object Bambuddy then processed, and processing writes Bambuddy's own state into it. An incoming push therefore showed Bambuddy's merged AMS list instead of the printer's AMS block, without fields such as `ams_exist_bits` and still listing an AMS that had been unplugged, and later messages could gain an AMS list they never carried. The log now keeps every incoming message exactly as it arrived.
 - **Printers without an AMS print from the spool holder (#3356, reported by @dogod3814t)** — A queue print to a printer with no AMS attached, such as a P1S on its spool holder alone, failed with "Failed to get AMS mapping table" when nothing was set on the spool holder. With nothing to match, the job went out without a filament mapping and asking the printer to use its AMS, which it refuses. Bambuddy now tells a printer that reports no AMS to print from the spool holder, as the slicer does. Dual-nozzle printers are unchanged. An AMS unplugged while Bambuddy runs also disappears from the printer card and from slicers connected through a virtual printer now, instead of staying until a restart, so this works straight after removing one. As with a spool taken out of a slot, spools assigned to an AMS that stays unplugged for more than two minutes are unassigned; RFID spools are assigned again when the AMS is back, other spools need assigning again.
 - **A personal cost center's budget can be changed (#3358, reported by @airforceixi)** — Saving the **Edit cost center** dialog on a user's personal cost center failed with "Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing", even when only the budget was changed, so a budget (or 0, to stop that user printing) could not be set. The dialog always sent the name as well, which a personal cost center refuses, and the budget was never saved. It now saves only the budget for a personal cost center, and its name field is greyed out, as the name is the user's.

+ 8 - 1
backend/app/api/routes/library.py

@@ -76,7 +76,7 @@ from backend.app.schemas.library import (
     ZipExtractResponse,
     ZipExtractResult,
 )
-from backend.app.schemas.slicer import SliceRequest, SliceResponse
+from backend.app.schemas.slicer import ORCA_ONLY_BED_TYPES, SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser, estimate_filament_cost
 from backend.app.services.design_settings import (
     DesignOverride,
@@ -4478,6 +4478,13 @@ async def _run_slicer_with_fallback(
     # gates the toggle on the picked printer matching the design's target,
     # so this path never re-targets across printer models.
     embedded_mode = bool(request.use_embedded_settings and is_3mf)
+    # The schema takes either slicer's plate names, but Bambu Studio slices a
+    # name it does not know as Cool Plate (#3359).
+    if not embedded_mode and preferred == "bambu_studio" and request.bed_type in ORCA_ONLY_BED_TYPES:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Bambu Studio has no {request.bed_type}. Pick another build plate or slice with OrcaSlicer.",
+        )
     # Bounds silence rather than total slicing time (#2730), so a heavy model
     # that keeps reporting progress runs to completion however long it takes.
     service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))

+ 17 - 3
backend/app/api/routes/pipeline_runs.py

@@ -65,7 +65,7 @@ from backend.app.schemas.pipeline_run import (
     PipelineRunListResponse,
     PipelineRunResponse,
 )
-from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer import PresetRef, SliceRequest, checked_bed_type
 from backend.app.services.pipeline_eligibility import (
     EligibilityReport,
     check_pipeline_eligibility,
@@ -175,11 +175,23 @@ def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
         for r in raw_filaments
         if isinstance(r, dict) and "source" in r and "id" in r
     ]
+    # Rows saved before #3359 can hold a name no slicer knows; say so instead
+    # of failing the run with a validation dump.
+    try:
+        bed_type = checked_bed_type(pipeline.bed_type)
+    except ValueError:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"The pipeline's build plate {pipeline.bed_type!r} is not one the slicers know. "
+                "Save the pipeline again from the Slice dialog with another plate."
+            ),
+        ) from None
     return SliceRequest(
         printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
         process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
         filament_presets=filament_presets,
-        bed_type=pipeline.bed_type,
+        bed_type=bed_type,
         export_3mf=True,
     )
 
@@ -543,7 +555,6 @@ def _make_orchestration_callable(
             await session.commit()
             await _publish_run_event(session, run)
 
-            slice_request = _slice_request_from_pipeline(pipeline)
             model_bytes = src_path.read_bytes()
 
             folder_id: int | None = None
@@ -553,6 +564,9 @@ def _make_orchestration_callable(
                     folder_id = lib.folder_id
 
             try:
+                # Inside the try: a row with a bed type no slicer knows fails
+                # the run instead of leaving it in "slicing" (#3359).
+                slice_request = _slice_request_from_pipeline(pipeline)
                 slice_response = await slice_and_persist(
                     session,
                     model_bytes=model_bytes,

+ 55 - 6
backend/app/schemas/slicer.py

@@ -3,13 +3,56 @@
 import re
 from typing import Any, Literal
 
-from pydantic import BaseModel, Field, model_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 # `#RRGGBB` or `#RRGGBBAA`. Bambu Studio writes the 6-digit form into
 # `filament_colour` but accepts and round-trips the 8-digit one, and the AMS
 # reports colours with an alpha byte, so both have to pass.
 _HEX_COLOUR = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
 
+# The ``curr_bed_type`` values the slicers know: ``s_keys_map_BedType`` in
+# BambuStudio's and OrcaSlicer's PrintConfig.cpp (Textured Cool Plate is Orca
+# only). The slicer CLI does not refuse any other name: it slices as Cool Plate
+# and then fails on filaments the Cool Plate does not take (#3359). "Default
+# Plate" is left out: it only means "the global plate" on a plate, and as the
+# global plate the CLI has no bed temperature to read for it.
+BED_TYPES = frozenset(
+    {
+        "Cool Plate",
+        "Engineering Plate",
+        "High Temp Plate",
+        "Textured PEI Plate",
+        "Supertack Plate",
+        "Textured Cool Plate",
+    }
+)
+
+# Plates only OrcaSlicer has; Bambu Studio slices them as Cool Plate.
+ORCA_ONLY_BED_TYPES = frozenset({"Textured Cool Plate"})
+
+# Names the Slice modal sent before #3359. Saved pipelines may still hold them.
+# Smooth PEI is not a plate of its own: the slicers call it High Temp Plate.
+_OLD_BED_TYPE_NAMES = {
+    "Cool Plate (SuperTack)": "Supertack Plate",
+    "Smooth PEI Plate": "High Temp Plate",
+}
+
+
+def canonical_bed_type(bed_type: str | None) -> str | None:
+    """The slicer's name for a bed type; empty means no override."""
+    if not bed_type:
+        return bed_type
+    return _OLD_BED_TYPE_NAMES.get(bed_type, bed_type)
+
+
+def checked_bed_type(bed_type: str | None) -> str | None:
+    """``canonical_bed_type`` for request bodies: a name no slicer knows is
+    refused instead of being sliced as Cool Plate."""
+    bed_type = canonical_bed_type(bed_type)
+    if bed_type and bed_type not in BED_TYPES:
+        raise ValueError(f"unknown bed_type {bed_type!r}, expected one of: {', '.join(sorted(BED_TYPES))}")
+    return bed_type
+
 
 class PresetRef(BaseModel):
     """A source-aware reference to a printer / process / filament preset.
@@ -163,11 +206,12 @@ class SliceRequest(BaseModel):
         default=None,
         max_length=64,
         description=(
-            "Override the process preset's curr_bed_type for this slice. Canonical "
-            "BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
-            "'High Temp Plate', 'Textured PEI Plate', 'Smooth PEI Plate', "
-            "'Cool Plate (SuperTack)', 'Supertack Plate'. None ⇒ inherit from the "
-            "process preset unchanged (#1337)."
+            "Override the process preset's curr_bed_type for this slice. One of "
+            "the BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
+            "'High Temp Plate' (Smooth PEI), 'Textured PEI Plate', 'Supertack "
+            "Plate', 'Textured Cool Plate' (OrcaSlicer only). "
+            "Any other name is refused (#3359). None ⇒ inherit from the process "
+            "preset unchanged (#1337)."
         ),
     )
     auto_orient: bool = Field(
@@ -192,6 +236,11 @@ class SliceRequest(BaseModel):
         ),
     )
 
+    @field_validator("bed_type")
+    @classmethod
+    def check_bed_type(cls, value: str | None) -> str | None:
+        return checked_bed_type(value)
+
     @model_validator(mode="after")
     def normalise_preset_refs(self) -> "SliceRequest":
         """Each slot must end up with a `PresetRef` set. Legacy integer ids

+ 18 - 2
backend/app/schemas/slicer_pipeline.py

@@ -10,10 +10,10 @@ opaque defaults — they come alive in PR B (single-target dispatch) and PR C
 from datetime import datetime
 from typing import Literal
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
-from backend.app.schemas.slicer import PresetRef
+from backend.app.schemas.slicer import PresetRef, canonical_bed_type, checked_bed_type
 
 
 class SlicerPipelineBase(BaseModel):
@@ -31,10 +31,21 @@ class SlicerPipelineBase(BaseModel):
     )
     bed_type: str | None = Field(default=None, max_length=64)
 
+    @field_validator("bed_type")
+    @classmethod
+    def slicer_bed_type(cls, value: str | None) -> str | None:
+        # A row saved with an old name reads back as the slicer's name (#3359).
+        return canonical_bed_type(value)
+
 
 class SlicerPipelineCreate(SlicerPipelineBase):
     """Payload for POST /slicer-pipelines."""
 
+    @field_validator("bed_type")
+    @classmethod
+    def slicer_bed_type(cls, value: str | None) -> str | None:
+        return checked_bed_type(value)
+
 
 class SlicerPipelineUpdate(IgnoresNullForRequiredColumns):
     """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
@@ -50,6 +61,11 @@ class SlicerPipelineUpdate(IgnoresNullForRequiredColumns):
     filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
     bed_type: str | None = Field(default=None, max_length=64)
 
+    @field_validator("bed_type")
+    @classmethod
+    def slicer_bed_type(cls, value: str | None) -> str | None:
+        return checked_bed_type(value)
+
     # PR B target binding. ``target_kind='specific_printer'`` requires
     # ``target_printer_id`` to be set OR cleared in the same payload (route
     # handler enforces). ``target_kind='printer_class'`` is wired by PR C

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

@@ -1035,6 +1035,8 @@ _BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
     "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
     "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
     "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
+    # OrcaSlicer only (PrintConfig.hpp get_bed_temp_key).
+    "Textured Cool Plate": ("textured_cool_plate_temp_initial_layer", "textured_cool_plate_temp"),
 }
 
 # Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,

+ 66 - 0
backend/tests/integration/test_library_slice_api.py

@@ -473,6 +473,72 @@ class TestSliceLibraryFile:
         assert final["error_status"] == 400
         assert "preset_type" in (final["error_detail"] or "")
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("slicer", "source", "as_designed", "reaches_the_slicer"),
+        [
+            ("bambu_studio", "stl", False, False),
+            # The flag does nothing for an STL, so the plate is still applied.
+            ("bambu_studio", "stl", True, False),
+            # "Slice as designed" on a 3MF never applies the picked plate.
+            ("bambu_studio", "3mf", True, True),
+            ("orcaslicer", "stl", False, True),
+        ],
+    )
+    async def test_an_orca_only_plate_is_refused_for_bambu_studio(
+        self, async_client: AsyncClient, db_session, slice_test_setup, slicer, source, as_designed, reaches_the_slicer
+    ):
+        """Bambu Studio slices a plate name it does not know as Cool Plate (#3359)."""
+        await db_session.execute(
+            SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value=slicer)
+        )
+        await db_session.commit()
+        file_id = slice_test_setup["src_file_id"]
+        if source == "3mf":
+            path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
+            path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
+            threemf = LibraryFile(
+                filename="designed.3mf",
+                file_path=str(path.relative_to(slice_test_setup["tmp_path"])),
+                file_type="3mf",
+                file_size=path.stat().st_size,
+            )
+            db_session.add(threemf)
+            await db_session.commit()
+            file_id = threemf.id
+        sent: list = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            sent.append(request)
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={"x-print-time-seconds": "10", "x-filament-used-g": "0.1", "x-filament-used-mm": "1.0"},
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{file_id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "bed_type": "Textured Cool Plate",
+                "use_embedded_settings": as_designed,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        if reaches_the_slicer:
+            assert final["status"] == "completed", final
+            assert any(_is_slice_post(r) for r in sent)
+        else:
+            assert final["status"] == "failed"
+            assert final["error_status"] == 400
+            assert "Bambu Studio has no Textured Cool Plate" in final["error_detail"]
+            assert not any(_is_slice_post(r) for r in sent)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_unknown_preferred_slicer_fails_with_400(

+ 114 - 0
backend/tests/integration/test_pipeline_bed_type_3359.py

@@ -0,0 +1,114 @@
+"""Pipelines and the bed-type names no slicer knows (#3359).
+
+The Slice modal used to save "Cool Plate (SuperTack)" and "Smooth PEI Plate",
+which the slicer CLI slices as Cool Plate. Saved pipelines keep those names.
+"""
+
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi import HTTPException
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineRun
+from backend.app.models.slicer_pipeline import SlicerPipeline
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _payload(bed_type: str) -> dict:
+    return {
+        "name": "SuperTack PLA",
+        "printer_preset": {"source": "local", "id": "1"},
+        "process_preset": {"source": "local", "id": "2"},
+        "filament_presets": [{"source": "local", "id": "3"}],
+        "bed_type": bed_type,
+    }
+
+
+async def _saved_pipeline(db_session, bed_type: str) -> SlicerPipeline:
+    pipeline = SlicerPipeline(
+        name="saved before #3359",
+        printer_preset_source="local",
+        printer_preset_id="1",
+        process_preset_source="local",
+        process_preset_id="2",
+        filament_presets_json='[{"source": "local", "id": "3"}]',
+        bed_type=bed_type,
+    )
+    db_session.add(pipeline)
+    await db_session.commit()
+    return pipeline
+
+
+async def test_saving_the_old_supertack_name_stores_the_slicers_name(async_client: AsyncClient):
+    resp = await async_client.post("/api/v1/slicer-pipelines/", json=_payload("Cool Plate (SuperTack)"))
+
+    assert resp.status_code == 201, resp.text
+    assert resp.json()["bed_type"] == "Supertack Plate"
+
+
+async def test_an_unknown_name_is_refused(async_client: AsyncClient):
+    created = await async_client.post("/api/v1/slicer-pipelines/", json=_payload("Glass Plate"))
+    assert created.status_code == 422
+
+    ok = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload("Cool Plate"))).json()
+    updated = await async_client.put(f"/api/v1/slicer-pipelines/{ok['id']}", json={"bed_type": "Glass Plate"})
+    assert updated.status_code == 422
+
+
+async def test_a_pipeline_saved_before_reads_back_with_the_slicers_name(async_client: AsyncClient, db_session):
+    pipeline = await _saved_pipeline(db_session, "Smooth PEI Plate")
+
+    resp = await async_client.get("/api/v1/slicer-pipelines/")
+
+    assert resp.status_code == 200
+    (listed,) = [p for p in resp.json()["pipelines"] if p["id"] == pipeline.id]
+    assert listed["bed_type"] == "High Temp Plate"
+
+
+async def test_a_run_of_a_pipeline_with_an_unknown_name_fails(db_session, test_engine, tmp_path):
+    """Before, building the request sat outside the try and the run stayed in "slicing"."""
+    from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
+
+    pipeline = await _saved_pipeline(db_session, "Glass Plate")
+    source = LibraryFile(filename="cube.stl", file_path="library/cube.stl", file_type="stl", file_size=1)
+    db_session.add(source)
+    await db_session.commit()
+    run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=1)
+    db_session.add(run)
+    await db_session.commit()
+    model = tmp_path / "cube.stl"
+    model.write_bytes(b"model")
+    sliced = AsyncMock()
+
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind="library_file",
+        src_id=source.id,
+        src_filename="cube.stl",
+        src_path=Path(model),
+        creator_user_id=None,
+        copies=1,
+    )
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.api.routes.pipeline_runs.async_session", maker),
+        patch("backend.app.api.routes.library.slice_and_persist", new=sliced),
+        patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
+        pytest.raises(HTTPException),
+    ):
+        await orchestrate(1)
+
+    sliced.assert_not_awaited()
+    async with maker() as fresh:
+        stored = await fresh.get(PipelineRun, run.id)
+    assert stored.status == "failed"
+    assert stored.error_message == (
+        "Slice failed: The pipeline's build plate 'Glass Plate' is not one the slicers know. "
+        "Save the pipeline again from the Slice dialog with another plate."
+    )

+ 3 - 1
backend/tests/unit/test_archive_bed_temperature_2989.py

@@ -39,10 +39,12 @@ class TestThePlateTheProjectIsSlicedFor:
             ("High Temp Plate", "hot_plate_temp"),
             ("Textured PEI Plate", "textured_plate_temp"),
             ("Supertack Plate", "supertack_plate_temp"),
+            ("Textured Cool Plate", "textured_cool_plate_temp"),
         ],
     )
     def test_every_plate_type_reads_its_own_array(self, bed_type, key):
-        """All five plates BambuStudio can name, each with its own key."""
+        """All five plates BambuStudio can name, and OrcaSlicer's Textured Cool
+        Plate, each with its own key."""
         assert _extract({"curr_bed_type": bed_type, key: ["65", "65"]}) == 65
 
     def test_the_fitted_plate_wins_over_the_others(self):

+ 97 - 12
backend/tests/unit/test_slice_request_bed_type.py

@@ -10,12 +10,25 @@ way to switch plates short of cloning the preset.
 """
 
 import json
+from datetime import datetime, timezone
 
 import pytest
 from pydantic import ValidationError
 
 from backend.app.api.routes.library import _patch_process_bed_type
+from backend.app.api.routes.pipeline_runs import _slice_request_from_pipeline
+from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer_pipeline import SlicerPipelineCreate, SlicerPipelineResponse, SlicerPipelineUpdate
+
+
+def _request(bed_type):
+    return SliceRequest(
+        printer_preset=PresetRef(source="local", id="1"),
+        process_preset=PresetRef(source="local", id="2"),
+        filament_preset=PresetRef(source="local", id="3"),
+        bed_type=bed_type,
+    )
 
 
 class TestSliceRequestBedTypeField:
@@ -27,22 +40,35 @@ class TestSliceRequestBedTypeField:
         )
         assert req.bed_type is None
 
-    def test_bed_type_accepts_canonical_values(self):
-        for value in (
+    @pytest.mark.parametrize(
+        "value",
+        [
             "Cool Plate",
-            "Cool Plate (SuperTack)",
             "Engineering Plate",
             "High Temp Plate",
             "Textured PEI Plate",
-            "Smooth PEI Plate",
-        ):
-            req = SliceRequest(
-                printer_preset=PresetRef(source="local", id="1"),
-                process_preset=PresetRef(source="local", id="2"),
-                filament_preset=PresetRef(source="local", id="3"),
-                bed_type=value,
-            )
-            assert req.bed_type == value
+            "Supertack Plate",
+            "Textured Cool Plate",
+        ],
+    )
+    def test_bed_type_accepts_the_slicers_names(self, value):
+        assert _request(value).bed_type == value
+
+    @pytest.mark.parametrize(
+        ("old", "slicer_name"),
+        [("Cool Plate (SuperTack)", "Supertack Plate"), ("Smooth PEI Plate", "High Temp Plate")],
+    )
+    def test_the_names_bambuddy_used_to_send_become_the_slicers_names(self, old, slicer_name):
+        """The slicer CLI slices an unknown name as Cool Plate (#3359)."""
+        assert _request(old).bed_type == slicer_name
+
+    @pytest.mark.parametrize("value", ["Smooth PEI", "cool plate", "Textured Plate", "Default Plate"])
+    def test_a_name_no_slicer_knows_is_refused(self, value):
+        with pytest.raises(ValidationError, match="unknown bed_type"):
+            _request(value)
+
+    def test_an_empty_bed_type_is_still_no_override(self):
+        assert _request("").bed_type == ""
 
     def test_bed_type_rejects_overlong_input(self):
         with pytest.raises(ValidationError):
@@ -78,3 +104,62 @@ class TestPatchProcessBedType:
     def test_returns_input_unchanged_when_json_is_not_a_dict(self):
         not_a_dict = json.dumps(["this", "is", "an", "array"])
         assert _patch_process_bed_type(not_a_dict, "Cool Plate") is not_a_dict
+
+
+class TestAPipelineSavedWithAnOldName:
+    """Pipelines store the bed type the modal sent before #3359."""
+
+    def _pipeline(self, bed_type):
+        return SlicerPipeline(
+            name="p",
+            printer_preset_source="local",
+            printer_preset_id="1",
+            process_preset_source="local",
+            process_preset_id="2",
+            filament_presets_json='[{"source": "local", "id": "3"}]',
+            bed_type=bed_type,
+        )
+
+    def test_its_run_slices_on_the_supertack_plate(self):
+        assert _slice_request_from_pipeline(self._pipeline("Cool Plate (SuperTack)")).bed_type == "Supertack Plate"
+
+    def test_it_reads_back_with_the_slicers_name(self):
+        response = SlicerPipelineResponse(
+            id=1,
+            created_by=None,
+            created_at=datetime.now(timezone.utc),
+            updated_at=datetime.now(timezone.utc),
+            name="p",
+            printer_preset=PresetRef(source="local", id="1"),
+            process_preset=PresetRef(source="local", id="2"),
+            filament_presets=[PresetRef(source="local", id="3")],
+            bed_type="Smooth PEI Plate",
+        )
+        assert response.bed_type == "High Temp Plate"
+
+    def test_a_row_with_an_unknown_name_still_reads_back(self):
+        """The list endpoint must not break on a row it would now refuse."""
+        response = SlicerPipelineResponse(
+            id=1,
+            created_by=None,
+            created_at=datetime.now(timezone.utc),
+            updated_at=datetime.now(timezone.utc),
+            name="p",
+            printer_preset=PresetRef(source="local", id="1"),
+            process_preset=PresetRef(source="local", id="2"),
+            filament_presets=[PresetRef(source="local", id="3")],
+            bed_type="Glass Plate",
+        )
+        assert response.bed_type == "Glass Plate"
+
+    @pytest.mark.parametrize("schema", [SlicerPipelineCreate, SlicerPipelineUpdate])
+    def test_saving_one_converts_old_names_and_refuses_unknown_ones(self, schema):
+        fields = {
+            "name": "p",
+            "printer_preset": {"source": "local", "id": "1"},
+            "process_preset": {"source": "local", "id": "2"},
+            "filament_presets": [{"source": "local", "id": "3"}],
+        }
+        assert schema(**fields, bed_type="Cool Plate (SuperTack)").bed_type == "Supertack Plate"
+        with pytest.raises(ValidationError, match="unknown bed_type"):
+            schema(**fields, bed_type="Glass Plate")

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Notify!|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Notify!|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Smooth PEI Plate \/ High Temp Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
 }
 

+ 79 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -717,6 +717,44 @@ describe('SliceModal', () => {
     });
   });
 
+  it("sends the slicer's own names for the SuperTack and Smooth PEI plates (#3359)", async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    const bedSelect = presetSelects().find((el) =>
+      (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
+    ) as HTMLSelectElement;
+    // BambuStudio and OrcaSlicer slice any other name as Cool Plate.
+    expect(Array.from(bedSelect.options).map((o) => o.value)).toEqual([
+      '',
+      'Cool Plate',
+      'Supertack Plate',
+      'Engineering Plate',
+      'High Temp Plate',
+      'Textured PEI Plate',
+    ]);
+    const user = userEvent.setup();
+    await user.selectOptions(bedSelect, 'Supertack Plate');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+        100,
+        expect.objectContaining({ bed_type: 'Supertack Plate' }),
+      );
+    });
+  });
+
   it('omits bed_type when the user leaves it on Auto (no override)', async () => {
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({
@@ -1705,6 +1743,47 @@ describe('SliceModal', () => {
     });
   });
 
+  it("shows a pipeline's plate the list doesn't offer instead of Auto (#3359)", async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [
+        {
+          id: 7,
+          name: 'Orca textured',
+          description: null,
+          printer_preset: { source: 'local', id: '1' },
+          process_preset: { source: 'local', id: '2' },
+          filament_presets: [{ source: 'local', id: '3' }],
+          bed_type: 'Textured Cool Plate',
+          target_kind: 'printer_class',
+          target_printer_id: null,
+          target_model_class: null,
+          fanout_strategy: 'max_parallel',
+          created_by: null,
+          created_at: '2026-06-27T00:00:00Z',
+          updated_at: '2026-06-27T00:00:00Z',
+        },
+      ],
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(within(select).getByText('Orca textured')).toBeDefined();
+    });
+    const user = userEvent.setup();
+    await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
+
+    const bedSelect = presetSelects().find((el) =>
+      (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
+    ) as HTMLSelectElement;
+    expect(bedSelect.value).toBe('Textured Cool Plate');
+    expect(bedSelect.selectedOptions[0].textContent).toBe('Textured Cool Plate');
+  });
+
   it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.createSlicerPipeline.mockResolvedValue({

+ 4 - 4
frontend/src/api/client.ts

@@ -1960,10 +1960,10 @@ export interface SliceRequest {
   plate?: number;
   export_3mf?: boolean;
   // Build-plate override (#1337). When omitted, the slicer uses the process
-  // preset's curr_bed_type as-is. Canonical values match BambuStudio /
-  // OrcaSlicer's enum: "Cool Plate", "Engineering Plate", "High Temp Plate",
-  // "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
-  // "Supertack Plate".
+  // preset's curr_bed_type as-is. A name from BambuStudio / OrcaSlicer's
+  // enum: "Cool Plate", "Engineering Plate", "High Temp Plate" (Smooth PEI),
+  // "Textured PEI Plate", "Supertack Plate", or OrcaSlicer's "Textured Cool
+  // Plate" (refused when Bambu Studio slices); the backend refuses others (#3359).
   bed_type?: string | null;
   // "Slice as designed" (#2611). 3MF only: slice using the file's embedded
   // project_settings.config (the designer's own wall count, infill, etc.)

+ 13 - 7
frontend/src/components/SliceModal.tsx

@@ -1560,21 +1560,23 @@ function CloudStatusBanner({
 }
 
 // Build-plate options offered in the SliceModal (#1337). Values are the
-// canonical strings the slicer's StaticPrintConfig validator accepts as
-// `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
-// enum; OrcaSlicer accepts the same set with a Supertack alias that users
-// can target via the same dropdown if they re-import their presets.
+// `curr_bed_type` names in BambuStudio's and OrcaSlicer's enum
+// (s_keys_map_BedType); the slicer CLI slices any other name as Cool Plate
+// (#3359). Smooth PEI is High Temp Plate there, so it has no entry of its own.
 const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
   { value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
   {
-    value: 'Cool Plate (SuperTack)',
+    value: 'Supertack Plate',
     labelKey: 'slice.bedType.coolPlateSuperTack',
     fallback: 'Cool Plate SuperTack',
   },
   { value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
-  { value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
+  {
+    value: 'High Temp Plate',
+    labelKey: 'slice.bedType.highTemp',
+    fallback: 'Smooth PEI Plate / High Temp Plate',
+  },
   { value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
-  { value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
 ];
 
 function BedTypeDropdown({
@@ -1599,6 +1601,10 @@ function BedTypeDropdown({
         className="flex-1 min-w-0 px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
       >
         <option value="">{t('slice.bedType.auto')}</option>
+        {/* A saved pipeline can hold a plate the list doesn't offer (saved
+            through the API). Shown as it is, so the select doesn't read
+            "Auto" while that plate is sent (#3359). */}
+        {value && !BED_TYPE_OPTIONS.some((opt) => opt.value === value) && <option value={value}>{value}</option>}
         {BED_TYPE_OPTIONS.map((opt) => (
           <option key={opt.value} value={opt.value}>
             {t(opt.labelKey, opt.fallback)}

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

@@ -4876,9 +4876,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4913,9 +4913,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4878,9 +4878,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4864,9 +4864,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4863,9 +4863,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4876,9 +4876,8 @@ export default {
       coolPlate: 'クールプレート',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4668,9 +4668,8 @@ export default {
       coolPlate: '쿨 플레이트',
       coolPlateSuperTack: '쿨 플레이트 슈퍼택',
       engineering: '엔지니어링 플레이트',
-      highTemp: '고온 플레이트',
+      highTemp: '매끄러운 PEI 플레이트 / 고온 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
-      smoothPEI: '매끄러운 PEI 플레이트'
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

+ 1 - 2
frontend/src/i18n/locales/nl.ts

@@ -4911,9 +4911,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4863,9 +4863,8 @@ export default {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4660,9 +4660,8 @@ export default {
       coolPlate: "Холодная пластина",
       coolPlateSuperTack: "Холодная пластина SuperTack",
       engineering: "Инженерная пластина",
-      highTemp: "Высокотемпературная пластина",
+      highTemp: "Гладкая PEI-пластина / Высокотемпературная пластина",
       texturedPEI: "Текстурированная PEI-пластина",
-      smoothPEI: "Гладкая PEI-пластина",
     },
     filters: {
       onlyConnectedModels: 'Только принтеры в сети',

+ 1 - 2
frontend/src/i18n/locales/sv.ts

@@ -4910,9 +4910,8 @@ errors: {
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Slät PEI-platta / High Temp Plate',
       texturedPEI: 'Texturerad PEI-platta',
-      smoothPEI: 'Slät PEI-platta',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4865,9 +4865,8 @@ export default {
       coolPlate: 'Soğuk Plaka',
       coolPlateSuperTack: 'Soğuk Plaka SuperTack',
       engineering: 'Mühendislik Plakası',
-      highTemp: 'Yüksek Sıcaklık Plakası',
+      highTemp: 'Düz PEI Plakası / Yüksek Sıcaklık Plakası',
       texturedPEI: 'Dokulu PEI Plakası',
-      smoothPEI: 'Düz PEI Plakası',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4908,9 +4908,8 @@ export default {
       coolPlate: "Холодна друкарська пластина",
       coolPlateSuperTack: "Cool Plate SuperTack",
       engineering: "Інженерна пластина",
-      highTemp: "Високотемпературна пластина",
+      highTemp: "Гладка PEI-пластина / Високотемпературна пластина",
       texturedPEI: "Текстурована PEI-пластина",
-      smoothPEI: "Гладка PEI-пластина",
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4863,9 +4863,8 @@ export default {
       coolPlate: '冷板',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {

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

@@ -4863,9 +4863,8 @@ export default {
       coolPlate: '冷板',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
-      highTemp: 'High Temp Plate',
+      highTemp: 'Smooth PEI Plate / High Temp Plate',
       texturedPEI: 'Textured PEI Plate',
-      smoothPEI: 'Smooth PEI Plate',
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     filters: {