Browse Source

fix(slicer): offer the desktop handoff only for formats the target slicer accepts (issue #3029)

    The Slice action and Open in Slicer offered .stl, .step and .stp
    alongside .3mf, on the assumption that a slicer which opens an STL from
    its own File menu will open one from a link. Bambu Studio does not.
    Every bambustudio:// and bambustudioopen:// URL reaches one import path
    that refuses any filename which is not .3mf, and refuses it before
    fetching anything: "Download failed, unknown file format." The message
    names the format, so the failure reads as a broken model rather than an
    unsupported handoff.

    OrcaSlicer has no such limit. Only MakerWorld links take its equivalent
    path; a link to the user's own Bambuddy goes to its general downloader,
    which does not inspect the extension.

    So the format list becomes per slicer. isSliceableFilename and
    isSliceableFileType take the target as a required argument -- a default
    would quietly reintroduce the handoff that cannot work -- and an
    unrecognised value falls back to the Bambu Studio list, which is where
    openInSlicer sends anything that is not exactly 'orcaslicer'.

    The File Manager offers Slice only when the configured desktop slicer
    will take the file. The 3D preview reaches both slicers from one split
    button, so instead of hiding it promotes whichever can take the file to
    the primary action, naming that slicer when it is not the configured
    one; the split collapses to a plain button when no alternative is left.

    The sidecar path is untouched: with Use Slicer API on, Slice still takes
    STL and 3MF whatever the desktop target is.
maziggy 4 days ago
parent
commit
f3dddf3f5b

+ 37 - 7
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -668,7 +668,11 @@ describe('ModelViewerModal', () => {
       expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
     });
 
-    it('offers the desktop handoff for an STL library file', async () => {
+    it('offers the desktop handoff for an STL library file, naming the slicer', async () => {
+      // Default settings mean Bambu Studio is preferred, and its protocol
+      // handler takes 3MF only (#3029) -- so OrcaSlicer becomes the primary
+      // action. It is named rather than hidden behind a generic "Open in
+      // Slicer", because the file is not going where the setting says.
       render(
         <ModelViewerModal
           libraryFileId={1}
@@ -678,17 +682,41 @@ describe('ModelViewerModal', () => {
         />
       );
 
-      await waitFor(() => {
-        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
-      });
+      const button = await screen.findByRole('button', { name: 'Open in OrcaSlicer' });
+      expect(button).toBeEnabled();
 
-      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      // OrcaSlicer is the only slicer that can take it, so there is no
+      // alternative to offer and the split collapses to a plain button.
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
 
+      fireEvent.click(button);
       await waitFor(() => {
-        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
       });
     });
 
+    it('does not offer Bambu Studio a file its handler will refuse', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      // OrcaSlicer is preferred and takes an STL, so it is the plain primary.
+      await screen.findByRole('button', { name: 'Open in Slicer' });
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
+    });
+
     it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
       server.use(
         http.get('/api/v1/settings/', () => {
@@ -713,9 +741,11 @@ describe('ModelViewerModal', () => {
       fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
 
       await waitFor(() => {
-        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
         expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
       });
+      // Bambu Studio is absent: the sidecar can slice an STL, but Bambu
+      // Studio's URL handler cannot load one (#3029).
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
     });
   });
 });

+ 67 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -1178,6 +1178,13 @@ describe('FileManagerPage', () => {
       vi.mocked(openInSlicer).mockClear();
       server.use(
         http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
+        // The only sliceable fixture is an STL, and since #3029 the desktop
+        // handoff is only offered to a slicer whose protocol handler will
+        // actually load one -- Bambu Studio's takes 3MF only. These tests are
+        // about the handoff mechanics and the permission gate, not about which
+        // slicer, so they run against OrcaSlicer. The Bambu Studio side is
+        // covered by its own tests below.
+        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'orcaslicer' })),
       );
     });
 
@@ -1209,7 +1216,7 @@ describe('FileManagerPage', () => {
       await waitFor(() => {
         expect(openInSlicer).toHaveBeenCalledWith(
           expect.stringContaining('/library/files/2/dl/test-token/'),
-          'bambu_studio',
+          'orcaslicer',
         );
       });
     });
