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.
This commit is contained in:
maziggy
2026-06-12 10:42:02 +02:00
parent 908227e4df
commit 857a071306
7 changed files with 55 additions and 17 deletions
+15 -12
View File
@@ -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")
@@ -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):