Browse Source

fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat

  Five stacked slice-pipeline bugs that each made the modal's profile picker
  theatrical for 3MF inputs:

  (1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
      `slice_info.config` / `cut_information.xml` along with
      `project_settings.config`. The CLI silently exited after
      "Initializing StaticPrintConfigs" — exit 0, no result.json — and
      Bambuddy masked the failure by re-running with embedded settings
      and the source's bound printer. Strip removed from the dispatch
      path entirely.

  (2) Standard-tier preset stubs lacked the `type` field, so the CLI
      rejected `--load-settings` with rc=-5 ("input preset file is
      invalid") and the same masking fallback fired. Added
      `_SLOT_TO_PROFILE_TYPE` so each stub carries the right
      machine/process/filament discriminator.

  (3) Sliced-archive cards listed every project-wide AMS slot (16+
      swatches for a 2-color print). `slice_and_persist_as_archive` now
      reads `filament_type` / `filament_color` from the sliced output's
      `slice_info.config` (which `ThreeMFParser` already gates on
      `used_g > 0`) instead of inheriting from the source archive.

  (4) SliceModal had no warning when the picked printer profile didn't
      match the source 3MF — the CLI rejects cross-printer slices
      (rc=-16) and fell back to embedded settings, producing wrong-printer
      g-code that errored at print dispatch. Plates response now exposes
      `source_printer_model`; the modal compares against the picked
      profile name and disables Slice + shows an inline warning on
      mismatch.

  (5) MakerWorld URL-paste resolver listed plate instances without
      showing which printer each was sliced for (`/instances/hits`
      omits compatibility info that lives on `design.instances[]
      .extention.modelInfo`). The resolve route now joins both payloads
      by instance ID and forwards `compatibility` + `otherCompatibility`
      onto each hit; the MakerWorld page renders "Sliced for {primary}"
      + "Also marked compatible: ..." per row.

  Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
  filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
  compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
  tests for the printer-mismatch warning + Slice-disabled gate. New i18n
  keys `slice.printerMismatch`, `makerworld.slicedFor`,
  `makerworld.alsoCompatible` across all 8 locales.
maziggy 4 weeks ago
parent
commit
c1f69ee0cc

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


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

@@ -31,6 +31,7 @@ from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
     extract_plate_extruder_set_from_3mf,
     extract_project_filaments_from_3mf,
+    extract_source_printer_model_from_3mf,
 )
 
 logger = logging.getLogger(__name__)
@@ -3072,12 +3073,20 @@ async def get_archive_plates(
     # to preview gcode — the viewer, skip-objects — can gate on this instead of
     # 404-ing on every plate request.
     has_gcode = bool(gcode_files)
+    # SliceModal pre-check signal — see library.py for rationale.
+    source_printer_model: str | None = None
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            source_printer_model = extract_source_printer_model_from_3mf(zf)
+    except (zipfile.BadZipFile, OSError):
+        pass
     return {
         "archive_id": archive_id,
         "filename": archive.filename,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
         "has_gcode": has_gcode,
+        "source_printer_model": source_printer_model,
     }
 
 

+ 28 - 5
backend/app/api/routes/library.py

@@ -65,6 +65,7 @@ from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
     extract_plate_extruder_set_from_3mf,
     extract_project_filaments_from_3mf,
+    extract_source_printer_model_from_3mf,
 )
 
 logger = logging.getLogger(__name__)
@@ -2302,11 +2303,22 @@ async def get_library_file_plates(
     except Exception as e:
         logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
 
+    # SliceModal pre-check signal: the source 3MF's bound printer model. The
+    # CLI cannot re-slice for a different printer; surface this so the modal
+    # can warn the user before they pick a mismatched profile.
+    source_printer_model: str | None = None
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            source_printer_model = extract_source_printer_model_from_3mf(zf)
+    except (zipfile.BadZipFile, OSError):
+        pass
+
     return {
         "file_id": file_id,
         "filename": lib_file.filename,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
+        "source_printer_model": source_printer_model,
     }
 
 
@@ -2671,13 +2683,24 @@ async def _run_slicer_with_fallback(
             detail=f"Unknown preferred_slicer setting: '{preferred}'. Expected 'orcaslicer' or 'bambu_studio'.",
         )
 
+    # Note: an earlier version of this code stripped Metadata/project_settings.
+    # config + model_settings.config + slice_info.config + cut_information.xml
+    # before forwarding the 3MF, the theory being that --load-settings would
+    # then take precedence cleanly. That theory was wrong: model_settings.
+    # config carries the plate definitions the CLI needs to map `--slice N`
+    # to a real plate, and slice_info / project_settings supply baseline
+    # config the CLI's StaticPrintConfig pass needs at all. Stripping ANY
+    # of them caused the CLI to silently exit immediately after
+    # "Initializing StaticPrintConfigs" — exit code 0, no result.json, no
+    # stderr — which Node's child_process treated as failure and Bambuddy
+    # then masked by falling back to slice_without_profiles using the
+    # un-stripped bytes (and the source's embedded printer). Net effect:
+    # every 3MF slice with profiles silently produced wrong-printer output.
+    # Forwarding the original bytes lets --load-settings override the
+    # specific fields the user changed (printer/process/filament) while
+    # the embedded plate / model definitions remain intact.
     is_3mf = model_filename.lower().endswith(".3mf")
     primary_bytes = model_bytes
-    if is_3mf:
-        try:
-            primary_bytes = _strip_3mf_embedded_settings(model_bytes)
-        except (zipfile.BadZipFile, KeyError) as exc:
-            raise HTTPException(status_code=400, detail=f"Source 3MF is corrupt: {exc}") from exc
 
     used_embedded_settings = False
     service = SlicerApiService(api_url)

+ 30 - 0
backend/app/api/routes/makerworld.py

@@ -182,6 +182,36 @@ async def resolve_url(
     if not isinstance(instances, list):
         instances = []
 
+    # /instances/hits omits the per-instance printer compatibility info that
+    # /design.instances[].extention.modelInfo carries (compatibility +
+    # otherCompatibility). Merge it in so the frontend can show "this
+    # instance was sliced for A1" + "also marked compatible with: H2D, P1S,
+    # …" before the user picks one — without that, every instance row looks
+    # identical in the UI and users blindly pick the first one regardless of
+    # whether it matches their printer.
+    design_instances = design.get("instances") or []
+    if isinstance(design_instances, list):
+        compat_by_id = {}
+        for di in design_instances:
+            if not isinstance(di, dict):
+                continue
+            iid = di.get("id")
+            if iid is None:
+                continue
+            ext = (di.get("extention") or {}).get("modelInfo") or {}
+            compat_by_id[iid] = {
+                "compatibility": ext.get("compatibility"),
+                "otherCompatibility": ext.get("otherCompatibility"),
+            }
+        for inst in instances:
+            if not isinstance(inst, dict):
+                continue
+            iid = inst.get("id")
+            extra = compat_by_id.get(iid)
+            if extra:
+                inst["compatibility"] = extra["compatibility"]
+                inst["otherCompatibility"] = extra["otherCompatibility"]
+
     # Find every library row whose source_url is either the model-level
     # canonical URL (legacy whole-model imports) or any plate-level URL
     # (``...#profileId-{n}``) under this model. The frontend surfaces this

+ 21 - 2
backend/app/services/preset_resolver.py

@@ -49,6 +49,22 @@ _SLOT_TO_BUNDLED_CATEGORY = {
     "filament": "filament",
 }
 
+# The CLI's --load-settings parser uses the JSON's `type` field to decide
+# how to interpret each file (machine/process/filament). Without it the
+# CLI logs `operator(): unknown config type ... in load-settings`,
+# writes `error_string: "The input preset file is invalid and can not be
+# parsed.", return_code: -5` to result.json, and exits 0 — which the
+# Node sidecar's child_process treats as silent success producing no
+# output, then bubbles up as a generic "Failed to slice the model" 5xx.
+# Bambuddy then falls back to the embedded-settings path for every 3MF
+# slice, silently using whatever printer the source file was originally
+# bound to. Setting `type` correctly per slot fixes the silent fallback.
+_SLOT_TO_PROFILE_TYPE = {
+    "printer": "machine",
+    "process": "process",
+    "filament": "filament",
+}
+
 
 async def resolve_preset_ref(
     db: AsyncSession,
@@ -148,8 +164,8 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl
 
 
 def _resolve_standard(ref: PresetRef, slot: str) -> str:
-    """Build a minimal `{inherits: <name>}` stub. The sidecar's resolver
-    walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
+    """Build a minimal `{name, inherits, from, type}` stub. The sidecar's
+    resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
     yielding the full bundled preset without us round-tripping the content
     through Bambuddy."""
     if slot not in _SLOT_TO_BUNDLED_CATEGORY:
@@ -165,5 +181,8 @@ def _resolve_standard(ref: PresetRef, slot: str) -> str:
             # the resolver was designed to fix for OrcaSlicer GUI exports —
             # we never want a bundled preset to be treated as User-authored.
             "from": "system",
+            # `type` is required by the CLI's --load-settings parser — see
+            # _SLOT_TO_PROFILE_TYPE above for the silent-failure mode.
+            "type": _SLOT_TO_PROFILE_TYPE[slot],
         }
     )

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

@@ -609,6 +609,38 @@ def inject_gcode_into_3mf(
         return None
 
 
+def extract_source_printer_model_from_3mf(zf: zipfile.ZipFile) -> str | None:
+    """Source 3MF's bound printer model from ``Metadata/project_settings.config``.
+
+    Returns e.g. ``"Bambu Lab A1"`` when the project was built for an A1, or
+    ``None`` when the file lacks the metadata or the field is absent. The
+    SliceModal uses this to warn the user before slicing if the chosen
+    printer profile targets a different model — the slicer CLI rejects
+    cross-printer slicing with rc=-16 and the result, when the strip + load
+    fallback masks it, is a misleadingly-tagged archive.
+    """
+    if "Metadata/project_settings.config" not in zf.namelist():
+        return None
+    try:
+        proj = json.loads(zf.read("Metadata/project_settings.config").decode())
+    except (ValueError, OSError):
+        return None
+    if not isinstance(proj, dict):
+        return None
+    model = proj.get("printer_model")
+    if isinstance(model, str) and model.strip():
+        return model.strip()
+    # Some older Bambu Studio exports stored the model under
+    # ``printer_settings_id`` (e.g. "Bambu Lab A1 0.4 nozzle"); strip the
+    # nozzle suffix to get the canonical model name. Best-effort — if the
+    # field doesn't follow the convention we leave it as-is.
+    settings_id = proj.get("printer_settings_id")
+    if isinstance(settings_id, str) and settings_id.strip():
+        # Drop trailing " 0.4 nozzle" / " 0.2 nozzle" / etc.
+        return re.sub(r"\s+0\.\d+\s+nozzle$", "", settings_id.strip())
+    return None
+
+
 def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     """Project-wide AMS slot config from ``Metadata/project_settings.config``.
 

+ 16 - 10
backend/tests/integration/test_library_slice_api.py

@@ -389,9 +389,17 @@ class TestSliceLibraryFile:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_3mf_input_strips_embedded_settings_before_forwarding(
+    async def test_3mf_input_forwarded_unmodified_to_sidecar(
         self, async_client: AsyncClient, db_session, slice_test_setup
     ):
+        # 3MF input must be forwarded to the sidecar verbatim — every
+        # Metadata/*.config the source carries (project_settings,
+        # model_settings, slice_info, cut_information) is needed by the
+        # CLI to find plate definitions and baseline config; an earlier
+        # version of this code stripped them and caused the CLI to
+        # silently exit immediately after "Initializing StaticPrintConfigs"
+        # for every 3MF slice. --load-settings overrides the specific
+        # fields the user changed; the rest comes from the embedded data.
         src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
         src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
         threemf = LibraryFile(
@@ -431,20 +439,18 @@ class TestSliceLibraryFile:
         final = await _wait_for_job(async_client, response.json()["job_id"])
         assert final["status"] == "completed", final
 
-        # Recover the embedded zip from the multipart body — the strip
-        # must remove every config that references the original slice's
-        # printer / filament IDs (otherwise the CLI's input validation
-        # rejects the new --load-settings triplet, the slice fails, and
-        # we drop into the embedded-settings fallback).  Geometry stays.
+        # Recover the embedded zip from the multipart body and assert ALL
+        # the source's Metadata/*.config files are still present — the
+        # opposite of the previous (broken) "strip everything" test.
         body = captured["body"]
         pk = body.find(b"PK\x03\x04")
         assert pk >= 0, "3MF body not found in multipart payload"
         with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
             names = set(zin.namelist())
-        assert "Metadata/project_settings.config" not in names
-        assert "Metadata/model_settings.config" not in names
-        assert "Metadata/slice_info.config" not in names
-        assert "Metadata/cut_information.xml" not in names
+        assert "Metadata/project_settings.config" in names
+        assert "Metadata/model_settings.config" in names
+        assert "Metadata/slice_info.config" in names
+        assert "Metadata/cut_information.xml" in names
         assert "3D/3dmodel.model" in names
 
 

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

@@ -113,6 +113,51 @@ class TestThreeMFParserErrorVisibility:
         # No assertions about which keys are present — just that it didn't blow up.
         assert isinstance(result, dict)
 
+    def test_filament_metadata_only_includes_filaments_with_used_g(
+        self,
+        tmp_path: Path,
+    ) -> None:
+        """slice_and_persist_as_archive uses parsed_metadata.filament_type/color
+        to populate the new archive's filament list. The parser must filter
+        out filaments whose used_g==0 — otherwise the resulting archive card
+        shows every project-wide AMS slot (16+ swatches) for what was
+        actually a 2-color print on a single plate.
+        """
+        p = tmp_path / "two-of-eighteen.3mf"
+        with zipfile.ZipFile(p, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            # 4 declared slots, only 2 actually consumed on this plate.
+            zf.writestr(
+                "Metadata/slice_info.config",
+                """<?xml version="1.0"?>
+                <config>
+                  <plate>
+                    <metadata key="index" value="1"/>
+                    <filament id="1" type="PLA"  color="#FFFFFF" used_g="25.0" used_m="8.5"/>
+                    <filament id="2" type="PETG" color="#FF0000" used_g="0"    used_m="0"/>
+                    <filament id="3" type="PLA"  color="#000000" used_g="12.5" used_m="4.2"/>
+                    <filament id="4" type="ABS"  color="#00FF00" used_g="0"    used_m="0"/>
+                  </plate>
+                </config>""",
+            )
+
+        result = ThreeMFParser(p).parse()
+
+        # Both fields should be comma-joined strings of only the consumed
+        # filaments — slot 2 (PETG #FF0000) and slot 4 (ABS #00FF00) must
+        # not appear on the new archive card. The parser dedupes types,
+        # so both PLA slots collapse into a single "PLA" entry; colors
+        # are unique per swatch and stay distinct.
+        types = result.get("filament_type", "")
+        assert "PLA" in types
+        assert "PETG" not in types  # used_g=0 → excluded
+        assert "ABS" not in types
+        colors = result.get("filament_color", "")
+        assert "#FFFFFF" in colors
+        assert "#000000" in colors
+        assert "#FF0000" not in colors
+        assert "#00FF00" not in colors
+
 
 class TestZipFileSentinel:
     """Sanity check the sentinel the archive pipeline relies on."""

+ 17 - 0
backend/tests/unit/services/test_preset_resolver.py

@@ -30,9 +30,26 @@ def test_standard_emits_inherits_stub():
         # treat this as a User-authored profile and reject it against
         # system filament/process pairs.
         "from": "system",
+        # `type` is required by the CLI's --load-settings parser. Without
+        # it the CLI silently exits with rc=-5 ("input preset file is
+        # invalid"), causing every 3MF slice to fall back to embedded
+        # settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
+        "type": "machine",
     }
 
 
+def test_standard_emits_correct_type_per_slot():
+    """Each slot maps to the right `type` value the CLI parser expects:
+    printer → machine, process → process, filament → filament. Missing or
+    wrong type causes the CLI to silently exit with rc=-5."""
+    for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
+        out = preset_resolver._resolve_standard(
+            PresetRef(source="standard", id="anything"),
+            slot=slot,
+        )
+        assert json.loads(out)["type"] == expected_type, slot
+
+
 def test_standard_rejects_unknown_slot():
     with pytest.raises(HTTPException) as exc:
         preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")

+ 86 - 0
backend/tests/unit/test_makerworld_routes.py

@@ -136,6 +136,92 @@ class TestResolve:
         assert resp.status_code == 200, resp.text
         assert resp.json()["already_imported_library_ids"] == [existing.id]
 
+    @pytest.mark.asyncio
+    async def test_merges_compatibility_from_design_into_instances(self, async_client):
+        """Per-instance printer compatibility info lives on
+        ``design.instances[].extention.modelInfo`` but not on
+        ``/instances/hits``. Resolve enriches each hit with both
+        ``compatibility`` (primary printer the instance was sliced for) and
+        ``otherCompatibility`` (extra printers the uploader marked it
+        compatible with) so the frontend can show "sliced for A1 / also
+        marked compatible with: H2D, P1S".
+        """
+        design_payload = {
+            "id": 1400373,
+            "title": "Seed Starter",
+            "instances": [
+                {
+                    "id": 1452154,
+                    "extention": {
+                        "modelInfo": {
+                            "compatibility": ["A1"],
+                            "otherCompatibility": ["H2D", "P1S"],
+                        }
+                    },
+                },
+                {
+                    "id": 1452158,
+                    "extention": {
+                        "modelInfo": {
+                            "compatibility": ["X1 Carbon"],
+                            "otherCompatibility": [],
+                        }
+                    },
+                },
+            ],
+        }
+        instances_payload = {
+            "total": 2,
+            "hits": [
+                {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
+                {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
+            ],
+        }
+        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+            )
+        assert resp.status_code == 200, resp.text
+        instances = resp.json()["instances"]
+        by_id = {i["id"]: i for i in instances}
+        assert by_id[1452154]["compatibility"] == ["A1"]
+        assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
+        assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
+        assert by_id[1452158]["otherCompatibility"] == []
+
+    @pytest.mark.asyncio
+    async def test_resolve_handles_missing_compatibility_gracefully(self, async_client):
+        """Older designs (or hits without a matching design.instances entry)
+        must not crash the resolve response — they just don't get the
+        compat fields."""
+        design_payload = {"id": 1400373, "instances": [{"id": 1452154}]}  # no extention
+        instances_payload = {
+            "total": 2,
+            "hits": [
+                {"id": 1452154, "profileId": 298919107},
+                {"id": 9999999, "profileId": 298919999},  # no design.instances match
+            ],
+        }
+        svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+            )
+        assert resp.status_code == 200, resp.text
+        instances = resp.json()["instances"]
+        # First instance: design entry exists but no extention → fields absent or None.
+        first = next(i for i in instances if i["id"] == 1452154)
+        assert first.get("compatibility") is None
+        assert first.get("otherCompatibility") is None
+        # Second instance: no design entry at all → no enrichment, no crash.
+        second = next(i for i in instances if i["id"] == 9999999)
+        assert "compatibility" not in second or second["compatibility"] is None
+
 
 class TestImport:
     """End-to-end of POST /makerworld/import — mocks the service but exercises

+ 41 - 0
backend/tests/unit/test_threemf_tools.py

@@ -13,6 +13,7 @@ from backend.app.utils.threemf_tools import (
     extract_filament_usage_from_3mf,
     extract_plate_extruder_set_from_3mf,
     extract_project_filaments_from_3mf,
+    extract_source_printer_model_from_3mf,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
@@ -645,3 +646,43 @@ class TestExtractPlateExtruderSetFrom3mf:
             # Top-level metadata still works; missing component model file
             # is silently skipped without crashing.
             assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
+
+
+# ---------------------------------------------------------------------------
+# Tests for extract_source_printer_model_from_3mf — feeds the SliceModal's
+# pre-slice mismatch warning. The CLI cannot re-slice a 3MF for a different
+# printer, so warning the user up-front avoids producing wrong-printer output.
+# ---------------------------------------------------------------------------
+
+
+class TestExtractSourcePrinterModelFrom3mf:
+    def test_returns_none_when_project_settings_missing(self):
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) is None
+
+    def test_reads_printer_model_directly(self):
+        proj = {"printer_model": "Bambu Lab H2D"}
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab H2D"
+
+    def test_falls_back_to_printer_settings_id_with_nozzle_strip(self):
+        # Older Bambu Studio exports stored the printer under
+        # printer_settings_id, often with a "0.4 nozzle" suffix the helper
+        # must strip to match the canonical model name.
+        proj = {"printer_settings_id": "Bambu Lab A1 0.4 nozzle"}
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab A1"
+
+    def test_settings_id_without_nozzle_suffix_returned_as_is(self):
+        proj = {"printer_settings_id": "Bambu Lab P1S"}
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab P1S"
+
+    def test_corrupt_json_returns_none_no_exception(self):
+        with _make_3mf_with({"Metadata/project_settings.config": b"{broken"}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) is None
+
+    def test_empty_string_treated_as_missing(self):
+        proj = {"printer_model": "", "printer_settings_id": ""}
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            assert extract_source_printer_model_from_3mf(zf) is None

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

@@ -691,4 +691,132 @@ describe('SliceModal', () => {
       expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' });
     });
   });
+
+  // Pre-slice printer-mismatch warning. The slicer CLI cannot re-slice a
+  // 3MF for a different printer model — clicking Slice in that state
+  // would silently fall back to the embedded settings and produce a
+  // wrong-printer file. The modal surfaces a warning and disables Slice
+  // when the source's source_printer_model doesn't match the picked
+  // printer profile.
+  it('shows a printer-mismatch warning and disables Slice when models differ', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'A1Original.3mf',
+      is_multi_plate: false,
+      source_printer_model: 'A1',
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: [],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: null,
+          filament_used_grams: null,
+          filaments: [],
+        },
+      ],
+    });
+    // Standard tier offers an X1C profile — the user picks (auto-picks) it.
+    mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
+      standard: {
+        printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
+        process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
+        filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
+      },
+    }));
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'A1Original.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() =>
+      expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
+    );
+
+    // Warning banner is visible (role=alert) and references both models.
+    const alert = await screen.findByRole('alert');
+    expect(alert.textContent).toMatch(/A1/);
+    expect(alert.textContent).toMatch(/X1 Carbon/);
+
+    // Slice button is disabled while the warning is up.
+    const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
+    expect(sliceButton.disabled).toBe(true);
+  });
+
+  it('keeps Slice enabled when the picked profile matches the source printer model', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'X1COriginal.3mf',
+      is_multi_plate: false,
+      source_printer_model: 'X1 Carbon',
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: [],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: null,
+          filament_used_grams: null,
+          filaments: [],
+        },
+      ],
+    });
+    mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
+      standard: {
+        printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
+        process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
+        filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
+      },
+    }));
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'X1COriginal.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() =>
+      expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
+    );
+
+    // No mismatch warning.
+    expect(screen.queryByRole('alert')).toBeNull();
+    const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
+    expect(sliceButton.disabled).toBe(false);
+  });
+
+  it('keeps Slice enabled when source_printer_model is unknown (legacy archives)', async () => {
+    // Older 3MFs without project_settings.printer_model fall through to
+    // no-warning — we don't have enough info to gate the user.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Legacy.3mf',
+      is_multi_plate: false,
+      source_printer_model: null,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: [],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: null,
+          filament_used_grams: null,
+          filaments: [],
+        },
+      ],
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Legacy.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+    expect(screen.queryByRole('alert')).toBeNull();
+    const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
+    expect(sliceButton.disabled).toBe(false);
+  });
 });

+ 85 - 30
frontend/src/components/SliceModal.tsx

@@ -241,11 +241,38 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     },
   });
 
+  // Pre-slice compatibility check: the slicer CLI (both OrcaSlicer and
+  // BambuStudio) cannot re-slice a 3MF for a printer different from the one
+  // it was originally bound to — the cross-printer "convert project" flow
+  // is desktop-Studio only. If we can match the source's printer model to a
+  // SliceModal-known model and the user's chosen printer profile names a
+  // different model, surface a warning before they click Slice.
+  const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
+  const printerProfileName = printerPreset
+    ? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
+    : null;
+  // Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
+  // nozzle"). The CLI compat check uses the model prefix; substring match
+  // catches both standard and locally-imported user-named profiles that
+  // include the model in the name. Cloud presets with arbitrary names
+  // (e.g. "My Custom X1C") fall through to no-warning, which is a
+  // reasonable default — the user picked it knowingly.
+  const printerMismatch =
+    !!sourcePrinterModel &&
+    !!printerProfileName &&
+    !printerProfileName.toLowerCase().includes(sourcePrinterModel.toLowerCase());
+
+  // Slice button stays disabled while the printer mismatch warning is
+  // visible: clicking it would silently fall back to embedded settings and
+  // produce a wrong-printer file, the exact UX bug the warning is here to
+  // prevent. Only re-enable when the user picks a matching profile (or
+  // cloud preset whose name we can't parse).
   const isReady =
     printerPreset != null &&
     processPreset != null &&
     filamentPresets.length > 0 &&
-    filamentPresets.every((r) => r != null);
+    filamentPresets.every((r) => r != null) &&
+    !printerMismatch;
   const isEnqueuing = enqueueMutation.isPending;
 
   // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
@@ -301,7 +328,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
 
         {/* Body */}
         <div className="flex-1 overflow-y-auto p-4 space-y-4">
-          {(platesQuery.isLoading || presetsQuery.isLoading || filamentReqsQuery.isLoading) && (
+          {/* Preset listing loader — printer/process dropdowns can't render
+              without it. Plate query reuses the same spinner since it's
+              also blocking. */}
+          {(platesQuery.isLoading || presetsQuery.isLoading) && (
             <div className="flex items-center gap-2 text-bambu-gray text-sm">
               <Loader2 className="w-4 h-4 animate-spin" />
               {t('slice.loadingPresets', 'Loading presets…')}
@@ -336,37 +366,62 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 onChange={setProcessPreset}
                 disabled={isEnqueuing}
               />
-              {filamentSlots.map((slot, idx) => (
-                <PresetDropdown
-                  key={`filament-${idx}`}
-                  label={
-                    filamentSlots.length > 1
-                      ? t('slice.filamentSlot', {
-                          index: idx + 1,
-                          type: slot.type,
-                          defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
-                        })
-                      : t('slice.filament', 'Filament profile')
-                  }
-                  slot="filament"
-                  data={presetsQuery.data}
-                  value={filamentPresets[idx] ?? null}
-                  onChange={(ref) =>
-                    setFilamentPresets((current) => {
-                      const next = current.length === filamentSlots.length
-                        ? [...current]
-                        : filamentSlots.map((_, i) => current[i] ?? null);
-                      next[idx] = ref;
-                      return next;
-                    })
-                  }
-                  disabled={isEnqueuing}
-                  swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
-                />
-              ))}
+              {/* Filament reqs may need a server-side preview-slice for
+                  unsliced project files (single-pass, then cached). Show a
+                  scoped spinner so the user sees the printer/process
+                  dropdowns instead of an opaque "Loading presets…" wait. */}
+              {filamentReqsQuery.isLoading ? (
+                <div className="flex items-center gap-2 text-bambu-gray text-sm py-2">
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…')}
+                </div>
+              ) : (
+                filamentSlots.map((slot, idx) => (
+                  <PresetDropdown
+                    key={`filament-${idx}`}
+                    label={
+                      filamentSlots.length > 1
+                        ? t('slice.filamentSlot', {
+                            index: idx + 1,
+                            type: slot.type,
+                            defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
+                          })
+                        : t('slice.filament', 'Filament profile')
+                    }
+                    slot="filament"
+                    data={presetsQuery.data}
+                    value={filamentPresets[idx] ?? null}
+                    onChange={(ref) =>
+                      setFilamentPresets((current) => {
+                        const next = current.length === filamentSlots.length
+                          ? [...current]
+                          : filamentSlots.map((_, i) => current[i] ?? null);
+                        next[idx] = ref;
+                        return next;
+                      })
+                    }
+                    disabled={isEnqueuing}
+                    swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                  />
+                ))
+              )}
             </>
           )}
 
+          {printerMismatch && (
+            <div
+              className="text-sm text-amber-200 bg-amber-900/20 border border-amber-700/40 rounded p-2"
+              role="alert"
+            >
+              {t('slice.printerMismatch', {
+                source: sourcePrinterModel,
+                target: printerProfileName,
+                defaultValue:
+                  'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
+              })}
+            </div>
+          )}
+
           {errorMessage && (
             <div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
               {errorMessage}

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

@@ -3253,6 +3253,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
+    analyzingPlateFilaments: 'Plattenfilamente werden analysiert…',
+    printerMismatch: 'Dieses 3MF wurde für {{source}} gesliced, du hast aber {{target}} ausgewählt. Der Slicer-CLI kann ein 3MF nicht für einen anderen Drucker neu slicen — öffne die Quelle in Bambu Studio, ändere den Drucker und exportiere neu.',
     noPresetsForSlot: 'Keine Profile verfügbar',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
@@ -5189,6 +5191,8 @@ export default {
     plateDefaultName: 'Platte {{n}}',
     materialCount: '{{count}} Filamente',
     amsRequired: 'AMS erforderlich',
+    slicedFor: 'Gesliced für {{printer}}',
+    alsoCompatible: 'Auch kompatibel: {{printers}}',
     importToLibrary: 'Speichern',
     sliceIn: 'Speichern & in {{slicer}} öffnen',
     disclaimer: 'Die MakerWorld-Integration verwendet von der Community dokumentierte API-Endpunkte. Bambuddy ist nicht mit MakerWorld oder Bambu Lab verbunden oder von diesen unterstützt.',

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

@@ -3256,6 +3256,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5198,6 +5200,8 @@ export default {
     plateDefaultName: 'Plate {{n}}',
     materialCount: '{{count}} filaments',
     amsRequired: 'AMS required',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: 'Save',
     sliceIn: 'Save & Slice in {{slicer}}',
     disclaimer: 'MakerWorld integration uses community-documented API endpoints. Bambuddy is not affiliated with or endorsed by MakerWorld or Bambu Lab.',

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

@@ -3175,6 +3175,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5101,6 +5103,8 @@ export default {
     plateDefaultName: 'Plateau {{n}}',
     materialCount: '{{count}} filaments',
     amsRequired: 'AMS requis',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: 'Enregistrer',
     sliceIn: 'Enregistrer et découper dans {{slicer}}',
     disclaimer: 'L\'intégration MakerWorld utilise des points de terminaison API documentés par la communauté. Bambuddy n\'est ni affilié ni approuvé par MakerWorld ou Bambu Lab.',

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

@@ -3174,6 +3174,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5100,6 +5102,8 @@ export default {
     plateDefaultName: 'Piatto {{n}}',
     materialCount: '{{count}} filamenti',
     amsRequired: 'AMS richiesto',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: 'Salva',
     sliceIn: 'Salva e affetta in {{slicer}}',
     disclaimer: 'L\'integrazione MakerWorld utilizza endpoint API documentati dalla community. Bambuddy non è affiliato né approvato da MakerWorld o Bambu Lab.',

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

@@ -3213,6 +3213,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5139,6 +5141,8 @@ export default {
     plateDefaultName: 'プレート {{n}}',
     materialCount: 'フィラメント {{count}} 本',
     amsRequired: 'AMS が必要',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: '保存',
     sliceIn: '保存して {{slicer}} でスライス',
     disclaimer: 'MakerWorld 連携はコミュニティで文書化された API エンドポイントを使用しています。Bambuddy は MakerWorld または Bambu Lab との提携・承認関係はありません。',

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

@@ -3188,6 +3188,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5114,6 +5116,8 @@ export default {
     plateDefaultName: 'Placa {{n}}',
     materialCount: '{{count}} filamentos',
     amsRequired: 'AMS necessário',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: 'Salvar',
     sliceIn: 'Salvar e fatiar no {{slicer}}',
     disclaimer: 'A integração com o MakerWorld usa endpoints de API documentados pela comunidade. Bambuddy não é afiliado nem endossado pelo MakerWorld ou pela Bambu Lab.',

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

@@ -3240,6 +3240,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5178,6 +5180,8 @@ export default {
     plateDefaultName: '打印板 {{n}}',
     materialCount: '{{count}} 种耗材',
     amsRequired: '需要 AMS',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: '保存',
     sliceIn: '保存并在 {{slicer}} 中切片',
     disclaimer: 'MakerWorld 集成使用由社区记录的 API 接口。Bambuddy 与 MakerWorld 或 Bambu Lab 没有从属或认可关系。',

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

@@ -3240,6 +3240,8 @@ export default {
     filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    analyzingPlateFilaments: 'Analyzing plate filaments…',
+    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
@@ -5178,6 +5180,8 @@ export default {
     plateDefaultName: '列印板 {{n}}',
     materialCount: '{{count}} 種耗材',
     amsRequired: '需要 AMS',
+    slicedFor: 'Sliced for {{printer}}',
+    alsoCompatible: 'Also marked compatible: {{printers}}',
     importToLibrary: '儲存',
     sliceIn: '儲存並在 {{slicer}} 中切片',
     disclaimer: 'MakerWorld 整合使用由社群記錄的 API 介面。Bambuddy 與 MakerWorld 或 Bambu Lab 無從屬或認可關係。',

+ 26 - 0
frontend/src/pages/MakerworldPage.tsx

@@ -599,6 +599,19 @@ export function MakerworldPage() {
                 const materialCnt = pickNumber(inst, 'materialCnt');
                 const needAms = inst?.['needAms'] === true;
                 const downloadsOnInstance = pickNumber(inst, 'downloadCount');
+                // Primary printer the file was sliced for (devProductName,
+                // e.g. "A1") + the alt-compatibility list MakerWorld marks.
+                // Both come from the design endpoint's per-instance
+                // extention.modelInfo, merged into the instance by the
+                // backend resolve route. The "compat" list is informational
+                // — Bambuddy can't actually re-slice across printers, but
+                // the user gets to see what they're picking.
+                const compat = (inst?.['compatibility'] as { devProductName?: string } | null) ?? null;
+                const others = (inst?.['otherCompatibility'] as Array<{ devProductName?: string }> | null) ?? null;
+                const primaryPrinter = compat?.devProductName ?? null;
+                const otherPrinters: string[] = Array.isArray(others)
+                  ? others.map((o) => o?.devProductName ?? '').filter(Boolean)
+                  : [];
                 if (instanceId == null) return null;
                 const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId;
                 const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId;
@@ -644,6 +657,11 @@ export function MakerworldPage() {
                           {instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}
                         </p>
                         <div className="flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
+                          {primaryPrinter && (
+                            <span className="font-medium text-gray-700 dark:text-gray-300">
+                              {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })}
+                            </span>
+                          )}
                           {materialCnt !== null && (
                             <span>{t('makerworld.materialCount', { count: materialCnt })}</span>
                           )}
@@ -652,6 +670,14 @@ export function MakerworldPage() {
                             <span>{t('makerworld.downloadsCount', { count: downloadsOnInstance })}</span>
                           )}
                         </div>
+                        {otherPrinters.length > 0 && (
+                          <p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={otherPrinters.join(', ')}>
+                            {t('makerworld.alsoCompatible', {
+                              printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''),
+                              defaultValue: 'Also marked compatible: {{printers}}',
+                            })}
+                          </p>
+                        )}
                       </div>
                       <div className="flex gap-2 shrink-0">
                         <Button

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

@@ -24,6 +24,11 @@ export interface ArchivePlatesResponse {
   plates: PlateMetadata[];
   is_multi_plate: boolean;
   has_gcode?: boolean;
+  // Bound printer model from the source 3MF's project_settings.config (e.g.
+  // "Bambu Lab A1"). Used by the SliceModal to warn before slicing if the
+  // user picks a profile for a different printer — the slicer CLI can't
+  // convert a 3MF across printer models.
+  source_printer_model?: string | null;
 }
 
 export interface LibraryFilePlatesResponse {
@@ -31,6 +36,7 @@ export interface LibraryFilePlatesResponse {
   filename: string;
   plates: PlateMetadata[];
   is_multi_plate: boolean;
+  source_printer_model?: string | null;
 }
 
 export interface ViewerPlateSelectionState {

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BZTbdNTm.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-ByGZ61Vo.js"></script>
+    <script type="module" crossorigin src="/assets/index-BZTbdNTm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Bbpbjxtl.css">
   </head>
   <body>

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