@@ -1255,6 +1262,64 @@ describe('FileManagerPage', () => {
       expect(card.className).not.toContain('overflow-hidden');
     });
 
+    // #3029: Bambu Studio's protocol handler refuses anything that is not a
+    // 3MF before it even fetches the URL -- "Download failed, unknown file
+    // format." Offering the handoff anyway put an action on an STL card that
+    // could only fail, with an error that blamed the file.
+    it('hides the desktop handoff for an STL when the target is Bambu Studio', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'bambu_studio' })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
+    });
+
+    it('honours the open_in_slicer override over the preferred slicer', async () => {
+      // The desktop target is its own setting (#1329), so it -- not
+      // preferred_slicer, which drives the sidecar -- decides whether an STL
+      // can be handed over at all.
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ preferred_slicer: 'bambu_studio', open_in_slicer: 'orcaslicer' }),
+        ),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
+      });
+    });
+
+    it('still offers an STL to the in-app slicer with Bambu Studio as the desktop target', async () => {
+      // The restriction is on the URL handoff, not on the file: the sidecar
+      // slices an STL regardless of which desktop slicer is configured.
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ use_slicer_api: true, preferred_slicer: 'bambu_studio' }),
+        ),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+    });
+
     it('hides the slice item for already-sliced files', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -1384,7 +1449,7 @@ describe('FileManagerPage', () => {
       await waitFor(() => {
         expect(openInSlicer).toHaveBeenCalledWith(
           expect.stringContaining('/library/files/2/dl/test-token/'),
-          'bambu_studio',
+          'orcaslicer',
         );
       });
     });

+ 56 - 4
frontend/src/__tests__/utils/slicerStepGating.test.ts

@@ -4,6 +4,7 @@ import {
   isApiSliceableFilename,
   isSliceableFileType,
   isSliceableFilename,
+  type SlicerType,
 } from '../../utils/slicer';
 
 /**
@@ -17,10 +18,14 @@ import {
  *
  * 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.
+ *
+ * The desktop half is asserted against OrcaSlicer since #3029: Bambu Studio's
+ * protocol handler takes 3MF only, so the handoff is per-slicer now. The split
+ * this file is about is unchanged -- see the second describe for the new axis.
  */
 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);
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(true);
   });
 
   it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
@@ -28,17 +33,18 @@ describe('STEP is offered to the desktop slicer but not the sidecar', () => {
   });
 
   it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
-    expect(isSliceableFilename(name)).toBe(true);
+    expect(isSliceableFilename(name, 'orcaslicer')).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(isSliceableFilename(name, 'orcaslicer')).toBe(false);
+    expect(isSliceableFilename(name, 'bambu_studio')).toBe(false);
     expect(isApiSliceableFilename(name)).toBe(false);
   });
 
   it('applies the same split to stored file types', () => {
-    expect(isSliceableFileType('step')).toBe(true);
+    expect(isSliceableFileType('step', 'orcaslicer')).toBe(true);
     expect(isApiSliceableFileType('step')).toBe(false);
     expect(isApiSliceableFileType('stl')).toBe(true);
     expect(isApiSliceableFileType('3mf')).toBe(true);
@@ -51,3 +57,49 @@ describe('STEP is offered to the desktop slicer but not the sidecar', () => {
     expect(isApiSliceableFileType('')).toBe(false);
   });
 });
