فهرست منبع

Stop offering server-side slicing for STEP files

The Slice action appeared on .step / .stp and the endpoint accepted the job,
but neither slicer can load one from its command line -- both answer
"Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
So the file was read, converted and uploaded before failing as "The input
model file to the slicer can not be parsed", which reads as a corrupt model
rather than an unsupported format.

The endpoint refuses a STEP up front with a message saying to export it as
STL or 3MF, and the Slice and pipeline buttons no longer appear on one.

Open in Slicer is unchanged and still hands STEP to the desktop application,
which opens it fine -- that was always the working path. isSliceableFilename
(desktop) and isApiSliceableFilename (sidecar) are now separate predicates so
the two cannot drift back together.
maziggy 3 هفته پیش
والد
کامیت
57190b10ba

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


+ 17 - 7
backend/app/api/routes/library.py

@@ -4464,13 +4464,23 @@ async def slice_library_file(
     lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
     src_lower = (lib_file.filename or "").lower()
-    if not (
-        src_lower.endswith(".stl")
-        or src_lower.endswith(".3mf")
-        or src_lower.endswith(".step")
-        or src_lower.endswith(".stp")
-    ):
-        raise HTTPException(status_code=400, detail="Source file must be STL, 3MF, or STEP")
+    if src_lower.endswith(".step") or src_lower.endswith(".stp"):
+        # Neither slicer's CLI can load STEP: OrcaSlicer 2.4.2 and BambuStudio
+        # 02.07.01.62 both answer "Unknown file format. Input file must have
+        # .stl, .obj, .amf(.xml) extension." Accepting the job here meant
+        # reading the file, converting it and uploading it before the sidecar
+        # rejected it as unparseable -- which reads as a corrupt model rather
+        # than an unsupported format. Say so before any of that happens.
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "STEP files cannot be sliced. The OrcaSlicer and Bambu Studio command-line "
+                "slicers load only STL and 3MF -- open the STEP in your slicer and export it "
+                "as one of those first."
+            ),
+        )
+    if not (src_lower.endswith(".stl") or src_lower.endswith(".3mf")):
+        raise HTTPException(status_code=400, detail="Source file must be STL or 3MF")
 
     src_path = Path(app_settings.base_dir) / lib_file.file_path
     if not src_path.exists():

+ 42 - 1
backend/tests/integration/test_library_slice_api.py

@@ -205,7 +205,48 @@ class TestSliceValidation:
             },
         )
         assert response.status_code == 400
-        assert "STL, 3MF, or STEP" in response.json()["detail"]
+        assert "STL or 3MF" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_step_source_is_refused_with_an_explanation(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """STEP was accepted here and then failed at the sidecar.
+
+        Neither slicer's CLI can load STEP -- it answers "Unknown file format"
+        and exits 250 -- so the job was read, converted and uploaded only to
+        come back as "The input model file to the slicer can not be parsed",
+        which reads as a corrupt model rather than an unsupported format.
+        """
+        from backend.app.models.library import LibraryFile
+
+        step_path = slice_test_setup["tmp_path"] / "part.step"
+        step_path.write_bytes(b"ISO-10303-21;\n")
+        sfile = LibraryFile(
+            filename="part.step",
+            file_path=str(step_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="step",
+            file_size=14,
+        )
+        db_session.add(sfile)
+        await db_session.commit()
+        await db_session.refresh(sfile)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{sfile.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+
+        assert response.status_code == 400
+        detail = response.json()["detail"]
+        assert "STEP" in detail
+        # Naming the way out matters more than the refusal.
+        assert "export" in detail.lower()
 
 
 # ---------------------------------------------------------------------------

+ 53 - 0
frontend/src/__tests__/utils/slicerStepGating.test.ts

@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import {
+  isApiSliceableFileType,
+  isApiSliceableFilename,
+  isSliceableFileType,
+  isSliceableFilename,
+} from '../../utils/slicer';
+
+/**
+ * STEP splits the two slice paths.
+ *
+ * The desktop slicers open a STEP fine, so "Open in Slicer" must keep offering
+ * it. Their command-line interfaces cannot load one -- OrcaSlicer 2.4.2 and
+ * Bambu Studio 02.07.01.62 both answer "Unknown file format. Input file must
+ * have .stl, .obj, .amf(.xml) extension." -- so the in-app "Slice" button and
+ * the pipeline action, which both post to the sidecar, must not.
+ *
+ * One predicate used to serve both, which is why a STEP got a Slice button
+ * that could only ever fail, several seconds and one upload later.
+ */
+describe('STEP is offered to the desktop slicer but not the sidecar', () => {
+  it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is a desktop handoff', (name) => {
+    expect(isSliceableFilename(name)).toBe(true);
+  });
+
+  it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
+    expect(isApiSliceableFilename(name)).toBe(false);
+  });
+
+  it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
+    expect(isSliceableFilename(name)).toBe(true);
+    expect(isApiSliceableFilename(name)).toBe(true);
+  });
+
+  it.each(['out.gcode', 'out.gcode.3mf'])('%s is slicer output, not input', (name) => {
+    expect(isSliceableFilename(name)).toBe(false);
+    expect(isApiSliceableFilename(name)).toBe(false);
+  });
+
+  it('applies the same split to stored file types', () => {
+    expect(isSliceableFileType('step')).toBe(true);
+    expect(isApiSliceableFileType('step')).toBe(false);
+    expect(isApiSliceableFileType('stl')).toBe(true);
+    expect(isApiSliceableFileType('3mf')).toBe(true);
+    expect(isApiSliceableFileType('gcode.3mf')).toBe(false);
+  });
+
+  it('treats a missing type as not sliceable', () => {
+    expect(isApiSliceableFileType(undefined)).toBe(false);
+    expect(isApiSliceableFileType(null)).toBe(false);
+    expect(isApiSliceableFileType('')).toBe(false);
+  });
+});

