From 57190b10ba5d8e569ca0c828490ca93b4d671777 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 10 Aug 2026 13:09:24 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + backend/app/api/routes/library.py | 24 +- .../integration/test_library_slice_api.py | 43 ++- .../__tests__/utils/slicerStepGating.test.ts | 53 ++++ frontend/src/components/ModelViewerModal.tsx | 14 +- frontend/src/pages/FileManagerPage.tsx | 13 +- frontend/src/utils/slicer.ts | 34 +++ .../{index-DjNRlhiN.js => index-CK67RtNz.js} | 250 +++++++++--------- static/index.html | 2 +- 9 files changed, 288 insertions(+), 146 deletions(-) create mode 100644 frontend/src/__tests__/utils/slicerStepGating.test.ts rename static/assets/{index-DjNRlhiN.js => index-CK67RtNz.js} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 714ca9090..4965af0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **STEP files were offered for server-side slicing, which cannot work** — The **Slice** action appeared on `.step` / `.stp` files and the backend accepted the job, but neither slicer can load one from its command line: 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 file was read, converted and uploaded, and the failure came back as "The input model file to the slicer can not be parsed" — which reads as a corrupt model rather than an unsupported format. The Slice button and the pipeline action no longer appear on STEP files, and the endpoint refuses one up front with a message that says to export it as STL or 3MF first. **Open in Slicer** is unchanged and still hands STEP to the desktop application, which opens it perfectly well — that was always the working path for these files. - **A large model was refused with "Slicer CLI failed (500): File too large" and no way to find out what was too large (#2802, reported by @zevulos)** — Server-side slicing of a big multi-colour project failed on every attempt, and the message pointed at nothing. The slicer sidecar caps the size of the model it will accept; that cap was fixed at 100 MB, which real MakerWorld projects exceed. Worse than the limit was how it arrived: the sidecar's upload layer reports a size rejection as a kind of error its own handler does not recognise, so it fell through to a generic **HTTP 500** carrying the bare words "File too large". A 500 reads as a crash inside the slicer, and Bambuddy's one good explanation about request size was written for the HTTP 413 that a reverse proxy sends, so it never appeared. The reporter did the only reasonable thing with what they were shown: set `MAX_FILE_SIZE`, `BODY_PARSER_LIMIT` and `EXPRESS_PAYLOAD_LIMIT`, restart everything, stop nginx in case it was interfering, and move the whole installation from Windows to Docker — none of which the sidecar reads, on a proxy that was never in the path. The cap is now **512 MB** by default and settable with `MAX_MODEL_UPLOAD_MB` on the slicer-api service, and the sidecar answers an oversized upload with a 413 that names the limit and where it lives. Bambuddy recognises the rejection by what it says rather than by its status code, so an installation still running an older sidecar image gets the same explanation — including that the fix there is to update the image, since those have no setting to change. Two things followed from the same misreading: the failure was classed as a slicer crash, so every attempt retried the identical oversized upload "with embedded settings", spending a second 25-second conversion on a guaranteed-identical answer; and nothing anywhere recorded the size of what was being sent, so the support package from a slice that died on an upload cap looked exactly like one that died on a bad profile. Both are fixed — the retry is skipped, and each slice logs the model's size. Raising the cap also changed how the sidecar handles the upload: the model is streamed to disk instead of being held whole in memory, so a 512 MB project no longer costs half a gigabyte of RAM per concurrent slice on the small machines most likely to be running it. **This needs a sidecar update to take effect** — `cd slicer-api/ && docker compose pull && docker compose up -d`. - **`/auth/me` described API keys as administrators they were never allowed to be (#1894, reported by @MorganMLGman)** — Asked to identify an API key, Bambuddy answered with a synthetic administrator: user id `0`, role `admin`, `is_admin: true`, and every permission in the system. None of that was true. An API key cannot reach an administrative route at all, whatever its scopes and whoever owns it, so a client that built its interface from this answer — which is exactly what a native app does — offered buttons that failed with a permission error the moment anyone pressed one, and still had no way to learn which user id its own prints were filed under. The endpoint now reports the key's **owner** as its identity, `is_admin: false`, and a permission list containing precisely what the key's scopes admit, so what a client is told matches what it will be allowed to do. Keys created before keys had owners have no identity to report and keep the old `id: 0` placeholder, but they no longer claim to be administrators either. Clients that branched on `is_admin` or `role` should branch on `permissions` instead. - **An unacknowledged plate no longer stops AMS drying once a minute, for ever (#2801, reported by @superflyer11)** — With "require plate clear" on, a finished print left unacknowledged and something pending in that printer's queue put the scheduler into a loop: it stopped drying, auto-drying re-armed on the next tick, and it stopped it again — around 2000 state changes over ten days on the reporter's P2S, with no cycle ever running long enough to remove any moisture. Cycles the user had started by hand on other AMS units of the same printer were torn down with it. Two ideas had become tangled. Plate-clear answers "is the bed ready for the next job", which says nothing about whether the AMS may heat — and the gap between a finished print and the acknowledgment is exactly when drying is most useful, since the printer is free and nobody is waiting on it. Leaving the plate unacknowledged is also how people hold the queue by hand, so the hold was costing them the drying it should have enabled. On top of that, the "print takes priority" stop was reached only on the passes where the print was *not* going to start: drying is not one of the things the idle check looks at, so stopping a cycle could never make a blocked printer dispatchable, and the cycle was spent for nothing. Auto-drying no longer consults plate-clear at all; the stop now happens on dispatches that are actually going to proceed, and only where the model cannot dry through a print — hardware that can, and has been allowed to, keeps drying as #2758 established it should. A stop is also confined to cycles Bambuddy itself started, matching a contract the code already documented but did not honour, so a manual dry on another unit is left alone. Two smaller faults went with it: a printer merely waiting on the plate was being classed as mid-print, which silently applied the mid-print spool-protection cap to a printer that was not printing and logged the cycle as `(mid-print)` in `FINISH`; and a humidity reading that dipped to the threshold as the AMS cooled discarded the unit's whole history, including the 30-minute re-arm cooldown added in #2770 — so a reading oscillating a point either side of the threshold reset the very guard meant to ride it out. **One behaviour change to be aware of:** "Block queue while drying" previously had no effect on dispatch at all, and now does what it says — with it on, a queued print waits for a running cycle to finish. It is off by default. 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) && (