diff --git a/CHANGELOG.md b/CHANGELOG.md index 55de171f6..54dbabd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,13 @@ All notable changes to Bambuddy will be documented in this file. ### Fixed - **P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge** ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded `.3mf` after the MQTT `project_file` command lands; FTP STOR returns 226 cleanly and the upload is intact, but `gcode_state` stays at `IDLE` and `subtask_id` doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (`_verify_print_response` in `background_dispatch.py` and `_watchdog_print_start` in `print_scheduler.py`) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called `force_reconnect_stale_session` to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit `0500_4003`: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's `gcode_file` field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, `state` stays unchanged, and `subtask_id` stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's `gcode_file` field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture `pre_gcode_file` alongside `pre_state` and `pre_subtask_id` from `printer_manager.get_status()` before sending the publish, then compare against the printer's current `gcode_file` after the watchdog times out. If the value changed → command landed → log a `#1150` warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original `force_reconnect_stale_session` call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to `pending` so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's `gcode_file` looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees `0500_4003` on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when `gcode_file` changed (the #1150 fix), reconnect when `gcode_file` is unchanged (the #936 protection preserved), skip reconnect when `pre_gcode_file=None` and current is non-None (printer just connected), reconnect when `pre_gcode_file` arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged. -- **3MF profile-driven slicing always fell back to embedded settings, plus sidecar errors were generic** — `_strip_3mf_embedded_settings` only removed `Metadata/project_settings.config` before forwarding the model to the slicer sidecar; real-world Bambu Studio / OrcaSlicer 3MFs also carry `Metadata/model_settings.config`, `Metadata/slice_info.config`, and `Metadata/cut_information.xml`, all of which reference the original slice's printer / filament IDs. Any single leftover triggered the CLI's input validation and the slice fell back to embedded settings — making the new SliceModal's profile picker theatrical for 3MF inputs. Strip widened to remove all four configs (centralised in `_STRIPPABLE_3MF_CONFIGS` with per-file rationale); geometry (`3D/3dmodel.model`), thumbnails, and multi-part data preserved verbatim. Existing strip integration test extended to assert all four files go and `3D/3dmodel.model` stays. Separately, `slicer_api.py` was reading only `message` from sidecar 5xx responses and dropping `details`, so every CLI failure surfaced as the unhelpful generic `Failed to slice the model`. New `_format_sidecar_error` helper combines both fields (preferring `message: details`, falling back to whichever is set, falling back to `response.text` for non-JSON 5xx like nginx 502s) and replaces the four duplicated `response.json().get("message", "")` blocks. After both changes, a CLI rejection log line goes from `Slicer CLI failed (500): Failed to slice the model` to `Slicer CLI failed (500): Failed to slice the model: `. 3 new unit tests in `test_slicer_api.py`: `details` field surfaced alongside `message`, `details`-only response still produces a useful error, plain-text 5xx (gateway timeouts) doesn't crash the JSON decode and falls back to the body. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses (`d9c6121`) and captures CLI stdout / stderr in the failure path (`fb928c8`). +- **3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile)** — Two stacked bugs in the slice pipeline. **(1) Pre-forward strip removed too much.** `_strip_3mf_embedded_settings` was scrubbing all four embedded `Metadata/*.config` files before forwarding the 3MF to the sidecar, on the theory that `--load-settings` would then take precedence cleanly. That theory was wrong: `Metadata/model_settings.config` carries the plate definitions the CLI needs to map `--slice N` to a real plate, and `slice_info.config` / `project_settings.config` supply baseline config the CLI's `StaticPrintConfigs` pass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, no `result.json`, no stderr — which the sidecar 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. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so `--load-settings` overrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. **(2) Standard-tier preset stubs were missing the `type` field.** `_resolve_standard` in `preset_resolver.py` emitted `{"name": ..., "inherits": ..., "from": "system"}` for the bundled tier, but the CLI's preset parser also requires a `type` discriminator (`machine` / `process` / `filament`) on every loaded settings file — without it the CLI silently rejects with `rc=-5` ("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New `_SLOT_TO_PROFILE_TYPE` constant maps each slot to its required type, and the stub now emits the right value per slot. **Tests**: integration test renamed from "strip removes all four configs" to `test_3mf_input_forwarded_unmodified_to_sidecar` — asserts every `Metadata/*.config` plus `3D/3dmodel.model` is preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; new `test_standard_emits_correct_type_per_slot` pins each (slot → type) pairing. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback. + +- **Sliced-archive card listed every project-wide AMS slot instead of just the filaments the print actually used** — `slice_and_persist_as_archive` previously copied `filament_type` / `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot configured in the source's `project_settings.config` (16+ swatches on the card for what was actually a 2-color print). The new archive row now reads those fields from the sliced output's `Metadata/slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0` per-slot), falling back to the source archive's values only when parsing the new 3MF failed. Test in `test_archive_copy.py::test_filament_metadata_only_includes_filaments_with_used_g` builds a 4-slot fixture where slots 2 and 4 have `used_g=0` and asserts both type and color outputs exclude them. + +- **Slice modal had no warning when the picked printer profile didn't match the source 3MF's bound printer — silent wrong-printer output** — Both BambuStudio and OrcaSlicer CLIs reject `--load-settings` for a printer different from the one the source 3MF was originally bound to (`rc=-16` "current 3mf file not support the new printer") because the cross-printer "convert project" flow is desktop-Studio only; the slice would then fall back to embedded settings and produce a file sliced for the wrong printer that errored at print dispatch time with "File was sliced for A1, but printing on H2D". The plates response now exposes `source_printer_model` (read from `project_settings.config`'s `printer_model` field, with fallback to stripping the nozzle suffix off `printer_settings_id`); the SliceModal compares it against the picked printer profile name (substring match against the model prefix, e.g. `"Bambu Lab H2D 0.4 nozzle"` matches `"H2D"`) and surfaces an inline amber warning explaining the limitation, plus disables the Slice button while the warning is up so users can't dispatch a guaranteed-wrong slice. Cloud presets with arbitrary user-chosen names (e.g. `"My Custom X1C"`) and legacy 3MFs without `project_settings.printer_model` fall through to no-warning, which is a reasonable default — the user picked it knowingly. New `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` with 6 unit tests covering missing/direct/nozzle-stripped/corrupt-JSON paths; 3 frontend tests in SliceModal pinning the warning + disabled-button on mismatch, no-warning on match, and no-warning when the source model is unknown. New i18n key `slice.printerMismatch` localised across all 8 UI languages. + +- **MakerWorld URL-paste resolver listed plate instances without showing which printer each was sliced for** — MakerWorld's `/instances/hits` endpoint omits the per-instance compatibility info that lives on `design.instances[].extention.modelInfo` (`compatibility` = primary printer the instance was sliced for, `otherCompatibility` = additional printers the uploader marked it compatible with), so every instance row in the resolved-design preview looked identical and users blindly picked the first one regardless of whether it matched their printer — leading to "I downloaded the H2D version and got A1 g-code" complaints. The resolve route now joins both endpoint payloads by instance ID and forwards both fields onto each hit; the MakerWorld page renders "Sliced for {primaryPrinter}" + (when present) "Also marked compatible: ..." per instance row. Backend tests in `test_makerworld_routes.py::TestResolve` cover the merge happy path (compatibility lists land on the right hits) and the "missing modelInfo" fallback (older designs / hits without a matching design.instances entry don't crash the response, just lose the optional fields). New i18n keys `makerworld.slicedFor` / `makerworld.alsoCompatible` localised across all 8 UI languages. - **Moving a file to an external folder updated the DB row but never wrote the bytes to the mount** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to `move_files` only updating `file.folder_id` in the DB while leaving the bytes in the internal `library_files_dir`. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new `_move_file_bytes` helper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to `/`, flip `is_external=True`, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flip `is_external=False`, store the relative path, unlink the external source, recompute `file_hash` since scan-tracked rows historically carry `file_hash=None`), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failed `shutil.copy2` cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames after `Path.resolve()`, missing source on disk, and `os.access(W_OK)` on the target mount. Each skip carries a structured `{file_id, code, reason}` entry in a new `skipped_reasons` field on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original `{moved, skipped}` numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests in `test_external_folders_api.py::TestCrossBoundaryMove` covering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips with `code: "name_collision"` and leaves the pre-existing target file intact, source on read-only external mount skips with `code: "source_readonly"`, managed → managed stays DB-only (file_path doesn't change, no shutil.copy), and `skipped_reasons` is always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index fceaa15f1..2b2a3650f 100644 --- a/backend/app/api/routes/archives.py +++ b/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, } diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index ccadf6283..fbb5b9d25 100644 --- a/backend/app/api/routes/library.py +++ b/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) diff --git a/backend/app/api/routes/makerworld.py b/backend/app/api/routes/makerworld.py index f52f85240..42f990fe7 100644 --- a/backend/app/api/routes/makerworld.py +++ b/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 diff --git a/backend/app/services/preset_resolver.py b/backend/app/services/preset_resolver.py index 879a0821f..df09ccdc8 100644 --- a/backend/app/services/preset_resolver.py +++ b/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: }` stub. The sidecar's resolver - walks `BUNDLED_PROFILES_PATH//.json` and merges, + """Build a minimal `{name, inherits, from, type}` stub. The sidecar's + resolver walks `BUNDLED_PROFILES_PATH//.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], } ) diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index bcaf4e240..ff26bf176 100644 --- a/backend/app/utils/threemf_tools.py +++ b/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``. diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index 848f7d178..dbc852cfa 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/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 diff --git a/backend/tests/unit/services/test_archive_copy.py b/backend/tests/unit/services/test_archive_copy.py index c5391c15d..f7f109fd1 100644 --- a/backend/tests/unit/services/test_archive_copy.py +++ b/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", "") + # 4 declared slots, only 2 actually consumed on this plate. + zf.writestr( + "Metadata/slice_info.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.""" diff --git a/backend/tests/unit/services/test_preset_resolver.py b/backend/tests/unit/services/test_preset_resolver.py index 67d4df5f7..c0774689f 100644 --- a/backend/tests/unit/services/test_preset_resolver.py +++ b/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") diff --git a/backend/tests/unit/test_makerworld_routes.py b/backend/tests/unit/test_makerworld_routes.py index ef814f20b..6ba6fdeaf 100644 --- a/backend/tests/unit/test_makerworld_routes.py +++ b/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 diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index 7ec4688d8..60376af20 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/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 diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 479e73184..854ce65aa 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/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); + }); }); diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index cc58f8e8f..1f689eaac 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/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 ` 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 */}
- {(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) && (
{t('slice.loadingPresets', 'Loading presets…')} @@ -336,37 +366,62 @@ export function SliceModal({ source, onClose }: SliceModalProps) { onChange={setProcessPreset} disabled={isEnqueuing} /> - {filamentSlots.map((slot, idx) => ( - 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 ? ( +
+ + {t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…')} +
+ ) : ( + filamentSlots.map((slot, idx) => ( + 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 && ( +
+ {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.', + })} +
+ )} + {errorMessage && (
{errorMessage} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 7e3b3f101..ec55c9864 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 85bfbc7f0..7d262e805 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index d889194eb..fd90fc512 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 836acb79f..b7bb1d49e 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index fb610f9d5..8cbed8632 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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 との提携・承認関係はありません。', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 07d304a4c..c44706bef 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index e9e7d289d..e2ee3be88 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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 没有从属或认可关系。', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index dd9e29d91..b091c0fbb 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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 無從屬或認可關係。', diff --git a/frontend/src/pages/MakerworldPage.tsx b/frontend/src/pages/MakerworldPage.tsx index 83b3e27e8..0ca08a923 100644 --- a/frontend/src/pages/MakerworldPage.tsx +++ b/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 })}

+ {primaryPrinter && ( + + {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })} + + )} {materialCnt !== null && ( {t('makerworld.materialCount', { count: materialCnt })} )} @@ -652,6 +670,14 @@ export function MakerworldPage() { {t('makerworld.downloadsCount', { count: downloadsOnInstance })} )}
+ {otherPrinters.length > 0 && ( +

+ {t('makerworld.alsoCompatible', { + printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''), + defaultValue: 'Also marked compatible: {{printers}}', + })} +

+ )}