+ 8 - 6
frontend/src/components/ModelViewerModal.tsx

@@ -6,7 +6,7 @@ import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
-import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 // The modal shows the model only; G-code has its own full-page viewer.
@@ -368,12 +368,14 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
   // Which file types can be handed to a desktop slicer via the URL protocol
-  // handler — and sliced in-app via the sidecar. Shares its list with
-  // `isSliceableFilename()`, which the File Manager's card menu and list row
-  // use, so a file's "Slice" action and its 3D-preview slicer button can no
-  // longer disagree about the same file.
+  // handler. Shares its list with `isSliceableFilename()`, which the File
+  // Manager's card menu and list row use, so a file's "Slice" action and its
+  // 3D-preview slicer button can no longer disagree about the same file.
   const slicerReadyType = isSliceableFileType(fileType);
   const canOpenInSlicer = isLibrary ? slicerReadyType : true;
+  // The sidecar's list is narrower: its CLI cannot load STEP even though the
+  // desktop GUI opens one fine, so in-app slicing is gated separately.
+  const apiSlicerReadyType = isApiSliceableFileType(fileType);
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -382,7 +384,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   // the API is off, when no in-app handler is wired (e.g. archive preview),
   // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
   const useBambuddySlicer = Boolean(
-    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
+    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && apiSlicerReadyType,
   );
 
   const handleOpenInSlicer = async (slicer: SlicerType) => {

+ 7 - 6
frontend/src/pages/FileManagerPage.tsx

@@ -74,7 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
-import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -895,7 +895,8 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                 </button>
               )}
-              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
+              {(useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) &&
+                (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                     canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -913,7 +914,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('slice.action')}
                 </button>
               )}
-              {onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && (
+              {onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                     hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -2639,7 +2640,7 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {isSliceableFilename(file.filename) && (
+                      {(settings?.use_slicer_api ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) && (
                         <button
                           onClick={() => {
                             if (!canSlice()) return;
@@ -2656,7 +2657,7 @@ export function FileManagerPage() {
                           {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                      {(settings?.use_slicer_api ?? false) && isApiSliceableFilename(file.filename) && (
                         <button
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           className={`p-1.5 rounded transition-colors ${
@@ -2902,7 +2903,7 @@ export function FileManagerPage() {
           onSliceWithBambuddy={
             // Only offer in-app slicing on files the SliceModal can actually
             // handle (matches the file-row Cog visibility check at :2127).
-            isSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
+            isApiSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
               ? () => {
                   const f = viewerFile;
                   setViewerFile(null);

+ 34 - 0
frontend/src/utils/slicer.ts

@@ -54,6 +54,17 @@ export function resolveDesktopSlicer(
  */
 export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
 
+/**
+ * The subset the *sidecar* can slice.
+ *
+ * The desktop slicers open a STEP happily; their command-line interfaces do
+ * not. OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer one with
+ * "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
+ * So a STEP still gets an "Open in Slicer" handoff, and no longer gets a
+ * "Slice" button that could only ever fail.
+ */
+export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
+
 /**
  * Does a `LibraryFile.file_type` name a sliceable source file?
  *
@@ -78,6 +89,19 @@ export function isSliceableFilename(filename: string): boolean {
   return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
 }
 
+/**
+ * Does a filename name something the slicer *sidecar* can slice?
+ *
+ * Narrower than `isSliceableFilename` by exactly STEP — see
+ * `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
+ * `/library/files/{id}/slice`; use the wider one for the desktop handoff.
+ */
+export function isApiSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return API_SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+}
+
 /**
  * Detect the user's operating system
  */
@@ -148,3 +172,13 @@ export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_st
   const downloadUrl = buildDownloadUrl(path);
   openInSlicer(downloadUrl, slicer);
 }
+
+/**
+ * Does a `LibraryFile.file_type` name something the sidecar can slice?
+ *
+ * The `isSliceableFileType` counterpart, narrowed to the sidecar's formats.
+ */
+export function isApiSliceableFileType(fileType?: string | null): boolean {
+  const normalized = (fileType || '').toLowerCase();
+  return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CK67RtNz.js


+ 1 - 1
static/index.html

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است