Przeglądaj źródła

Merge branch 'dev' into feature/ukrainian-localization

MartinNYHC 1 miesiąc temu
rodzic
commit
30d26efb3d

Plik diff jest za duży
+ 3 - 0
CHANGELOG.md


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

@@ -29,6 +29,7 @@ from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveSta
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
+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 (
@@ -41,6 +42,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/archives", tags=["archives"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _safe_filename(filename: str) -> str:
     """Extract basename from a client-supplied filename, preventing path traversal.
@@ -3461,11 +3465,23 @@ async def get_archive_plates(
     # Printer / process preset names the 3MF was prepared with — used by the
     # SliceModal to default its dropdowns (#1325).
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622),
+    # offered in the SliceModal for a cross-printer re-slice. Same payload the
+    # library plates endpoint returns — SliceModal reads one shape for both.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -3727,6 +3743,7 @@ async def get_archive_plates(
         "has_gcode": has_gcode,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 

+ 36 - 0
backend/app/api/routes/library.py

@@ -64,6 +64,11 @@ from backend.app.schemas.library import (
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
+from backend.app.services.design_settings import (
+    apply_design_overrides,
+    extract_design_process_overrides,
+    overrides_from_config,
+)
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
@@ -77,6 +82,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/library", tags=["library"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _ensure_library_file_visible(
     library_file: LibraryFile | None,
@@ -2774,11 +2782,23 @@ async def get_library_file_plates(
     # SliceModal to default its dropdowns (#1325). Initialised here so the
     # final return never raises NameError when the file isn't a valid zip.
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622).
+    # Offered in the SliceModal so a cross-printer re-slice can carry them
+    # instead of silently losing them to the picked process profile.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -3007,6 +3027,7 @@ async def get_library_file_plates(
         "is_multi_plate": len(plates) > 1,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 
@@ -3657,6 +3678,21 @@ async def _run_slicer_with_fallback(
         # with a PVA slot loaded but never used.
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
 
+        # #2622: carry the designer's own process tweaks onto the picked preset.
+        # BambuStudio records exactly which keys deviate from the system preset
+        # in `different_settings_to_system`, so a MakerWorld author's 5 walls /
+        # 100% infill / 0.1mm first layer survive a re-slice for another printer
+        # instead of being flattened by --load-settings. Opt-in per key: only the
+        # keys the caller names are applied, and only if the source really lists
+        # them as changed. Runs after the #1881 support patch so an explicit
+        # design pick wins over the blanket support carry-over.
+        if request.design_overrides:
+            presets["process"] = apply_design_overrides(
+                presets["process"],
+                extract_design_process_overrides(primary_bytes),
+                request.design_overrides,
+            )
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only

+ 11 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,17 @@ class SliceRequest(BaseModel):
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
+    design_overrides: list[str] | None = Field(
+        default=None,
+        description=(
+            "3MF only. Process setting keys from the source file's "
+            "``different_settings_to_system`` to carry onto the picked process "
+            "preset (#2622) — the designer's own wall count, infill, first-layer "
+            "height and so on, which ``--load-settings`` would otherwise discard. "
+            "Only keys the source actually lists as changed are applied; anything "
+            "else is ignored. ``None``/empty means a plain profile slice."
+        ),
+    )
     use_embedded_settings: bool = Field(
         default=False,
         description=(

+ 75 - 4
backend/app/services/bambu_cloud.py

@@ -416,6 +416,42 @@ class BambuCloudService:
             logger.error("Email verification failed: %s", e)
             raise BambuCloudAuthError(f"Verification failed: {e}")
 
+    async def _fetch_csrf_token(self, web_origin: str) -> str | None:
+        """Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
+
+        Bambu added double-submit CSRF protection to the ``bambulab.com`` web
+        origin. A POST without the cookie is rejected ``403 {"error": "CSRF
+        error: missing_cookie"}`` before the request body is looked at; with the
+        cookie but no matching header it becomes ``missing_header``. Only
+        ``GET /api/csrf`` mints one — the sign-in *page* sets nothing but
+        Cloudflare's ``__cf_bm``, so landing there first does not help.
+
+        The token is re-fetched per verification rather than cached: the client
+        is process-wide and long-lived, so a stale cookie could otherwise
+        disagree with the header we send.
+        """
+        try:
+            response = await self._client.get(
+                f"{web_origin}/api/csrf",
+                headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
+            )
+        except Exception as e:
+            logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
+            return None
+        # httpx stores the Set-Cookie on the shared jar, which is also what makes
+        # the cookie ride along on the POST below — we only need the value here
+        # to echo it back in the header.
+        try:
+            token = self._client.cookies.get("bbl_csrf_token")
+        except Exception:  # multiple cookies of the same name across domains
+            token = None
+        if not token:
+            logger.warning(
+                "Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
+                response.status_code,
+            )
+        return token
+
     async def verify_totp(self, tfa_key: str, code: str) -> dict:
         """
         Complete login with TOTP code from authenticator app.
@@ -433,9 +469,24 @@ class BambuCloudService:
             # expected application-level "Login failed" JSON, no Cloudflare
             # interstitial). Browser-impersonation removed to stay clearly on
             # the right side of Bambu Lab's "no falsified client identity" line.
-            tfa_url = "https://bambulab.com/api/sign-in/tfa"
-            if "bambulab.cn" in self.base_url:
-                tfa_url = "https://bambulab.cn/api/sign-in/tfa"
+            web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
+            tfa_url = f"{web_origin}/api/sign-in/tfa"
+
+            # #2696: the web origin is CSRF-protected (double submit). Without
+            # both halves the endpoint 403s before it ever evaluates the code,
+            # which surfaced to users as a permanent, misleading "Invalid code".
+            # api.bambulab.com — where every other call in this service goes,
+            # including the email-code 2FA path — is not gated, which is why
+            # only TOTP sign-ins broke.
+            csrf_token = await self._fetch_csrf_token(web_origin)
+            if not csrf_token:
+                return {
+                    "success": False,
+                    "message": (
+                        "Could not obtain a security token from Bambu Cloud. "
+                        "Check the server's internet access and try again."
+                    ),
+                }
 
             response = await self._client.post(
                 tfa_url,
@@ -443,6 +494,10 @@ class BambuCloudService:
                     "Content-Type": "application/json",
                     "User-Agent": _USER_AGENT,
                     "Accept": "application/json",
+                    # Echo of the bbl_csrf_token cookie httpx just stored. Both
+                    # halves are required; the cookie alone yields
+                    # "missing_header".
+                    "x-bbl-csrf-token": csrf_token,
                 },
                 json={
                     "tfaKey": tfa_key,
@@ -487,10 +542,26 @@ class BambuCloudService:
 
             # Provide helpful error message
             error_msg = data.get("message", "")
+
+            # A CSRF rejection means the code was never evaluated (#2696). It
+            # used to fall through to the generic path below and read as
+            # "Invalid code", which sent the reporter chasing clock drift and
+            # leading-zero parsing for a request Bambu had already refused.
+            csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
+            if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
+                logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
+                return {
+                    "success": False,
+                    "message": (
+                        "Bambu Cloud rejected the sign-in request before checking your code "
+                        "(security-token error). Your code is fine — please try again."
+                    ),
+                }
+
             if "expired" in error_msg.lower():
                 return {"success": False, "message": "TOTP session expired. Please try logging in again."}
             if not error_msg:
-                error_msg = f"TOTP verification failed (status {response.status_code})"
+                error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
 
             return {"success": False, "message": error_msg}
 

+ 193 - 0
backend/app/services/design_settings.py

@@ -0,0 +1,193 @@
+"""Carry a 3MF designer's own process tweaks across a re-slice (#2622).
+
+A MakerWorld model is often published with deliberate deviations from the stock
+Bambu process preset — 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
+file for a different printer used to drop every one of them: ``--load-settings``
+is authoritative, so the picked process preset wins over the 3MF's embedded
+``Metadata/project_settings.config``.
+
+We do not have to *compute* what the designer changed. BambuStudio already did,
+and wrote the answer into the file:
+
+    different_settings_to_system = [
+        "enable_support;inner_wall_speed;sparse_infill_density;...",   # [0]  process
+        "filament_change_length;filament_prime_volume",                # [1..N] filaments
+        "machine_start_gcode;bed_custom_model;...",                    # [-1] printer
+    ]
+
+The array is ``1 + len(filament_settings_id) + 1`` long — verified against real
+files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
+that differ from the system preset, which is the reporter's step 1 for free: no
+baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
+on the slicer sidecar (which exposes bundled presets by name only, with no way to
+flatten one).
+
+Delivery is the mechanism ``_patch_process_support_settings`` already proved in
+#1881: write the values into the process JSON that goes out as ``--load-settings``.
+For a "standard" preset pick that JSON is a ``{inherits: …}`` stub, so the keys we
+write are the *child* in the inherits chain and win over the flattened parent.
+
+Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
+``outer_wall_speed`` and ``prime_tower_max_speed`` in that list — values tuned for
+the designer's machine that can be plain wrong, or out of range, on the target.
+Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
+decides. Nothing is applied that the caller did not ask for by name.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import zipfile
+from io import BytesIO
+from typing import Any, NamedTuple
+
+logger = logging.getLogger(__name__)
+
+_PROJECT_SETTINGS = "Metadata/project_settings.config"
+
+
+class DesignOverride(NamedTuple):
+    """One process setting the designer changed away from the system preset."""
+
+    key: str
+    value: Any
+    printer_coupled: bool
+
+
+# Process keys whose sane value depends on the machine, not on the design intent.
+# The designer picked these for *their* printer's kinematics, chamber and hotend;
+# carrying them onto another model risks a slice that is merely slower/uglier —
+# or a hard range-validation reject from the CLI, which is how the very first
+# slicer spike died. Offered, but never pre-selected.
+#
+# Matching is by exact key OR by suffix/substring rule below, because Bambu's
+# process schema has dozens of per-feature speed keys and an exhaustive literal
+# list would rot on every slicer release.
+_PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
+    {
+        "default_acceleration",
+        "independent_support_layer_height",
+        "precise_z_height",
+        "travel_acceleration",
+        "enable_wrapping_detection",
+    }
+)
+
+# Substring rules for the families that are always machine-coupled. Kept
+# deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
+# kinematic families, "fan"/"temperature" follow the hotend and chamber, and
+# "prime_tower" follows the target's toolchange hardware.
+_PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
+    # Prime-tower geometry (and whether there is one at all) follows the target's
+    # extruder count and bed, not the design — a real file carries five of these.
+    "prime_tower",
+    "_speed",
+    "speed_",
+    "acceleration",
+    "_accel",
+    "jerk",
+    "fan_speed",
+    "_temperature",
+    "temperature_",
+)
+
+
+def is_printer_coupled(key: str) -> bool:
+    """Whether carrying this process key across printer models is risky."""
+    if key in _PRINTER_COUPLED_EXACT:
+        return True
+    lowered = key.lower()
+    return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
+
+
+def _split_changed_keys(entry: Any) -> list[str]:
+    """Parse one ``different_settings_to_system`` entry into its key names."""
+    if not isinstance(entry, str):
+        return []
+    return [part.strip() for part in entry.split(";") if part.strip()]
+
+
+def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
+    """Process settings the 3MF's designer changed away from the system preset.
+
+    Returns an empty list for anything that is not a BambuStudio-style 3MF
+    carrying both ``project_settings.config`` and a well-formed
+    ``different_settings_to_system`` — including OrcaSlicer files and older
+    exports that predate the field. Callers treat empty as "nothing to offer",
+    which is the pre-feature behaviour.
+    """
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+            if _PROJECT_SETTINGS not in zf.namelist():
+                return []
+            config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return []
+    return overrides_from_config(config)
+
+
+def overrides_from_config(config: Any) -> list[DesignOverride]:
+    """``extract_design_process_overrides`` on an already-parsed config dict."""
+    if not isinstance(config, dict):
+        return []
+
+    changed = config.get("different_settings_to_system")
+    if not isinstance(changed, list) or not changed:
+        return []
+
+    # Sanity-check the layout before trusting index 0. The array should be
+    # [process, *filaments, printer]; a file whose length disagrees with its own
+    # filament count is one we do not understand, and guessing there could carry
+    # printer G-code into the process slot.
+    filaments = config.get("filament_settings_id")
+    if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
+        logger.debug(
+            "3MF different_settings_to_system has %d entries for %d filaments "
+            "(expected %d) — skipping design-settings carry-over",
+            len(changed),
+            len(filaments),
+            len(filaments) + 2,
+        )
+        return []
+
+    overrides: list[DesignOverride] = []
+    # Index 0 is the process slot — see the layout in the module docstring. The
+    # length check above is what earns the right to index it blindly.
+    for key in _split_changed_keys(changed[0]):
+        if key not in config:
+            # Listed as changed but absent from the flattened config — nothing
+            # to carry. Seen with keys the slicer renamed between versions.
+            continue
+        overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
+
+    overrides.sort(key=lambda o: o.key)
+    return overrides
+
+
+def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
+    """Write the selected designer values into the outgoing process JSON.
+
+    ``selected_keys`` is authoritative — a key the caller did not name is not
+    applied even when it is present in ``overrides``. Returns ``process_json``
+    unchanged when nothing is selected or the JSON is unparseable, so a bad
+    input degrades to a plain profile slice rather than failing it.
+    """
+    if not selected_keys or not overrides:
+        return process_json
+
+    wanted = set(selected_keys)
+    by_key = {o.key: o.value for o in overrides if o.key in wanted}
+    if not by_key:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(by_key)
+    logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
+    return json.dumps(process_cfg)

+ 10 - 0
backend/tests/integration/test_cloud_auth.py

@@ -501,6 +501,12 @@ class TestCloudRouteRegionPlumbing:
 
         def handler(request: httpx.Request) -> httpx.Response:
             captured.append(str(request.url))
+            # The TOTP path now performs a CSRF handshake first (#2696): it
+            # fetches /api/csrf and refuses to submit the code unless that call
+            # yields a bbl_csrf_token cookie. Mint one here so region-routing
+            # tests reach the TFA POST they are actually asserting on.
+            if request.url.path == "/api/csrf":
+                return httpx.Response(204, headers={"set-cookie": "bbl_csrf_token=csrf-test-token; Path=/"})
             return httpx.Response(status, json=response_json)
 
         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
@@ -574,6 +580,10 @@ class TestCloudRouteRegionPlumbing:
                 # TOTP endpoint lives on bambulab.cn (without the api. prefix),
                 # NOT bambulab.com — that's exactly the bug we just fixed.
                 assert any("bambulab.cn/api/sign-in/tfa" in url for url in captured_urls), captured_urls
+                # The CSRF handshake (#2696) must follow the same origin —
+                # fetching a token from the global site would hand the .cn
+                # endpoint a cookie it never issued.
+                assert any("bambulab.cn/api/csrf" in url for url in captured_urls), captured_urls
                 assert not any("bambulab.com" in url for url in captured_urls), captured_urls
         finally:
             set_shared_http_client(None)

+ 100 - 0
backend/tests/integration/test_design_settings_plates.py

@@ -0,0 +1,100 @@
+"""The plates endpoints must surface the designer's changed settings (#2622).
+
+Parsing is covered in ``unit/test_design_settings.py``. What is asserted here is
+the wiring: SliceModal reads ``design_overrides`` off the plates response, so a
+correct parser that never reaches the payload is a feature that silently does
+nothing.
+"""
+
+import json
+import zipfile
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+
+def _designed_3mf(path: Path, *, with_deviations: bool = True) -> None:
+    """A Bambu-style project 3MF, optionally carrying designer deviations."""
+    config = {
+        "print_settings_id": "0.20mm Standard @BBL A1",
+        "printer_settings_id": "Bambu Lab A1 0.4 nozzle",
+        "filament_settings_id": ["Bambu PLA Basic @BBL A1"],
+        "wall_loops": "5",
+        "outer_wall_speed": "200",
+        "machine_start_gcode": "G28 ; designer printer",
+        "different_settings_to_system": (
+            ["wall_loops;outer_wall_speed", "", "machine_start_gcode"] if with_deviations else ["", "", ""]
+        ),
+    }
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("Metadata/plate_1.gcode", "G0\n")
+        zf.writestr("Metadata/project_settings.config", json.dumps(config))
+
+
+@pytest.fixture
+def _patch_base_dir(monkeypatch, tmp_path):
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+class TestArchivePlatesDesignOverrides:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_the_process_deviations_with_classification(
+        self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
+    ):
+        _designed_3mf(_patch_base_dir / "designed.3mf")
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="designed.3mf", file_path="designed.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        overrides = response.json()["design_overrides"]
+        assert [o["key"] for o in overrides] == ["outer_wall_speed", "wall_loops"]
+        by_key = {o["key"]: o for o in overrides}
+        assert by_key["wall_loops"] == {"key": "wall_loops", "value": "5", "printer_coupled": False}
+        assert by_key["outer_wall_speed"]["printer_coupled"] is True
+        # The printer slot must never leak into the process list.
+        assert "machine_start_gcode" not in by_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_for_a_file_that_changes_nothing(
+        self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
+    ):
+        _designed_3mf(_patch_base_dir / "stock.3mf", with_deviations=False)
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="stock.3mf", file_path="stock.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        assert response.json()["design_overrides"] == []
+
+
+class TestLibraryPlatesDesignOverrides:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_the_process_deviations(self, async_client: AsyncClient, db_session, tmp_path):
+        from backend.app.models.library import LibraryFile
+
+        path = tmp_path / "designed.3mf"
+        _designed_3mf(path)
+        lib_file = LibraryFile(
+            filename="designed.3mf",
+            file_path=str(path),
+            file_type="3mf",
+            file_size=path.stat().st_size,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
+
+        assert response.status_code == 200
+        assert [o["key"] for o in response.json()["design_overrides"]] == ["outer_wall_speed", "wall_loops"]

+ 149 - 0
backend/tests/unit/test_cloud_totp_csrf.py

@@ -0,0 +1,149 @@
+"""Tests for the CSRF handshake on Bambu Cloud TOTP sign-in (#2696).
+
+Bambu added double-submit CSRF protection to the ``bambulab.com`` web origin,
+which is where — and only where — this service posts. Verified against the live
+endpoint while diagnosing the report:
+
+    POST /api/sign-in/tfa  (bare)                     403 {"reason":"missing_cookie"}
+    GET  /api/csrf                                    204 + Set-Cookie: bbl_csrf_token
+    POST /api/sign-in/tfa  (cookie only)              403 {"reason":"missing_header"}
+    POST /api/sign-in/tfa  (cookie + x-bbl-csrf-token) 400 {"code":5,"error":"Login failed"}
+
+The last line is the endpoint reaching application logic with a deliberately
+invalid key — i.e. CSRF satisfied. Four header spellings were tried;
+``x-bbl-csrf-token`` is the only one accepted, so the exact name is pinned here.
+Landing on the sign-in page first does not help: it sets only Cloudflare's
+``__cf_bm``.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.bambu_cloud import BambuCloudService
+
+
+def _response(status: int, body: str, *, cookies: dict | None = None) -> MagicMock:
+    response = MagicMock()
+    response.status_code = status
+    response.text = body
+    response.json.return_value = json.loads(body) if body else {}
+    response.cookies = cookies or {}
+    return response
+
+
+def _service(*, csrf_token: str | None = "csrf-abc123", region: str = "global") -> BambuCloudService:
+    service = BambuCloudService(region=region)
+    client = MagicMock()
+    client.get = AsyncMock(return_value=_response(204, ""))
+    client.post = AsyncMock(return_value=_response(200, '{"accessToken": "tok"}'))
+    jar = MagicMock()
+    jar.get.return_value = csrf_token
+    client.cookies = jar
+    service._client = client
+    return service
+
+
+class TestCsrfHandshake:
+    @pytest.mark.asyncio
+    async def test_fetches_the_token_before_posting_the_code(self):
+        service = _service()
+
+        result = await service.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is True
+        service._client.get.assert_awaited_once()
+        assert service._client.get.await_args.args[0] == "https://bambulab.com/api/csrf"
+
+    @pytest.mark.asyncio
+    async def test_echoes_the_cookie_in_the_x_bbl_csrf_token_header(self):
+        service = _service(csrf_token="csrf-abc123")
+
+        await service.verify_totp("tfa-key", "123456")
+
+        headers = service._client.post.await_args.kwargs["headers"]
+        # Pinned deliberately: every other spelling tried against the live
+        # endpoint still returned "missing_header".
+        assert headers["x-bbl-csrf-token"] == "csrf-abc123"
+
+    @pytest.mark.asyncio
+    async def test_posts_to_the_tfa_endpoint_with_the_key_and_code(self):
+        service = _service()
+
+        await service.verify_totp("tfa-key", "123456")
+
+        assert service._client.post.await_args.args[0] == "https://bambulab.com/api/sign-in/tfa"
+        assert service._client.post.await_args.kwargs["json"] == {"tfaKey": "tfa-key", "tfaCode": "123456"}
+
+    @pytest.mark.asyncio
+    async def test_uses_the_china_origin_for_the_china_region(self):
+        service = _service(region="china")
+
+        await service.verify_totp("tfa-key", "123456")
+
+        assert service._client.get.await_args.args[0] == "https://bambulab.cn/api/csrf"
+        assert service._client.post.await_args.args[0] == "https://bambulab.cn/api/sign-in/tfa"
+
+    @pytest.mark.asyncio
+    async def test_does_not_post_the_code_when_no_token_could_be_obtained(self):
+        service = _service(csrf_token=None)
+
+        result = await service.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is False
+        assert "security token" in result["message"]
+        # Sending the code without CSRF would burn a one-shot TOTP window on a
+        # request Bambu is guaranteed to refuse.
+        service._client.post.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_failing_csrf_fetch_is_reported_not_swallowed(self):
+        service = _service()
+        service._client.get = AsyncMock(side_effect=RuntimeError("connection reset"))
+
+        result = await service.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is False
+        assert "security token" in result["message"]
+        service._client.post.assert_not_awaited()
+
+
+class TestCsrfRejectionMessage:
+    """A CSRF refusal must not read as a wrong code — that misdiagnosis is what
+    sent the reporter chasing clock drift and leading-zero parsing."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("reason", ["missing_cookie", "missing_header"])
+    async def test_csrf_rejection_says_the_code_was_never_checked(self, reason):
+        service = _service()
+        body = json.dumps({"error": f"CSRF error: {reason}", "reason": reason})
+        service._client.post = AsyncMock(return_value=_response(403, body))
+
+        result = await service.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is False
+        assert "before checking your code" in result["message"]
+        assert "Invalid" not in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_a_genuinely_wrong_code_still_reports_bambus_own_message(self):
+        service = _service()
+        service._client.post = AsyncMock(return_value=_response(400, '{"code":5,"error":"Login failed"}'))
+
+        result = await service.verify_totp("tfa-key", "000000")
+
+        assert result["success"] is False
+        assert result["message"] == "Login failed"
+
+    @pytest.mark.asyncio
+    async def test_expired_session_keeps_its_dedicated_message(self):
+        service = _service()
+        service._client.post = AsyncMock(return_value=_response(400, '{"message":"tfaKey expired"}'))
+
+        result = await service.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is False
+        assert "expired" in result["message"].lower()

+ 191 - 0
backend/tests/unit/test_design_settings.py

@@ -0,0 +1,191 @@
+"""Tests for carrying a 3MF designer's process tweaks across a re-slice (#2622).
+
+The layout asserted here — ``different_settings_to_system`` being
+``[process, *filaments, printer]`` — was verified against real BambuStudio files
+at 2, 3 and 4 filament slots before this was written. Getting the index wrong
+would carry ``machine_start_gcode`` from the designer's printer onto the user's,
+so the parser refuses any file whose array length disagrees with its own filament
+count rather than guessing.
+"""
+
+import io
+import json
+import zipfile
+
+from backend.app.services.design_settings import (
+    DesignOverride,
+    apply_design_overrides,
+    extract_design_process_overrides,
+    is_printer_coupled,
+    overrides_from_config,
+)
+
+
+def _config(**overrides) -> dict:
+    """A minimal project_settings.config with two filament slots."""
+    base = {
+        "print_settings_id": "0.20mm Standard @BBL A1",
+        "printer_settings_id": "Bambu Lab A1 0.4 nozzle",
+        "filament_settings_id": ["Bambu PLA Basic @BBL A1", "Bambu PLA Matte @BBL A1"],
+        "wall_loops": "5",
+        "sparse_infill_density": "100%",
+        "initial_layer_print_height": "0.1",
+        "outer_wall_speed": "200",
+        "machine_start_gcode": "G28 ; designer printer",
+        "different_settings_to_system": [
+            "wall_loops;sparse_infill_density;initial_layer_print_height;outer_wall_speed",
+            "",
+            "",
+            "machine_start_gcode",
+        ],
+    }
+    base.update(overrides)
+    return base
+
+
+def _3mf(config: dict | None, *, include_config: bool = True) -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        if include_config:
+            zf.writestr("Metadata/project_settings.config", json.dumps(config))
+    return buffer.getvalue()
+
+
+class TestClassification:
+    def test_geometry_and_quality_keys_are_portable(self):
+        for key in (
+            "wall_loops",
+            "sparse_infill_density",
+            "sparse_infill_pattern",
+            "initial_layer_print_height",
+            "layer_height",
+            "enable_support",
+            "brim_type",
+            "seam_position",
+            "ironing_type",
+        ):
+            assert is_printer_coupled(key) is False, key
+
+    def test_kinematic_and_thermal_keys_are_printer_coupled(self):
+        for key in (
+            "outer_wall_speed",
+            "inner_wall_speed",
+            "internal_solid_infill_speed",
+            "travel_acceleration",
+            "default_acceleration",
+            "default_jerk",
+            "overhang_fan_speed",
+            "nozzle_temperature",
+            "prime_tower_max_speed",
+            "prime_tower_width",
+            "prime_tower_rib_wall",
+            "prime_tower_infill_gap",
+            "enable_prime_tower",
+            "independent_support_layer_height",
+            "precise_z_height",
+        ):
+            assert is_printer_coupled(key) is True, key
+
+
+class TestExtraction:
+    def test_reads_the_process_slot_and_classifies_each_key(self):
+        overrides = extract_design_process_overrides(_3mf(_config()))
+
+        assert [o.key for o in overrides] == [
+            "initial_layer_print_height",
+            "outer_wall_speed",
+            "sparse_infill_density",
+            "wall_loops",
+        ]
+        by_key = {o.key: o for o in overrides}
+        assert by_key["wall_loops"].value == "5"
+        assert by_key["sparse_infill_density"].value == "100%"
+        assert by_key["wall_loops"].printer_coupled is False
+        assert by_key["outer_wall_speed"].printer_coupled is True
+
+    def test_never_surfaces_the_printer_slot(self):
+        # machine_start_gcode is listed in the printer entry, not the process
+        # one. Carrying it would push the designer's start G-code onto another
+        # machine — the exact failure the length check exists to prevent.
+        overrides = extract_design_process_overrides(_3mf(_config()))
+        assert "machine_start_gcode" not in {o.key for o in overrides}
+
+    def test_rejects_an_array_whose_length_contradicts_the_filament_count(self):
+        # Three entries for two filaments: the layout is not the one we know,
+        # so index 0 might not be the process slot. Refuse rather than guess.
+        cfg = _config(different_settings_to_system=["wall_loops", "", ""])
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_skips_keys_absent_from_the_flattened_config(self):
+        cfg = _config(different_settings_to_system=["wall_loops;renamed_in_a_later_slicer", "", "", ""])
+        assert [o.key for o in extract_design_process_overrides(_3mf(cfg))] == ["wall_loops"]
+
+    def test_empty_process_entry_yields_nothing(self):
+        cfg = _config(different_settings_to_system=["", "", "", "machine_start_gcode"])
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_files_without_the_field_yield_nothing(self):
+        cfg = _config()
+        del cfg["different_settings_to_system"]
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_files_without_project_settings_yield_nothing(self):
+        assert extract_design_process_overrides(_3mf(None, include_config=False)) == []
+
+    def test_malformed_input_yields_nothing(self):
+        assert extract_design_process_overrides(b"not a zip") == []
+        assert overrides_from_config("not a dict") == []
+        assert overrides_from_config({"different_settings_to_system": "not a list"}) == []
+
+    def test_tolerates_a_missing_filament_list(self):
+        # No filament_settings_id to cross-check against — index 0 is still the
+        # documented process slot, so parse it rather than bailing out.
+        cfg = _config()
+        del cfg["filament_settings_id"]
+        assert [o.key for o in extract_design_process_overrides(_3mf(cfg))] == [
+            "initial_layer_print_height",
+            "outer_wall_speed",
+            "sparse_infill_density",
+            "wall_loops",
+        ]
+
+
+class TestApply:
+    def _overrides(self) -> list[DesignOverride]:
+        return extract_design_process_overrides(_3mf(_config()))
+
+    def test_writes_only_the_selected_keys(self):
+        process = json.dumps({"inherits": "0.20mm Standard @BBL X1C", "from": "system", "wall_loops": "2"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["wall_loops"]))
+
+        assert patched["wall_loops"] == "5"
+        # Not selected — the picked preset's own value must survive.
+        assert "sparse_infill_density" not in patched
+        assert "outer_wall_speed" not in patched
+        # The inherits stub is what makes the patch win over the flattened
+        # parent inside the sidecar; it must not be disturbed.
+        assert patched["inherits"] == "0.20mm Standard @BBL X1C"
+        assert patched["from"] == "system"
+
+    def test_a_key_the_source_never_flagged_is_ignored(self):
+        process = json.dumps({"inherits": "x"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["layer_height"]))
+
+        assert "layer_height" not in patched
+
+    def test_printer_coupled_keys_apply_when_explicitly_selected(self):
+        process = json.dumps({"inherits": "x"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["outer_wall_speed"]))
+
+        assert patched["outer_wall_speed"] == "200"
+
+    def test_no_selection_is_a_no_op(self):
+        process = json.dumps({"inherits": "x"})
+        assert apply_design_overrides(process, self._overrides(), []) == process
+
+    def test_unparseable_process_json_is_returned_untouched(self):
+        assert apply_design_overrides("{not json", self._overrides(), ["wall_loops"]) == "{not json"

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

@@ -332,6 +332,138 @@ describe('SliceModal', () => {
     expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
   });
 
+  // #2622: a MakerWorld 3MF designed for another printer carries the author's
+  // own process tweaks. BambuStudio records which keys deviate from the stock
+  // preset inside the file, so a cross-printer re-slice can carry them instead
+  // of losing them to the picked process profile.
+  const designedFor = {
+    file_id: 100,
+    filename: 'Designed.3mf',
+    plates: [],
+    is_multi_plate: false,
+    embedded_printer: 'Bambu Lab A1 0.4 nozzle',
+    embedded_process: '0.20mm Standard @BBL A1',
+    design_overrides: [
+      { key: 'wall_loops', value: '5', printer_coupled: false },
+      { key: 'sparse_infill_density', value: '100%', printer_coupled: false },
+      { key: 'outer_wall_speed', value: '200', printer_coupled: true },
+    ],
+  };
+
+  async function openDesignSection() {
+    const user = userEvent.setup();
+    await user.click(await screen.findByText(/Keep the designer's settings/));
+    return user;
+  }
+
+  it("carries the design's printer-independent settings by default (#2622)", async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    // Two of three pre-selected: the speed key is machine-coupled.
+    expect(await screen.findByText('2 of 3 selected')).toBeInTheDocument();
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect([...(payload.design_overrides ?? [])].sort()).toEqual(['sparse_infill_density', 'wall_loops']);
+  });
+
+  it('lists every changed setting with its value and flags the machine-coupled ones (#2622)', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await openDesignSection();
+
+    expect(screen.getByText('wall_loops')).toBeInTheDocument();
+    expect(screen.getByText('5')).toBeInTheDocument();
+    expect(screen.getByText('sparse_infill_density')).toBeInTheDocument();
+    expect(screen.getByText('100%')).toBeInTheDocument();
+    // The risky one is listed too — visible, explained, just not pre-ticked.
+    expect(screen.getByText('outer_wall_speed')).toBeInTheDocument();
+    expect(screen.getByText('printer-specific')).toBeInTheDocument();
+  });
+
+  it('lets the user opt a machine-coupled setting in and a safe one out (#2622)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    const user = await openDesignSection();
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    const byKey = (key: string) =>
+      boxes.find((b) => b.closest('label')?.textContent?.includes(key)) as HTMLInputElement;
+
+    await user.click(byKey('outer_wall_speed'));
+    await user.click(byKey('wall_loops'));
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect([...(payload.design_overrides ?? [])].sort()).toEqual(['outer_wall_speed', 'sparse_infill_density']);
+  });
+
+  it('omits design_overrides entirely when the user unticks everything (#2622)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    const user = await openDesignSection();
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    for (const box of boxes) {
+      if (box.checked) await user.click(box);
+    }
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    expect(mockApi.sliceLibraryFile.mock.calls[0][1]).not.toHaveProperty('design_overrides');
+  });
+
+  it('hides the section for a file that changes nothing (#2622)', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue({ ...designedFor, design_overrides: [] });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
+    expect(screen.queryByText(/Keep the designer's settings/)).toBeNull();
+  });
+
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({

+ 96 - 1
frontend/src/components/SliceModal.tsx

@@ -16,7 +16,7 @@ import {
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
-import type { PlateFilament } from '../types/plates';
+import type { DesignOverride, PlateFilament } from '../types/plates';
 import {
   presetCompatibility,
   buildCompatibilityIndex,
@@ -186,6 +186,16 @@ function formatElapsed(seconds: number): string {
   return `${h}h ${remM}m`;
 }
 
+// Render a slicer parameter value for the design-settings list. Bambu's process
+// schema stores everything as strings or arrays of strings, so this only has to
+// flatten arrays and keep scalars readable — no unit or type interpretation,
+// which would rot against every slicer release.
+function formatDesignValue(value: unknown): string {
+  if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
+  if (value == null) return '';
+  return String(value);
+}
+
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -227,6 +237,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // see canUseEmbedded below.
   const [useEmbedded, setUseEmbedded] = useState(false);
 
+  // #2622: process settings the designer changed away from the stock preset,
+  // carried onto the picked process profile so a cross-printer re-slice keeps
+  // the model's intended wall count / infill / first layer instead of losing
+  // them to --load-settings. Keys the file flags as machine-coupled (speeds,
+  // accelerations, prime-tower geometry) are listed but start unticked — those
+  // were tuned for the designer's printer and can be plain wrong on another.
+  const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
+  const [designExpanded, setDesignExpanded] = useState(false);
+
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
   const pipelinesQuery = useQuery({
@@ -385,6 +404,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // plates query resolves before the presets query (the latter is gated on
   // it), so these are known by the time the pre-pick effects run.
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
+  const designOverrides = useMemo<DesignOverride[]>(
+    () => platesQuery.data?.design_overrides ?? [],
+    [platesQuery.data],
+  );
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
 
   // "Slice as designed" is offered only when the source carries embedded
@@ -405,6 +428,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     if (!canUseEmbedded) setUseEmbedded(false);
   }, [canUseEmbedded]);
 
+  // Pre-tick the printer-independent design settings once the source's list
+  // arrives. Machine-coupled keys stay off until the user opts in explicitly.
+  useEffect(() => {
+    setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
+  }, [designOverrides]);
+
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // that preset is available, else the first listed printer. Runs once when
   // presets first arrive; later re-renders preserve any manual choice.
@@ -505,6 +534,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       // them) but go unused when this flag is set — the slicer falls back on
       // the file's embedded project_settings.config instead.
       ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
+      // Carried design settings are patched onto the resolved process JSON,
+      // which the embedded-settings path never sends — so they are mutually
+      // exclusive by construction (#2622).
+      ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
     };
   }
 
@@ -775,6 +808,68 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 selectedPrinterName={selectedPrinterName}
                 compatIndex={compatIndex}
               />
+              {/* Designer's process tweaks (#2622). BambuStudio records which
+                  keys deviate from the stock preset in the 3MF itself, so a
+                  re-slice for another printer can carry them instead of
+                  flattening them under --load-settings. Hidden entirely when
+                  the source lists none, and disabled in embedded mode where
+                  the process JSON these patch is never sent. */}
+              {designOverrides.length > 0 && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
+                  <button
+                    type="button"
+                    onClick={() => setDesignExpanded((v) => !v)}
+                    className="flex w-full items-center justify-between gap-2 text-left"
+                  >
+                    <span className="text-sm text-white">
+                      {t('slice.designSettings')}
+                      <span className="block text-xs text-bambu-gray/70">
+                        {t('slice.designSettingsHint', { count: designOverrides.length })}
+                      </span>
+                    </span>
+                    <span className="shrink-0 text-xs text-bambu-gray">
+                      {t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
+                    </span>
+                  </button>
+                  {designExpanded && (
+                    <div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
+                      {designOverrides.map((o) => (
+                        <label
+                          key={o.key}
+                          className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
+                        >
+                          <input
+                            type="checkbox"
+                            checked={designKeys.has(o.key)}
+                            disabled={isEnqueuing || useEmbedded}
+                            onChange={(e) => {
+                              setDesignKeys((prev) => {
+                                const next = new Set(prev);
+                                if (e.target.checked) next.add(o.key);
+                                else next.delete(o.key);
+                                return next;
+                              });
+                            }}
+                            className="mt-0.5 shrink-0 cursor-pointer"
+                          />
+                          <span className="min-w-0 flex-1">
+                            <span className="font-mono text-bambu-gray">{o.key}</span>
+                            <span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
+                            {o.printer_coupled && (
+                              <span
+                                className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
+                                title={t('slice.designSettingsPrinterCoupledHint')}
+                              >
+                                {t('slice.designSettingsPrinterCoupled')}
+                              </span>
+                            )}
+                          </span>
+                        </label>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              )}
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}

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

@@ -4072,6 +4072,11 @@ export default {
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
     useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
+    designSettings: 'Einstellungen des Erstellers behalten',
+    designSettingsHint: 'Diese Datei ändert {{count}} Druckeinstellung(en) gegenüber dem Standardprofil.',
+    designSettingsSelected: '{{selected}} von {{total}} ausgewählt',
+    designSettingsPrinterCoupled: 'druckerspezifisch',
+    designSettingsPrinterCoupledHint: 'Auf den Drucker abgestimmt, für den die Datei erstellt wurde — auf deinem kann der Wert falsch oder unzulässig sein.',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',

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

@@ -4106,6 +4106,11 @@ export default {
     allPresetsRequired: 'All presets must be selected',
     useEmbedded: "Use the file's built-in settings",
     useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
+    designSettings: 'Keep the designer\'s settings',
+    designSettingsHint: 'This file changes {{count}} print setting(s) from the stock profile.',
+    designSettingsSelected: '{{selected}} of {{total}} selected',
+    designSettingsPrinterCoupled: 'printer-specific',
+    designSettingsPrinterCoupledHint: 'Tuned for the printer this file was designed for — may be wrong or out of range on yours.',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',

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

@@ -4075,6 +4075,11 @@ export default {
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     useEmbedded: 'Usar la configuración incorporada del archivo',
     useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
+    designSettings: 'Mantener los ajustes del diseñador',
+    designSettingsHint: 'Este archivo cambia {{count}} ajuste(s) de impresión respecto al perfil estándar.',
+    designSettingsSelected: '{{selected}} de {{total}} seleccionados',
+    designSettingsPrinterCoupled: 'específico de la impresora',
+    designSettingsPrinterCoupledHint: 'Ajustado para la impresora para la que se diseñó el archivo: puede ser incorrecto o quedar fuera de rango en la tuya.',
     enqueuing: 'Enviando el trabajo de laminado…',
     queued: 'En cola…',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',

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

@@ -4061,6 +4061,11 @@ export default {
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     useEmbedded: 'Utiliser les réglages intégrés du fichier',
     useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
+    designSettings: 'Conserver les réglages du concepteur',
+    designSettingsHint: 'Ce fichier modifie {{count}} réglage(s) d\'impression par rapport au profil standard.',
+    designSettingsSelected: '{{selected}} sur {{total}} sélectionnés',
+    designSettingsPrinterCoupled: 'spécifique à l\'imprimante',
+    designSettingsPrinterCoupledHint: 'Réglé pour l\'imprimante visée par le fichier — peut être incorrect ou hors plage sur la vôtre.',
     enqueuing: 'Envoi du travail de découpage…',
     queued: 'En file d\'attente…',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',

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

@@ -4060,6 +4060,11 @@ export default {
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     useEmbedded: 'Usa le impostazioni integrate del file',
     useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
+    designSettings: 'Mantieni le impostazioni del progettista',
+    designSettingsHint: 'Questo file modifica {{count}} impostazione/i di stampa rispetto al profilo standard.',
+    designSettingsSelected: '{{selected}} di {{total}} selezionate',
+    designSettingsPrinterCoupled: 'specifico della stampante',
+    designSettingsPrinterCoupledHint: 'Tarato per la stampante per cui è stato progettato il file: sulla tua può essere errato o fuori intervallo.',
     enqueuing: 'Invio lavoro di slicing…',
     queued: 'In coda…',
     failed: 'Slicing fallito. Controlla i log del sidecar.',

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

@@ -4072,6 +4072,11 @@ export default {
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     useEmbedded: 'ファイルに埋め込まれた設定を使用',
     useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
+    designSettings: '設計者の設定を保持',
+    designSettingsHint: 'このファイルは標準プロファイルから {{count}} 個の印刷設定を変更しています。',
+    designSettingsSelected: '{{total}} 個中 {{selected}} 個を選択',
+    designSettingsPrinterCoupled: 'プリンター固有',
+    designSettingsPrinterCoupledHint: 'このファイルが対象とするプリンター向けに調整された値です。お使いのプリンターでは不適切または範囲外になる場合があります。',
     enqueuing: 'スライスジョブを送信中…',
     queued: '待機中…',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',

+ 5 - 0
frontend/src/i18n/locales/ko.ts

@@ -3861,6 +3861,11 @@ export default {
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
     useEmbedded: '파일에 포함된 설정 사용',
     useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
+    designSettings: '디자이너 설정 유지',
+    designSettingsHint: '이 파일은 기본 프로파일에서 {{count}}개의 출력 설정을 변경합니다.',
+    designSettingsSelected: '{{total}}개 중 {{selected}}개 선택됨',
+    designSettingsPrinterCoupled: '프린터 전용',
+    designSettingsPrinterCoupledHint: '이 파일이 대상으로 한 프린터에 맞춘 값입니다. 사용 중인 프린터에서는 잘못되었거나 범위를 벗어날 수 있습니다.',
     enqueuing: '슬라이싱 작업 제출 중…',
     queued: '대기 중…',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',

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

@@ -4060,6 +4060,11 @@ export default {
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     useEmbedded: 'Usar as configurações incorporadas do arquivo',
     useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
+    designSettings: 'Manter as configurações do designer',
+    designSettingsHint: 'Este arquivo altera {{count}} configuração(ões) de impressão em relação ao perfil padrão.',
+    designSettingsSelected: '{{selected}} de {{total}} selecionadas',
+    designSettingsPrinterCoupled: 'específico da impressora',
+    designSettingsPrinterCoupledHint: 'Ajustado para a impressora para a qual o arquivo foi projetado — pode estar incorreto ou fora de faixa na sua.',
     enqueuing: 'Enviando trabalho de fatiamento…',
     queued: 'Na fila…',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',

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

@@ -3857,6 +3857,11 @@ export default {
     allPresetsRequired: "Необходимо выбрать все профили",
     useEmbedded: "Использовать встроенные настройки файла",
     useEmbeddedHint: "Нарезать так, как задумал автор модели (стенки, заполнение, филамент), вместо профилей выше. Доступно, потому что ваш принтер совпадает с указанным в файле.",
+    designSettings: "Сохранить настройки автора",
+    designSettingsHint: "Этот файл меняет {{count}} настроек печати по сравнению со стандартным профилем.",
+    designSettingsSelected: "Выбрано {{selected}} из {{total}}",
+    designSettingsPrinterCoupled: "зависит от принтера",
+    designSettingsPrinterCoupledHint: "Значение подобрано для принтера, под который создан файл, — на вашем оно может быть неверным или вне допустимого диапазона.",
     enqueuing: "Отправка задания на нарезку…",
     queued: "В очереди…",
     failed: "Ошибка нарезки. Проверьте журналы вспомогательного сервиса слайсера.",

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

@@ -4062,6 +4062,11 @@ export default {
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
     useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
     useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
+    designSettings: 'Tasarımcının ayarlarını koru',
+    designSettingsHint: 'Bu dosya standart profile göre {{count}} baskı ayarını değiştiriyor.',
+    designSettingsSelected: '{{total}} ayardan {{selected}} tanesi seçili',
+    designSettingsPrinterCoupled: 'yazıcıya özel',
+    designSettingsPrinterCoupledHint: 'Dosyanın tasarlandığı yazıcıya göre ayarlanmış — sizinkinde yanlış veya aralık dışı olabilir.',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     queued: 'Kuyrukta…',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',

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

@@ -4060,6 +4060,11 @@ export default {
     allPresetsRequired: '必须选择所有预设',
     useEmbedded: '使用文件的内置设置',
     useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
+    designSettings: '保留设计者的设置',
+    designSettingsHint: '此文件相对标准配置修改了 {{count}} 项打印设置。',
+    designSettingsSelected: '已选择 {{selected}} / {{total}}',
+    designSettingsPrinterCoupled: '与打印机相关',
+    designSettingsPrinterCoupledHint: '该值是为此文件面向的打印机调校的,在你的打印机上可能不正确或超出范围。',
     enqueuing: '提交切片任务中…',
     queued: '已排队…',
     failed: '切片失败。请检查切片器 sidecar 日志。',

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

@@ -4060,6 +4060,11 @@ export default {
     allPresetsRequired: '必須選擇所有預設',
     useEmbedded: '使用檔案的內建設定',
     useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
+    designSettings: '保留設計者的設定',
+    designSettingsHint: '此檔案相對標準設定檔修改了 {{count}} 項列印設定。',
+    designSettingsSelected: '已選擇 {{selected}} / {{total}}',
+    designSettingsPrinterCoupled: '與印表機相關',
+    designSettingsPrinterCoupledHint: '此值是為該檔案面向的印表機調校的,在你的印表機上可能不正確或超出範圍。',
     enqueuing: '提交切片任務中…',
     queued: '已排隊…',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',

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

@@ -38,6 +38,21 @@ export interface PlateMetadata {
 interface EmbeddedPresets {
   embedded_printer?: string | null;
   embedded_process?: string | null;
+  // Process settings the designer changed away from the stock preset, read
+  // from the 3MF's own `different_settings_to_system` (#2622). Offered in the
+  // SliceModal so a re-slice for another printer can carry them instead of
+  // losing them to the picked process profile. Empty for STL, OrcaSlicer
+  // files, and older exports that predate the field.
+  design_overrides?: DesignOverride[];
+}
+
+// One process setting the designer deviated on. `printer_coupled` marks the
+// values that only make sense on the machine they were tuned for (speeds,
+// accelerations, prime-tower geometry) — offered, but never pre-selected.
+export interface DesignOverride {
+  key: string;
+  value: unknown;
+  printer_coupled: boolean;
 }
 
 export interface ArchivePlatesResponse extends EmbeddedPresets {

Plik diff jest za duży
+ 0 - 0
static/assets/index-D5mCVall.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-iZ6BPzxV.js"></script>
+    <script type="module" crossorigin src="/assets/index-D5mCVall.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików