diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 56ae607fc..7ad899bbc 100644 --- a/backend/app/api/routes/library.py +++ b/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(): diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index 3b561775f..6c7460a81 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/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() # --------------------------------------------------------------------------- diff --git a/frontend/src/__tests__/utils/slicerStepGating.test.ts b/frontend/src/__tests__/utils/slicerStepGating.test.ts new file mode 100644 index 000000000..99e150363 --- /dev/null +++ b/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); + }); +}); diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 612efb822..5c7e84933 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/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) => { diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 98481c2db..00e8cf2d6 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/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')} )} - {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && ( + {(useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) && + (useSlicerApi ? onSlice : onOpenInSlicer) && ( )} - {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && ( + {(settings?.use_slicer_api ?? false) && isApiSliceableFilename(file.filename) && (