From 857a071306def6bf10d15f5877fc25006ddf2a42 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 12 Jun 2026 10:42:02 +0200 Subject: [PATCH] fix(library): preview sidecar-sliced .gcode.3mf rows as G-code, not ZIP bytes (#1709) slice_and_persist writes a .gcode.3mf ZIP container but persisted the row with file_type="gcode". The G-code preview endpoint short-circuits on file_type == "gcode" and returns the bytes as text/plain, so the embedded viewer received the raw ZIP body instead of the embedded toolpath. - Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type and external-scan rows). - get_gcode also routes to the unzip branch when the filename ends with .gcode.3mf, so rows already written under the bug self-heal on first preview without a DB migration. - Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do. - Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy row preview must be text/plain, contain G28, and NOT start with PK. --- CHANGELOG.md | 1 + backend/app/api/routes/library.py | 27 ++++++++------- backend/tests/integration/test_library_api.py | 34 +++++++++++++++++++ frontend/src/pages/FileManagerPage.tsx | 4 +-- frontend/src/pages/ProjectDetailPage.tsx | 2 +- .../{index-VyNhPxaj.js => index-C-AWhT3K.js} | 2 +- static/index.html | 2 +- 7 files changed, 55 insertions(+), 17 deletions(-) rename static/assets/{index-VyNhPxaj.js => index-C-AWhT3K.js} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9306f34..bc9380ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Fixed +- **Library G-code preview returned raw ZIP bytes as `text/plain` for sidecar-sliced rows (#1709, root cause + fix from @yanglei1980)** — `slice_and_persist` writes its output as a `.gcode.3mf` (a ZIP container with embedded G-code) but persisted the LibraryFile row with `file_type="gcode"`. The G-code preview endpoint at `library.py::get_gcode` short-circuits on `file_type == "gcode"` and streams the on-disk bytes with `media_type="text/plain"`, so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (`PK\x03\x04…`) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed `.gcode.3mf` rows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain `.gcode` uploads were unaffected (their on-disk bytes really are text). **Fix:** (1) forward — `slice_and_persist` now persists `file_type="gcode.3mf"`, matching what `_classify_file_type` returns for the `.gcode.3mf` extension and what external-scan rows already use; (2) back-compat — `get_gcode` also routes to the unzip branch when the filename ends with `.gcode.3mf`, so rows already written under the bug self-heal on first preview without a DB migration. **UI gates:** three frontend call sites that gated badge colour or the preview-eye icon on `file_type == "gcode"` were extended to also accept `"gcode.3mf"` — `FileManagerPage.tsx` badge + viewer-affordance gate, `ProjectDetailPage.tsx` badge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (`isSlicedFilename`, `isSliceableFilename`) that already accept `.gcode.3mf`, so they need no change. **Tests:** new `test_library_get_gcode_recovers_legacy_gcode_type_for_3mf` regression case in `test_library_api.py` constructs a row with `file_type="gcode"` + `.gcode.3mf` filename pointing at a real ZIP, asserts the response is `text/plain`, contains `G28`, and does NOT start with `PK` — pins the legacy-row recovery path. Existing `test_library_get_gcode_endpoint_accepts_compound_file_type` continues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint + `npm run build` clean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here on `dev`. - **Cloud + Orca Cloud preset resolver: pin `type` and `from` to CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice)** — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from `.bbscfg`-on-disk. (1) **`type` field**: Bambu Cloud labels presets with `type: "printer"` / `"print"` / `"filament"`, but the BambuStudio CLI's `--load-settings` parser only accepts `"machine"` / `"process"` / `"filament"`. The user's first failing slice produced `operator(): unknown config type print of file preset.json in load-settings` with exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2) **`from` field**: Bambu Cloud's filament detail endpoint routinely ships presets with empty `from` (or no `from` at all). The CLI's compatibility check rejects either with `operator(): file ... 's from unsupported` (the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar's `normalizeFromField` already maps `"User"` / `"System"` → `"system"`, but it doesn't touch empty / missing values. **Fix:** `_resolve_cloud` and `_resolve_orca_cloud` now force `type = _SLOT_TO_PROFILE_TYPE[slot]` and `from = "system"` on the payload before `json.dumps`, mirroring what `_resolve_standard` already does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). **Tests:** new `test_cloud_rewrites_type_field_for_cli` (7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case), `test_cloud_pins_from_field_to_system` (4 cases: empty, already-system, GUI User, GUI System), and `test_cloud_synthesises_from_field_when_missing` (the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for `_resolve_cloud` / `_resolve_orca_cloud` updated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. **What this can NOT recover:** if Bambu Cloud later starts emitting a `from` value other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state. ### Removed diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 3fe2c6379..2d3ed5c75 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -3576,11 +3576,13 @@ async def slice_and_persist( folder_id=folder_id, filename=out_filename, file_path=to_relative_path(out_path), - # Sliced output is a `.gcode.3mf` zip with embedded G-code, but the - # user-facing meaning is "ready-to-print G-code" — using "gcode" - # gives it the same badge as plain .gcode files and distinguishes - # it from un-sliced `.3mf` source models. - file_type="gcode", + # The on-disk payload is a ZIP container — the file_type must + # record that so the preview endpoint opens it as a 3MF instead + # of returning the ZIP bytes as text/plain (#1709 / yanglei1980). + # Earlier code mis-typed sliced rows as "gcode" to share the + # plain-G-code badge; that broke the embedded viewer. UI badges + # and gates for "gcode.3mf" are explicit at the call sites. + file_type="gcode.3mf", file_size=len(result.content), file_hash=hashlib.sha256(result.content).hexdigest(), thumbnail_path=thumbnail_relative, @@ -4332,15 +4334,14 @@ async def get_gcode( if not abs_path or not abs_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") - if file.file_type == "gcode": - return FastAPIFileResponse(str(abs_path), media_type="text/plain") - elif file.file_type in ("3mf", "gcode.3mf"): - # Extract gcode from 3mf zip container. `.gcode.3mf` sliced outputs - # carry the same `Metadata/plate_*.gcode` entries as a `.3mf`, so - # the unzip path is identical — just had to expand the gate. + # Legacy sliced rows from before #1709 stored a `.gcode.3mf` ZIP body + # under file_type="gcode" — the on-disk filename is the truth in that + # case, so detect by suffix before checking the type column. + is_gcode_3mf = file.file_type in ("3mf", "gcode.3mf") or file.filename.lower().endswith(".gcode.3mf") + + if is_gcode_3mf: try: with zipfile.ZipFile(str(abs_path), "r") as zf: - # Find gcode file gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")] if not gcode_files: raise HTTPException(status_code=404, detail="No gcode found in 3MF file") @@ -4350,6 +4351,8 @@ async def get_gcode( return Response(content=gcode_content, media_type="text/plain") except zipfile.BadZipFile: raise HTTPException(status_code=400, detail="Invalid 3MF file") + elif file.file_type == "gcode": + return FastAPIFileResponse(str(abs_path), media_type="text/plain") else: raise HTTPException(status_code=400, detail="Unsupported file type") diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 1864cbde0..bd3d36b7c 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -1274,6 +1274,40 @@ class TestPrintFileUploadValidation: assert response.status_code == 200 assert b"G28" in response.content + @pytest.mark.asyncio + @pytest.mark.integration + async def test_library_get_gcode_recovers_legacy_gcode_type_for_3mf(self, async_client: AsyncClient, db_session): + """#1709 regression guard. Before the fix, ``slice_and_persist`` + wrote a `.gcode.3mf` ZIP container to disk but stored the row with + ``file_type='gcode'`` — the preview endpoint then streamed the + ZIP body as ``text/plain`` and the embedded G-code viewer saw + ``PK\\x03\\x04...`` instead of the toolpath. New sliced rows now + store ``file_type='gcode.3mf'``; rows already written under the + bug self-heal because the endpoint also detects the ZIP via the + ``.gcode.3mf`` filename suffix when the column is still legacy.""" + from backend.app.models.library import LibraryFile + + with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp: + tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode")) + tmp_path = tmp.name + + lib_file = LibraryFile( + filename="legacy-sliced.gcode.3mf", + file_path=tmp_path, + file_type="gcode", + file_size=Path(tmp_path).stat().st_size, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert b"G28" in response.content + # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer. + assert not response.content.startswith(b"PK") + @pytest.mark.asyncio @pytest.mark.integration async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session): diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index b3759986b..f1128d87f 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -2164,7 +2164,7 @@ export function FileManagerPage() {
@@ -2223,7 +2223,7 @@ export function FileManagerPage() { )} - {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && ( + {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (