mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user