Ver Fonte

Added option to open/slice files in local slicers when slicer api is enabled

Pascal há 1 mês atrás
pai
commit
eda2e04599

+ 162 - 0
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { setStreamToken } from '../../api/client';
 import { setStreamToken } from '../../api/client';
+import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
@@ -28,6 +29,10 @@ vi.mock('../../components/GcodeViewer', () => ({
   ),
   ),
 }));
 }));
 
 
+vi.mock('../../utils/slicer', () => ({
+  openInSlicer: vi.fn(),
+}));
+
 const mockCapabilities = {
 const mockCapabilities = {
   has_model: true,
   has_model: true,
   has_gcode: true,
   has_gcode: true,
@@ -528,4 +533,161 @@ describe('ModelViewerModal', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('slicer split button (#2725)', () => {
+    it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      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();
+      });
+    });
+
+    it('opens the selected local slicer from the Bambuddy dropdown', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      const orcaItem = await screen.findByText('Open in OrcaSlicer');
+      fireEvent.click(orcaItem);
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
+      });
+    });
+
+    it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
+    });
+
+    it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
+    });
+
+    it('does not render a split chevron when the file cannot open in a slicer', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled();
+      });
+
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+    });
+
+    it('renders a plain Slice button without a split chevron for non-handoff files', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+    });
+  });
 });
 });

+ 133 - 12
frontend/src/components/ModelViewerModal.tsx

@@ -1,7 +1,7 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
+import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -32,6 +32,98 @@ interface Capabilities {
   filament_colors: string[];
   filament_colors: string[];
 }
 }
 
 
+interface SlicerSplitButtonProps {
+  icon: ReactNode;
+  label: string;
+  dropdownLabel: string;
+  onPrimary: () => void;
+  disabled?: boolean;
+  items: Array<{ key: string; label: string; onClick: () => void }>;
+}
+
+// Split button: the primary part runs the default slicer action, the chevron
+// opens a dropdown with the other slicer options. Outside click or Escape
+// (non-propagating) closes the dropdown.
+function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = false, items }: SlicerSplitButtonProps) {
+  const [open, setOpen] = useState(false);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handlePointerDown = (e: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+        setOpen(false);
+      }
+    };
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        e.stopPropagation();
+        setOpen(false);
+      }
+    };
+    document.addEventListener('mousedown', handlePointerDown);
+    document.addEventListener('keydown', handleKeyDown);
+    return () => {
+      document.removeEventListener('mousedown', handlePointerDown);
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [open]);
+
+  return (
+    <div className="relative inline-flex" ref={containerRef}>
+      <div className="flex relative z-50">
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => {
+            setOpen(false);
+            onPrimary();
+          }}
+          disabled={disabled}
+          className="rounded-r-none"
+        >
+          {icon}
+          {label}
+        </Button>
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => setOpen((prev) => !prev)}
+          disabled={disabled}
+          aria-label={dropdownLabel}
+          aria-haspopup="menu"
+          aria-expanded={open}
+          className="rounded-l-none border-l border-bambu-dark px-2"
+        >
+          <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
+        </Button>
+      </div>
+      {open && (
+        <div
+          role="menu"
+          className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
+        >
+          {items.map((item) => (
+            <button
+              key={item.key}
+              type="button"
+              role="menuitem"
+              onClick={() => {
+                setOpen(false);
+                item.onClick();
+              }}
+              className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
+            >
+              <ExternalLink className="w-4 h-4 flex-shrink-0" />
+              {item.label}
+            </button>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -296,27 +388,38 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
     isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
   );
   );
 
 
-  const handleOpenInSlicer = async () => {
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
+    ? ['bambu_studio', 'orcaslicer']
+    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  const slicerName = (slicer: SlicerType) =>
+    slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
+  const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
+    key: slicer,
+    label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
+    onClick: () => handleOpenInSlicer(slicer),
+  }));
+
+  const handleOpenInSlicer = async (slicer: SlicerType) => {
     if (!canOpenInSlicer) return;
     if (!canOpenInSlicer) return;
     const filename = title || 'model';
     const filename = title || 'model';
     try {
     try {
       if (isLibrary) {
       if (isLibrary) {
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       } else {
       } else {
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       }
       }
     } catch {
     } catch {
       // Fallback to direct URL (works when auth is disabled)
       // Fallback to direct URL (works when auth is disabled)
       if (isLibrary) {
       if (isLibrary) {
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       } else {
       } else {
         const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
         const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       }
       }
     }
     }
   };
   };