+
+/**
+ * Which slicer the URL is handed to changes the answer (#3029).
+ *
+ * Bambu Studio funnels every protocol URL into ``Plater::import_model_id``,
+ * which refuses a filename that is not .3mf before it even makes the request:
+ * "Download failed, unknown file format." So a File Manager that offered an
+ * STL handoff to Bambu Studio was offering an action that could only fail, and
+ * the error blamed the file.
+ *
+ * OrcaSlicer only sends MakerWorld links down that path; a link to our own host
+ * goes to its generic downloader, which has no extension check.
+ */
+describe('the desktop handoff is per-slicer', () => {
+  it.each(['cube.stl', 'part.step', 'part.stp'])('%s goes to OrcaSlicer but not Bambu Studio', (name) => {
+    expect(isSliceableFilename(name, 'orcaslicer')).toBe(true);
+    expect(isSliceableFilename(name, 'bambu_studio')).toBe(false);
+  });
+
+  it('a 3MF goes to either', () => {
+    expect(isSliceableFilename('project.3mf', 'orcaslicer')).toBe(true);
+    expect(isSliceableFilename('project.3mf', 'bambu_studio')).toBe(true);
+  });
+
+  it('applies the same split to stored file types', () => {
+    expect(isSliceableFileType('stl', 'orcaslicer')).toBe(true);
+    expect(isSliceableFileType('stl', 'bambu_studio')).toBe(false);
+    expect(isSliceableFileType('3mf', 'bambu_studio')).toBe(true);
+    expect(isSliceableFileType('gcode.3mf', 'orcaslicer')).toBe(false);
+  });
+
+  it('treats a missing type as not sliceable for either', () => {
+    expect(isSliceableFileType(undefined, 'orcaslicer')).toBe(false);
+    expect(isSliceableFileType(null, 'bambu_studio')).toBe(false);
+    expect(isSliceableFileType('', 'orcaslicer')).toBe(false);
+  });
+
+  it('falls back to the Bambu Studio list for an unrecognised slicer', () => {
+    // `settings.open_in_slicer` is not validated on the way in, and
+    // `openInSlicer` sends anything that is not exactly 'orcaslicer' to Bambu
+    // Studio. The gate has to agree with where the URL actually goes.
+    const bogus = 'prusa' as unknown as SlicerType;
+    expect(isSliceableFilename('cube.stl', bogus)).toBe(false);
+    expect(isSliceableFilename('project.3mf', bogus)).toBe(true);
+  });
+});

+ 41 - 9
frontend/src/components/ModelViewerModal.tsx

@@ -44,6 +44,11 @@ interface SlicerSplitButtonProps {
 // opens a dropdown with the other slicer options. Outside click or Escape
 // (non-propagating) closes the dropdown. The split only renders when the
 // action is already possible, so there is no disabled state to express.
+//
+// With nothing to put in the dropdown it collapses to a plain button rather
+// than offering a chevron onto an empty menu. That is reachable since #3029:
+// an STL has exactly one slicer that will take it, so there is no alternative
+// to offer once that one is the primary action.
 function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
   const [open, setOpen] = useState(false);
   const containerRef = useRef<HTMLDivElement>(null);
@@ -69,6 +74,15 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
     };
   }, [open]);
 
+  if (items.length === 0) {
+    return (
+      <Button variant="secondary" size="sm" onClick={onPrimary}>
+        {icon}
+        {label}
+      </Button>
+    );
+  }
+
   return (
     <div className="relative inline-flex" ref={containerRef}>
       <div className="flex relative z-50">
@@ -367,12 +381,20 @@ 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. Shares its list with `isSliceableFilename()`, which the File
+  // Which desktop slicers this file can actually be handed to, configured one
+  // first. Shares its lists 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;
+  //
+  // Per-slicer rather than one list, because Bambu Studio's protocol handler
+  // takes 3MF only while OrcaSlicer's takes STL and STEP too (#3029). Archive
+  // previews always hand over a 3MF, which both accept.
+  const desktopSlicerOrder: SlicerType[] =
+    preferredSlicer === 'orcaslicer' ? ['orcaslicer', 'bambu_studio'] : ['bambu_studio', 'orcaslicer'];
+  const usableSlicers = isLibrary
+    ? desktopSlicerOrder.filter((slicer) => isSliceableFileType(fileType, slicer))
+    : desktopSlicerOrder;
+  const canOpenInSlicer = usableSlicers.length > 0;
   // 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);
@@ -415,9 +437,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     }
   };
 
-  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
-    ? ['bambu_studio', 'orcaslicer']
-    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  // With the sidecar as the primary action every usable slicer is an
+  // alternative; without it the first one is the primary, so the dropdown holds
+  // the rest.
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer ? usableSlicers : usableSlicers.slice(1);
   const slicerName = (slicer: SlicerType) =>
     slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
   const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
