Преглед на файлове

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.
maziggy преди 2 месеца
родител
ревизия
857a071306

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
CHANGELOG.md


+ 15 - 12
backend/app/api/routes/library.py

@@ -3576,11 +3576,13 @@ async def slice_and_persist(
         folder_id=folder_id,
         folder_id=folder_id,
         filename=out_filename,
         filename=out_filename,
         file_path=to_relative_path(out_path),
         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_size=len(result.content),
         file_hash=hashlib.sha256(result.content).hexdigest(),
         file_hash=hashlib.sha256(result.content).hexdigest(),
         thumbnail_path=thumbnail_relative,
         thumbnail_path=thumbnail_relative,
@@ -4332,15 +4334,14 @@ async def get_gcode(
     if not abs_path or not abs_path.exists():
     if not abs_path or not abs_path.exists():
         raise HTTPException(status_code=404, detail="File not found on disk")
         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:
         try:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
-                # Find gcode file
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 if not gcode_files:
                 if not gcode_files:
                     raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
                     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")
                 return Response(content=gcode_content, media_type="text/plain")
         except zipfile.BadZipFile:
         except zipfile.BadZipFile:
             raise HTTPException(status_code=400, detail="Invalid 3MF file")
             raise HTTPException(status_code=400, detail="Invalid 3MF file")
+    elif file.file_type == "gcode":
+        return FastAPIFileResponse(str(abs_path), media_type="text/plain")
     else:
     else:
         raise HTTPException(status_code=400, detail="Unsupported file type")
         raise HTTPException(status_code=400, detail="Unsupported file type")
 
 

+ 34 - 0
backend/tests/integration/test_library_api.py

@@ -1274,6 +1274,40 @@ class TestPrintFileUploadValidation:
         assert response.status_code == 200
         assert response.status_code == 200
         assert b"G28" in response.content
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
     async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):

+ 2 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -2164,7 +2164,7 @@ export function FileManagerPage() {
                     <div>
                     <div>
                       <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
                       <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
                         file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
                         file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
-                        : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
+                        : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-500/20 text-blue-400'
                         : file.file_type === 'stl' ? 'bg-purple-500/20 text-purple-400'
                         : file.file_type === 'stl' ? 'bg-purple-500/20 text-purple-400'
                         : 'bg-bambu-gray/20 text-bambu-gray'
                         : 'bg-bambu-gray/20 text-bambu-gray'
                       }`}>
                       }`}>
@@ -2223,7 +2223,7 @@ export function FileManagerPage() {
                           <Cog className="w-4 h-4" />
                           <Cog className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
-                      {(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') && (
                         <button
                         <button
                           onClick={() => {
                           onClick={() => {
                             if (!hasPermission('library:read')) return;
                             if (!hasPermission('library:read')) return;

+ 1 - 1
frontend/src/pages/ProjectDetailPage.tsx

@@ -949,7 +949,7 @@ export function ProjectDetailPage() {
                                 </p>
                                 </p>
                                 <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
                                 <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
                                   file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
                                   file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
-                                  : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
+                                  : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-500/20 text-blue-400'
                                   : 'bg-bambu-gray/20 text-bambu-gray'
                                   : 'bg-bambu-gray/20 text-bambu-gray'
                                 }`}>
                                 }`}>
                                   {file.file_type.toUpperCase()}
                                   {file.file_type.toUpperCase()}

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-C-AWhT3K.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-VyNhPxaj.js"></script>
+    <script type="module" crossorigin src="/assets/index-C-AWhT3K.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-45eedLWT.css">
     <link rel="stylesheet" crossorigin href="/assets/index-45eedLWT.css">
   </head>
   </head>
   <body>
   <body>

Някои файлове не бяха показани, защото твърде много файлове са промени