@@ -344,12 +447,30 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
           </div>
           </div>
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
             {useBambuddySlicer ? (
             {useBambuddySlicer ? (
-              <Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
-                <Cog className="w-4 h-4" />
-                {t('slice.action')}
-              </Button>
+              canOpenInSlicer ? (
+                <SlicerSplitButton
+                  icon={<Cog className="w-4 h-4" />}
+                  label={t('slice.action')}
+                  dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                  onPrimary={() => onSliceWithBambuddy?.()}
+                  items={slicerDropdownItems}
+                />
+              ) : (
+                <Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
+                  <Cog className="w-4 h-4" />
+                  {t('slice.action')}
+                </Button>
+              )
+            ) : canOpenInSlicer ? (
+              <SlicerSplitButton
+                icon={<ExternalLink className="w-4 h-4" />}
+                label={t('modelViewer.openInSlicer')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => handleOpenInSlicer(preferredSlicer)}
+                items={slicerDropdownItems}
+              />
             ) : (
             ) : (
-              <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
+              <Button variant="secondary" size="sm" onClick={() => handleOpenInSlicer(preferredSlicer)} disabled>
                 <ExternalLink className="w-4 h-4" />
                 <ExternalLink className="w-4 h-4" />
                 {t('modelViewer.openInSlicer')}
                 {t('modelViewer.openInSlicer')}
               </Button>
               </Button>

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -5293,6 +5293,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Im Slicer öffnen',
     openInSlicer: 'Im Slicer öffnen',
+    openInSlicerWith: 'In {{slicer}} öffnen',
+    moreSlicerOptions: 'Weitere Slicer-Optionen',
     tabs: {
     tabs: {
       model: '3D-Modell',
       model: '3D-Modell',
       gcode: 'G-Code Vorschau',
       gcode: 'G-Code Vorschau',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -5337,6 +5337,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Open in Slicer',
     openInSlicer: 'Open in Slicer',
+    openInSlicerWith: 'Open in {{slicer}}',
+    moreSlicerOptions: 'More slicer options',
     tabs: {
     tabs: {
       model: '3D Model',
       model: '3D Model',
       gcode: 'G-code Preview',
       gcode: 'G-code Preview',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -5302,6 +5302,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Abrir en el laminador',
     openInSlicer: 'Abrir en el laminador',
+    openInSlicerWith: 'Abrir en {{slicer}}',
+    moreSlicerOptions: 'Más opciones de laminador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
       gcode: 'Vista previa de G-code',
       gcode: 'Vista previa de G-code',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -5283,6 +5283,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Ouvrir dans le Slicer',
     openInSlicer: 'Ouvrir dans le Slicer',
+    openInSlicerWith: 'Ouvrir dans {{slicer}}',
+    moreSlicerOptions: "Plus d'options de slicer",
     tabs: {
     tabs: {
       model: 'Modèle 3D',
       model: 'Modèle 3D',
       gcode: 'Aperçu G-code',
       gcode: 'Aperçu G-code',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -5282,6 +5282,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Apri nello slicer',
     openInSlicer: 'Apri nello slicer',
+    openInSlicerWith: 'Apri in {{slicer}}',
+    moreSlicerOptions: 'Altre opzioni dello slicer',
     tabs: {
     tabs: {
       model: 'Modello 3D',
       model: 'Modello 3D',
       gcode: 'Anteprima G-code',
       gcode: 'Anteprima G-code',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -5294,6 +5294,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'スライサーで開く',
     openInSlicer: 'スライサーで開く',
+    openInSlicerWith: '{{slicer}}で開く',
+    moreSlicerOptions: 'その他のスライサーオプション',
     tabs: {
     tabs: {
       model: '3Dモデル',
       model: '3Dモデル',
       gcode: 'G-codeプレビュー',
       gcode: 'G-codeプレビュー',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -5029,6 +5029,8 @@ export default {
   },
   },
   modelViewer: {
   modelViewer: {
     openInSlicer: '슬라이서에서 열기',
     openInSlicer: '슬라이서에서 열기',
+    openInSlicerWith: '{{slicer}}에서 열기',
+    moreSlicerOptions: '슬라이서 옵션 더 보기',
     tabs: {
     tabs: {
       model: '3D 모델',
       model: '3D 모델',
       gcode: 'G-code 미리보기'
       gcode: 'G-code 미리보기'

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5282,6 +5282,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Abrir no Slicer',
     openInSlicer: 'Abrir no Slicer',
+    openInSlicerWith: 'Abrir em {{slicer}}',
+    moreSlicerOptions: 'Mais opções de fatiador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
       gcode: 'Pré-visualização G-code',
       gcode: 'Pré-visualização G-code',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -5017,6 +5017,8 @@ export default {
   },
   },
   modelViewer: {
   modelViewer: {
     openInSlicer: "Открыть в слайсере",
     openInSlicer: "Открыть в слайсере",
+    openInSlicerWith: "Открыть в {{slicer}}",
+    moreSlicerOptions: "Другие варианты слайсера",
     tabs: {
     tabs: {
       model: "3D-модель",
       model: "3D-модель",
       gcode: "Предпросмотр G-code",
       gcode: "Предпросмотр G-code",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -5258,6 +5258,8 @@ export default {
   // Model Görüntüleyici
   // Model Görüntüleyici
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Dilimleyicide Aç',
     openInSlicer: 'Dilimleyicide Aç',
+    openInSlicerWith: '{{slicer}} ile aç',
+    moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
     tabs: {
     tabs: {
       model: '3B Model',
       model: '3B Model',
       gcode: 'G-kod Önizleme',
       gcode: 'G-kod Önizleme',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5282,6 +5282,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: '在切片软件中打开',
     openInSlicer: '在切片软件中打开',
+    openInSlicerWith: '用{{slicer}}打开',
+    moreSlicerOptions: '更多切片软件选项',
     tabs: {
     tabs: {
       model: '3D 模型',
       model: '3D 模型',
       gcode: 'G-code 预览',
       gcode: 'G-code 预览',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5282,6 +5282,8 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: '在切片軟體中開啟',
     openInSlicer: '在切片軟體中開啟',
+    openInSlicerWith: '用{{slicer}}開啟',
+    moreSlicerOptions: '更多切片軟體選項',
     tabs: {
     tabs: {
       model: '3D 模型',
       model: '3D 模型',
       gcode: 'G-code 預覽',
       gcode: 'G-code 預覽',