@@ -459,9 +482,18 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
             ) : canOpenInSlicer ? (
               <SlicerSplitButton
                 icon={<ExternalLink className="w-4 h-4" />}
-                label={t('modelViewer.openInSlicer')}
+                // Name the slicer when it is not the configured one. That happens
+                // when the configured slicer cannot take this format — an STL with
+                // Bambu Studio selected — and silently handing the file to the
+                // other one without saying so would be worse than the failure it
+                // replaces.
+                label={
+                  usableSlicers[0] === preferredSlicer
+                    ? t('modelViewer.openInSlicer')
+                    : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
+                }
                 dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => handleOpenInSlicer(preferredSlicer)}
+                onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
                 items={slicerDropdownItems}
               />
             ) : (

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

@@ -755,6 +755,10 @@ interface FileCardProps {
   onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
+  // Which slicer the desktop handoff targets. Decides whether an STL or STEP
+  // gets a Slice action at all: Bambu Studio's protocol handler takes 3MF only
+  // (#3029), so offering one there would only ever fail.
+  desktopSlicer: SlicerType;
   canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
@@ -768,7 +772,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -792,7 +796,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
     });
   }
-  if (isSliceableLibraryFile(file, !!useSlicerApi) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
+  if (isSliceableLibraryFile(file, !!useSlicerApi, desktopSlicer) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
     menuItems.push({
       label: t('slice.action'),
       icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
@@ -801,7 +805,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
     });
   }
-  if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true)) {
+  if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer)) {
     menuItems.push({
       label: t('library.runWithPipeline.actionLabel'),
       icon: <Play className="w-4 h-4" />,
@@ -2428,6 +2432,7 @@ export function FileManagerPage() {
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
                     onOpenInSlicer={handleOpenInSlicer}
+                    desktopSlicer={preferredSlicer}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     canSlice={canSlice()}
@@ -2606,7 +2611,7 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {isSliceableLibraryFile(file, !!settings?.use_slicer_api) && (
+                      {isSliceableLibraryFile(file, !!settings?.use_slicer_api, preferredSlicer) && (
                         <button
                           onClick={() => {
                             if (!canSlice()) return;
@@ -2623,7 +2628,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) && isSliceableLibraryFile(file, true) && (
+                      {(settings?.use_slicer_api ?? false) && isSliceableLibraryFile(file, true, preferredSlicer) && (
                         <button
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           className={`p-1.5 rounded transition-colors ${
@@ -2869,7 +2874,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).
-            isSliceableLibraryFile(viewerFile, true) && hasPermission('library:upload')
+            isSliceableLibraryFile(viewerFile, true, preferredSlicer) && hasPermission('library:upload')
               ? () => {
                   const f = viewerFile;
                   setViewerFile(null);

+ 10 - 2
frontend/src/utils/libraryFiles.ts

@@ -1,4 +1,4 @@
-import { isApiSliceableFilename, isSliceableFilename } from './slicer';
+import { isApiSliceableFilename, isSliceableFilename, type SlicerType } from './slicer';
 
 /**
  * Is a library file sliced — does it carry printer-executable G-code?
@@ -34,11 +34,19 @@ export function isSlicedLibraryFile(file: {
  * as name-bound as the Print gate it sits beside (#2993), so once a sliced
  * `Foo.3mf` correctly gained a Print button it would have been offered a
  * Slice one as well: re-slicing its own G-code.
+ *
+ * `desktopSlicer` is required even when `useSlicerApi` is true, where it is not
+ * consulted: the desktop answer depends on which slicer the URL is handed to
+ * (#3029), and a caller that cannot name one has not yet decided what its
+ * button will do.
  */
 export function isSliceableLibraryFile(
   file: { filename: string; file_type?: string | null },
   useSlicerApi: boolean,
+  desktopSlicer: SlicerType,
 ): boolean {
   if (isSlicedLibraryFile(file)) return false;
-  return useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename);
+  return useSlicerApi
+    ? isApiSliceableFilename(file.filename)
+    : isSliceableFilename(file.filename, desktopSlicer);
 }

+ 51 - 14
frontend/src/utils/slicer.ts

@@ -42,17 +42,49 @@ export function resolveDesktopSlicer(
 }
 
 /**
- * File types a slicer can be handed — both by the desktop URI handler and by
- * the in-app sidecar. Source geometry only: a sliced file is an output, and
- * neither slicer has anything to do with one.
+ * What each desktop slicer's protocol handler will actually load.
  *
- * Lives here rather than beside either caller because both the File Manager
- * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
+ * These are not the formats the two applications can open — both take an STL
+ * from File > Import perfectly well. They are what survives the *URL handoff*,
+ * which is a narrower thing, and the two slicers disagree about it (#3029).
+ *
+ * Bambu Studio routes every `bambustudio://open?file=` and `bambustudioopen://`
+ * URL into `Plater::import_model_id`, which refuses any filename that is not
+ * `.3mf` before it makes the HTTP request at all — "Download failed, unknown
+ * file format." Handing it an STL could only ever fail, and the message names
+ * the format rather than the handoff, so the failure reads as a broken file.
+ *
+ * OrcaSlicer sends only MakerWorld links and `bambustudioopen://` down that
+ * same 3MF-only path (`Downloader::start_download`). Everything else — which is
+ * what we emit for it, `orcaslicer://open?file=` against our own host — goes to
+ * its generic downloader, which has no extension check at all. So STL and STEP
+ * work there.
+ *
+ * Source geometry only either way: a sliced file is an output, and neither
+ * slicer has anything to do with one.
+ *
+ * This lives here rather than beside any caller because the File Manager (which
+ * has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
  * decide the same thing about the same file. They used to hold separate lists,
  * and the two disagreed — a card menu offered a desktop handoff for an STL
  * whose own 3D preview showed "Open in Slicer" greyed out.
  */
-export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
+export const DESKTOP_SLICEABLE_FILE_TYPES: Record<SlicerType, readonly string[]> = {
+  bambu_studio: ['3mf'],
+  orcaslicer: ['3mf', 'stl', 'step', 'stp'],
+};
+
+/**
+ * The formats `slicer` accepts over the handoff.
+ *
+ * Unknown values fall back to Bambu Studio's list rather than throwing, because
+ * that is what the handoff itself does: `openInSlicer` treats anything that is
+ * not exactly `orcaslicer` as Bambu Studio. `settings.open_in_slicer` comes off
+ * the API unvalidated, so the two have to agree on that.
+ */
+function desktopFormats(slicer: SlicerType): readonly string[] {
+  return DESKTOP_SLICEABLE_FILE_TYPES[slicer] ?? DESKTOP_SLICEABLE_FILE_TYPES.bambu_studio;
+}
 
 /**
  * The subset the *sidecar* can slice.
@@ -66,35 +98,40 @@ export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
 export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
 
 /**
- * Does a `LibraryFile.file_type` name a sliceable source file?
+ * Can `slicer` be handed this `LibraryFile.file_type` over the protocol handler?
  *
  * The backend stores compound extensions whole — a sliced 3MF classifies as
  * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
  * membership alone is enough to exclude sliced output here.
+ *
+ * The slicer is a required argument on purpose: the answer genuinely differs
+ * between the two, and a default would quietly reintroduce the STL handoff that
+ * Bambu Studio cannot honour.
  */
-export function isSliceableFileType(fileType?: string | null): boolean {
+export function isSliceableFileType(fileType: string | null | undefined, slicer: SlicerType): boolean {
   const normalized = (fileType || '').toLowerCase();
-  return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+  return desktopFormats(slicer).includes(normalized);
 }
 
 /**
- * Does a filename name a sliceable source file?
+ * Can `slicer` be handed this filename over the protocol handler?
  *
  * Checked against the name rather than a stored type, so the compound
  * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
  */
-export function isSliceableFilename(filename: string): boolean {
+export function isSliceableFilename(filename: string, slicer: SlicerType): boolean {
   const lower = filename.toLowerCase();
   if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
-  return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+  return desktopFormats(slicer).some((ext) => lower.endsWith(`.${ext}`));
 }
 
 /**
  * Does a filename name something the slicer *sidecar* can slice?
  *
- * Narrower than `isSliceableFilename` by exactly STEP — see
+ * Narrower than OrcaSlicer's handoff list 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.
+ * `/library/files/{id}/slice`; use `isSliceableFilename` for the desktop
+ * handoff, which needs to know which slicer it is handing to.
  */
 export function isApiSliceableFilename(filename: string): boolean {
   const lower = filename.toLowerCase();

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DlHDB85v.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-BQ97FBEj.js"></script>
+    <script type="module" crossorigin src="/assets/index-DlHDB85v.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff