Parcourir la source

Add configurable slicer preference — Bambu Studio or OrcaSlicer (#313)

Users can now choose their preferred slicer (Bambu Studio or OrcaSlicer)
in Settings → General. All "Open in Slicer" buttons across Archives,
3D Preview, and context menus use the correct protocol for the selected
slicer. OrcaSlicer uses orcaslicer://open?file=<URL> on all platforms;
Bambu Studio uses bambustudio:// (Windows) or bambustudioopen:// (macOS/
Linux). Default remains Bambu Studio for backward compatibility.
maziggy il y a 3 mois
Parent
commit
8d42b05f62

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.9b] - Not released
 ## [0.1.9b] - Not released
 
 
 ### New Features
 ### New Features
+- **Configurable Slicer Preference** ([#313](https://github.com/maziggy/bambuddy/issues/313)) — New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the `orcaslicer://open?file=` protocol. Default remains Bambu Studio for backward compatibility.
 - **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
 - **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
 - **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
 - **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.

+ 1 - 1
README.md

@@ -191,7 +191,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 </tr>
 </tr>
 </table>
 </table>
 
 
-**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
 
 
 ---
 ---
 
 

+ 7 - 0
backend/app/schemas/settings.py

@@ -130,6 +130,12 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
     )
 
 
+    # Preferred slicer application
+    preferred_slicer: str = Field(
+        default="bambu_studio",
+        description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
+    )
+
     # Prometheus metrics endpoint
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_token: str = Field(
     prometheus_token: str = Field(
@@ -191,5 +197,6 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     camera_view_mode: str | None = None
+    preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     prometheus_token: str | None = None

+ 48 - 0
backend/tests/unit/test_slicer_settings.py

@@ -0,0 +1,48 @@
+"""Unit tests for preferred_slicer setting in AppSettings schema."""
+
+import pytest
+
+from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+
+
+@pytest.mark.unit
+class TestPreferredSlicerSchema:
+    """Tests for the preferred_slicer field in settings schemas."""
+
+    def test_default_value_is_bambu_studio(self):
+        """Default preferred_slicer should be bambu_studio."""
+        settings = AppSettings()
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_set_to_orcaslicer(self):
+        """Should accept orcaslicer as a valid value."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        assert settings.preferred_slicer == "orcaslicer"
+
+    def test_set_to_bambu_studio_explicit(self):
+        """Should accept bambu_studio as an explicit value."""
+        settings = AppSettings(preferred_slicer="bambu_studio")
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_update_schema_default_is_none(self):
+        """AppSettingsUpdate preferred_slicer should default to None."""
+        update = AppSettingsUpdate()
+        assert update.preferred_slicer is None
+
+    def test_update_schema_accepts_value(self):
+        """AppSettingsUpdate should accept a preferred_slicer value."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        assert update.preferred_slicer == "orcaslicer"
+
+    def test_serialization_roundtrip(self):
+        """Settings should survive serialization roundtrip."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        data = settings.model_dump()
+        restored = AppSettings(**data)
+        assert restored.preferred_slicer == "orcaslicer"
+
+    def test_partial_update_preserves_other_fields(self):
+        """Updating preferred_slicer should not affect other fields."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        data = update.model_dump(exclude_none=True)
+        assert data == {"preferred_slicer": "orcaslicer"}

+ 17 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -118,6 +118,23 @@ describe('SettingsPage', () => {
       });
       });
     });
     });
 
 
+    it('shows preferred slicer setting', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows slicer dropdown with both options', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
+        expect(slicerSelect.length).toBeGreaterThan(0);
+      });
+    });
+
     it('shows appearance section', async () => {
     it('shows appearance section', async () => {
       render(<SettingsPage />);
       render(<SettingsPage />);
 
 

+ 115 - 0
frontend/src/__tests__/utils/slicer.test.ts

@@ -0,0 +1,115 @@
+/**
+ * Tests for the slicer utility functions.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { openInSlicer, detectPlatform, buildDownloadUrl } from '../../utils/slicer';
+
+describe('slicer utility', () => {
+  let clickSpy: ReturnType<typeof vi.fn>;
+  let appendSpy: ReturnType<typeof vi.fn>;
+  let removeSpy: ReturnType<typeof vi.fn>;
+  let createdLink: HTMLAnchorElement;
+
+  beforeEach(() => {
+    clickSpy = vi.fn();
+    appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
+      createdLink = node as HTMLAnchorElement;
+      return node;
+    });
+    removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
+
+    // Mock click on created elements
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('detectPlatform', () => {
+    it('detects Windows', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
+      expect(detectPlatform()).toBe('windows');
+    });
+
+    it('detects macOS', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
+      expect(detectPlatform()).toBe('macos');
+    });
+
+    it('detects Linux', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      expect(detectPlatform()).toBe('linux');
+    });
+  });
+
+  describe('openInSlicer', () => {
+    it('uses bambustudio:// protocol on Windows for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalled();
+      expect(createdLink.href).toContain('bambustudio://');
+      expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
+      expect(clickSpy).toHaveBeenCalled();
+      expect(removeSpy).toHaveBeenCalled();
+    });
+
+    it('uses bambustudioopen:// protocol on macOS for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses bambustudioopen:// protocol on Linux for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'orcaslicer');
+
+      expect(createdLink.href).toContain('orcaslicer://');
+      expect(createdLink.href).toContain('open?file=');
+    });
+
+    it('does not encode the file URL for orcaslicer', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      const url = 'http://localhost:8000/api/v1/archives/1/file/My Model.3mf';
+      openInSlicer(url, 'orcaslicer');
+
+      // The href should contain the raw URL (browser may normalize it but it should not be double-encoded)
+      expect(createdLink.href).toContain('orcaslicer://open?file=');
+      // Should NOT contain %253A (double-encoded colon)
+      expect(createdLink.href).not.toContain('%253A');
+    });
+
+    it('defaults to bambu_studio when no slicer specified', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf');
+
+      expect(createdLink.href).toContain('bambustudio://');
+    });
+
+    it('creates and removes a temporary link element', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalledOnce();
+      expect(clickSpy).toHaveBeenCalledOnce();
+      expect(removeSpy).toHaveBeenCalledOnce();
+    });
+  });
+
+  describe('buildDownloadUrl', () => {
+    it('prepends window.location.origin', () => {
+      const result = buildDownloadUrl('/api/v1/archives/1/file/test.3mf');
+      expect(result).toBe(`${window.location.origin}/api/v1/archives/1/file/test.3mf`);
+    });
+  });
+});

+ 2 - 0
frontend/src/api/client.ts

@@ -772,6 +772,8 @@ export interface AppSettings {
   library_disk_warning_gb: number;
   library_disk_warning_gb: number;
   // Camera view settings
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
   camera_view_mode: 'window' | 'embedded';
+  // Preferred slicer
+  preferred_slicer: 'bambu_studio' | 'orcaslicer';
   // Prometheus metrics
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_enabled: boolean;
   prometheus_token: string;
   prometheus_token: string;

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

@@ -1,11 +1,12 @@
 import { useState, useEffect, useRef, useMemo } from 'react';
 import { useState, useEffect, useRef, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
 import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } 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';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { openInSlicer } from '../utils/slicer';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
@@ -28,6 +29,8 @@ interface Capabilities {
 
 
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
   const isLibrary = libraryFileId != null;
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -266,11 +269,11 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     const filename = title || 'model';
     const filename = title || 'model';
     if (isLibrary) {
     if (isLibrary) {
       const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
       const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl);
+      openInSlicer(downloadUrl, preferredSlicer);
       return;
       return;
     }
     }
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl);
+    openInSlicer(downloadUrl, preferredSlicer);
   };
   };
 
 
   return (
   return (

+ 5 - 3
frontend/src/i18n/locales/de.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
     menu: {
       print: 'Drucken',
       print: 'Drucken',
       schedule: 'Planen',
       schedule: 'Planen',
-      openInBambuStudio: 'In Bambu Studio öffnen',
+      openInBambuStudio: 'Im Slicer öffnen',
       slice: 'Slicen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
       externalLink: 'Externer Link',
       viewOnMakerWorld: 'Auf MakerWorld ansehen',
       viewOnMakerWorld: 'Auf MakerWorld ansehen',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Drucken',
       reprint: 'Drucken',
       schedulePrint: 'Druck planen',
       schedulePrint: 'Druck planen',
       schedule: 'Planen',
       schedule: 'Planen',
-      openInBambuStudio: 'In Bambu Studio öffnen',
-      openInBambuStudioToSlice: 'In Bambu Studio öffnen zum Slicen',
+      openInBambuStudio: 'Im Slicer öffnen',
+      openInBambuStudioToSlice: 'Im Slicer öffnen zum Slicen',
       slice: 'Slicen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
       externalLink: 'Externer Link',
       makerWorld: 'MakerWorld: {{designer}}',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Archiv-Einstellungen',
     archiveSettings: 'Archiv-Einstellungen',
     newWindow: 'Neues Fenster',
     newWindow: 'Neues Fenster',
     embeddedOverlay: 'Eingebettetes Overlay',
     embeddedOverlay: 'Eingebettetes Overlay',
+    preferredSlicer: 'Bevorzugter Slicer',
+    preferredSlicerDescription: 'Wähle die Slicer-Anwendung zum Öffnen von Dateien',
     externalCameras: 'Externe Kameras',
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
     costTracking: 'Kostenverfolgung',
     printsOnly: 'Nur Drucke',
     printsOnly: 'Nur Drucke',

+ 5 - 3
frontend/src/i18n/locales/en.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
     menu: {
       print: 'Print',
       print: 'Print',
       schedule: 'Schedule',
       schedule: 'Schedule',
-      openInBambuStudio: 'Open in Bambu Studio',
+      openInBambuStudio: 'Open in Slicer',
       slice: 'Slice',
       slice: 'Slice',
       externalLink: 'External Link',
       externalLink: 'External Link',
       viewOnMakerWorld: 'View on MakerWorld',
       viewOnMakerWorld: 'View on MakerWorld',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Reprint',
       reprint: 'Reprint',
       schedulePrint: 'Schedule Print',
       schedulePrint: 'Schedule Print',
       schedule: 'Schedule',
       schedule: 'Schedule',
-      openInBambuStudio: 'Open in Bambu Studio',
-      openInBambuStudioToSlice: 'Open in Bambu Studio to slice',
+      openInBambuStudio: 'Open in Slicer',
+      openInBambuStudioToSlice: 'Open in Slicer to slice',
       slice: 'Slice',
       slice: 'Slice',
       externalLink: 'External Link',
       externalLink: 'External Link',
       makerWorld: 'MakerWorld: {{designer}}',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Archive Settings',
     archiveSettings: 'Archive Settings',
     newWindow: 'New Window',
     newWindow: 'New Window',
     embeddedOverlay: 'Embedded Overlay',
     embeddedOverlay: 'Embedded Overlay',
+    preferredSlicer: 'Preferred Slicer',
+    preferredSlicerDescription: 'Choose which slicer application to open files with',
     externalCameras: 'External Cameras',
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
     costTracking: 'Cost Tracking',
     printsOnly: 'Prints Only',
     printsOnly: 'Prints Only',

+ 5 - 3
frontend/src/i18n/locales/it.ts

@@ -512,7 +512,7 @@ export default {
     menu: {
     menu: {
       print: 'Stampa',
       print: 'Stampa',
       schedule: 'Programma',
       schedule: 'Programma',
-      openInBambuStudio: 'Apri in Bambu Studio',
+      openInBambuStudio: 'Apri nello slicer',
       slice: 'Slice',
       slice: 'Slice',
       externalLink: 'Link esterno',
       externalLink: 'Link esterno',
       viewOnMakerWorld: 'Vedi su MakerWorld',
       viewOnMakerWorld: 'Vedi su MakerWorld',
@@ -593,8 +593,8 @@ export default {
       reprint: 'Ristampa',
       reprint: 'Ristampa',
       schedulePrint: 'Programma Stampa',
       schedulePrint: 'Programma Stampa',
       schedule: 'Programma',
       schedule: 'Programma',
-      openInBambuStudio: 'Apri in Bambu Studio',
-      openInBambuStudioToSlice: 'Apri in Bambu Studio per slicing',
+      openInBambuStudio: 'Apri nello slicer',
+      openInBambuStudioToSlice: 'Apri nello slicer per slicing',
       slice: 'Slice',
       slice: 'Slice',
       externalLink: 'Link esterno',
       externalLink: 'Link esterno',
       makerWorld: 'MakerWorld: {{designer}}',
       makerWorld: 'MakerWorld: {{designer}}',
@@ -1254,6 +1254,8 @@ export default {
     archiveSettings: 'Impostazioni archivio',
     archiveSettings: 'Impostazioni archivio',
     newWindow: 'Nuova finestra',
     newWindow: 'Nuova finestra',
     embeddedOverlay: 'Overlay incorporato',
     embeddedOverlay: 'Overlay incorporato',
+    preferredSlicer: 'Slicer preferito',
+    preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',
     externalCameras: 'Camere esterne',
     externalCameras: 'Camere esterne',
     costTracking: 'Tracciamento costi',
     costTracking: 'Tracciamento costi',
     printsOnly: 'Solo stampe',
     printsOnly: 'Solo stampe',

+ 6 - 4
frontend/src/i18n/locales/ja.ts

@@ -551,7 +551,7 @@ export default {
       select: '選択',
       select: '選択',
       deselect: '選択解除',
       deselect: '選択解除',
       print: '印刷',
       print: '印刷',
-      openInBambuStudio: 'Bambu Studioで開く',
+      openInBambuStudio: 'スライサーで開く',
       scanForTimelapse: 'タイムラプスをスキャン',
       scanForTimelapse: 'タイムラプスをスキャン',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       viewPhotosCount: '写真を表示 ({{count}})',
       viewPhotosCount: '写真を表示 ({{count}})',
@@ -604,8 +604,8 @@ export default {
       uploadedBy: 'アップロード者',
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noPermissionReprint: '再印刷する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
-      openInBambuStudio: 'Bambu Studioで開く',
-      openInBambuStudioToSlice: 'Bambu Studioでスライス',
+      openInBambuStudio: 'スライサーで開く',
+      openInBambuStudioToSlice: 'スライサーでスライス',
       makerWorld: 'MakerWorld: {{designer}}',
       makerWorld: 'MakerWorld: {{designer}}',
       viewProject: 'プロジェクトを表示',
       viewProject: 'プロジェクトを表示',
       noExternalLink: '外部リンクなし',
       noExternalLink: '外部リンクなし',
@@ -673,7 +673,7 @@ export default {
       compare: '比較',
       compare: '比較',
       viewTimelapse: 'タイムラプスを表示',
       viewTimelapse: 'タイムラプスを表示',
       downloadTimelapse: 'タイムラプスをダウンロード',
       downloadTimelapse: 'タイムラプスをダウンロード',
-      openInBambuStudio: 'Bambu Studioで開く',
+      openInBambuStudio: 'スライサーで開く',
       openInOrcaSlicer: 'OrcaSlicerで開く',
       openInOrcaSlicer: 'OrcaSlicerで開く',
       addToProject: 'プロジェクトに追加',
       addToProject: 'プロジェクトに追加',
       removeFromProject: 'プロジェクトから削除',
       removeFromProject: 'プロジェクトから削除',
@@ -1309,6 +1309,8 @@ export default {
     configureBambuddy: 'Bambuddyを設定',
     configureBambuddy: 'Bambuddyを設定',
     newWindow: '新しいウィンドウ',
     newWindow: '新しいウィンドウ',
     embeddedOverlay: '埋め込みオーバーレイ',
     embeddedOverlay: '埋め込みオーバーレイ',
+    preferredSlicer: '優先スライサー',
+    preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',
     externalCameras: '外部カメラ',
     externalCameras: '外部カメラ',
     printsOnly: '印刷のみ',
     printsOnly: '印刷のみ',
     totalConsumption: '総消費量',
     totalConsumption: '総消費量',

+ 16 - 9
frontend/src/pages/ArchivesPage.tsx

@@ -48,7 +48,7 @@ import {
   User,
   User,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { openInSlicer } from '../utils/slicer';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import type { Archive, ProjectListItem } from '../api/client';
@@ -119,6 +119,7 @@ function ArchiveCard({
   projects,
   projects,
   isHighlighted,
   isHighlighted,
   timeFormat = 'system',
   timeFormat = 'system',
+  preferredSlicer = 'bambu_studio',
   t,
   t,
 }: {
 }: {
   archive: Archive;
   archive: Archive;
@@ -129,6 +130,7 @@ function ArchiveCard({
   projects: ProjectListItem[] | undefined;
   projects: ProjectListItem[] | undefined;
   isHighlighted?: boolean;
   isHighlighted?: boolean;
   timeFormat?: TimeFormat;
   timeFormat?: TimeFormat;
+  preferredSlicer?: SlicerType;
   t: TFunction;
   t: TFunction;
 }) {
 }) {
   // Debug: log when card is highlighted
   // Debug: log when card is highlighted
@@ -317,7 +319,7 @@ function ArchiveCard({
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
         },
       },
       },
     ] : [
     ] : [
@@ -327,7 +329,7 @@ function ArchiveCard({
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
         },
       },
       },
     ]),
     ]),
@@ -701,7 +703,7 @@ function ArchiveCard({
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
               const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
               const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
-              openInSlicer(downloadUrl);
+              openInSlicer(downloadUrl, preferredSlicer);
             }}
             }}
             title={t('archives.card.openSource3mf')}
             title={t('archives.card.openSource3mf')}
           >
           >
@@ -948,7 +950,7 @@ function ArchiveCard({
                 onClick={() => {
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
                   const filename = archive.print_name || archive.filename || 'model';
                   const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
                   const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  openInSlicer(downloadUrl);
+                  openInSlicer(downloadUrl, preferredSlicer);
                 }}
                 }}
                 title={t('archives.card.openInBambuStudio')}
                 title={t('archives.card.openInBambuStudio')}
               >
               >
@@ -964,7 +966,7 @@ function ArchiveCard({
               onClick={() => {
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
                 const filename = archive.print_name || archive.filename || 'model';
                 const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
                 const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                openInSlicer(downloadUrl);
+                openInSlicer(downloadUrl, preferredSlicer);
               }}
               }}
               title={t('archives.card.openInBambuStudioToSlice')}
               title={t('archives.card.openInBambuStudioToSlice')}
             >
             >
@@ -1276,6 +1278,7 @@ function ArchiveListRow({
   selectionMode,
   selectionMode,
   projects,
   projects,
   isHighlighted,
   isHighlighted,
+  preferredSlicer = 'bambu_studio',
   t,
   t,
 }: {
 }: {
   archive: Archive;
   archive: Archive;
@@ -1285,6 +1288,7 @@ function ArchiveListRow({
   selectionMode: boolean;
   selectionMode: boolean;
   projects: ProjectListItem[] | undefined;
   projects: ProjectListItem[] | undefined;
   isHighlighted?: boolean;
   isHighlighted?: boolean;
+  preferredSlicer?: SlicerType;
   t: TFunction;
   t: TFunction;
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -1450,7 +1454,7 @@ function ArchiveListRow({
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
         },
       },
       },
     ] : [
     ] : [
@@ -1460,7 +1464,7 @@ function ArchiveListRow({
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl);
+          openInSlicer(downloadUrl, preferredSlicer);
         },
         },
       },
       },
     ]),
     ]),
@@ -1770,7 +1774,7 @@ function ArchiveListRow({
             onClick={() => {
             onClick={() => {
               const filename = archive.print_name || archive.filename || 'model';
               const filename = archive.print_name || archive.filename || 'model';
               const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
               const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-              openInSlicer(downloadUrl);
+              openInSlicer(downloadUrl, preferredSlicer);
             }}
             }}
             title={t('archives.card.openInBambuStudio')}
             title={t('archives.card.openInBambuStudio')}
           >
           >
@@ -2161,6 +2165,7 @@ export function ArchivesPage() {
   });
   });
 
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const timeFormat: TimeFormat = settings?.time_format || 'system';
+  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
 
 
   const bulkDeleteMutation = useMutation({
   const bulkDeleteMutation = useMutation({
     mutationFn: async (ids: number[]) => {
     mutationFn: async (ids: number[]) => {
@@ -2910,6 +2915,7 @@ export function ArchivesPage() {
               projects={projects}
               projects={projects}
               isHighlighted={archive.id === highlightedArchiveId}
               isHighlighted={archive.id === highlightedArchiveId}
               timeFormat={timeFormat}
               timeFormat={timeFormat}
+              preferredSlicer={preferredSlicer}
               t={t}
               t={t}
             />
             />
           ))}
           ))}
@@ -2937,6 +2943,7 @@ export function ArchivesPage() {
                 selectionMode={selectionMode}
                 selectionMode={selectionMode}
                 projects={projects}
                 projects={projects}
                 isHighlighted={archive.id === highlightedArchiveId}
                 isHighlighted={archive.id === highlightedArchiveId}
+                preferredSlicer={preferredSlicer}
                 t={t}
                 t={t}
               />
               />
             ))}
             ))}

+ 22 - 0
frontend/src/pages/SettingsPage.tsx

@@ -750,6 +750,7 @@ export function SettingsPage() {
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
       (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
+      (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token;
       settings.prometheus_token !== localSettings.prometheus_token;
 
 
@@ -813,6 +814,7 @@ export function SettingsPage() {
         library_archive_mode: localSettings.library_archive_mode,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
         camera_view_mode: localSettings.camera_view_mode,
+        preferred_slicer: localSettings.preferred_slicer,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
         prometheus_token: localSettings.prometheus_token,
       };
       };
@@ -1154,6 +1156,25 @@ export function SettingsPage() {
                   Pre-select this printer for uploads, reprints, and other operations.
                   Pre-select this printer for uploads, reprints, and other operations.
                 </p>
                 </p>
               </div>
               </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('settings.preferredSlicer')}
+                </label>
+                <div className="relative">
+                  <select
+                    value={localSettings.preferred_slicer ?? 'bambu_studio'}
+                    onChange={(e) => updateSetting('preferred_slicer', e.target.value as 'bambu_studio' | 'orcaslicer')}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    <option value="bambu_studio">Bambu Studio</option>
+                    <option value="orcaslicer">OrcaSlicer</option>
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('settings.preferredSlicerDescription')}
+                </p>
+              </div>
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
                   <p className="text-white">{t('settings.sidebarOrder')}</p>
                   <p className="text-white">{t('settings.sidebarOrder')}</p>
@@ -1476,6 +1497,7 @@ export function SettingsPage() {
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 
+
           <Card>
           <Card>
             <CardHeader>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>
               <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>

+ 33 - 24
frontend/src/utils/slicer.ts

@@ -1,11 +1,16 @@
 /**
 /**
- * Utility for opening files in Bambu Studio slicer
+ * Utility for opening files in slicer applications
  *
  *
- * The URL protocol handler is OS-specific:
- * - Windows: bambustudio://
- * - macOS/Linux: bambustudioopen://
+ * Bambu Studio URL protocol is OS-specific:
+ * - Windows: bambustudio://<encoded-URL>
+ * - macOS/Linux: bambustudioopen://<encoded-URL>
+ *
+ * OrcaSlicer uses the same protocol on all platforms:
+ * - orcaslicer://open?file=<URL>
  */
  */
 
 
+export type SlicerType = 'bambu_studio' | 'orcaslicer';
+
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 
 
 /**
 /**
@@ -28,28 +33,31 @@ export function detectPlatform(): Platform {
 }
 }
 
 
 /**
 /**
- * Get the appropriate slicer protocol for the current OS
+ * Open a URL in the specified slicer application.
+ * Uses a temporary link element to trigger the protocol handler,
+ * which avoids browser "unknown protocol" blocks on window.location.href.
+ * @param downloadUrl - The URL to the file to open
+ * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
  */
-export function getSlicerProtocol(): string {
-  const platform = detectPlatform();
+export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
+  let url: string;
 
 
-  switch (platform) {
-    case 'windows':
-      return 'bambustudio://';
-    case 'macos':
-    case 'linux':
-    default:
-      return 'bambustudioopen://';
+  if (slicer === 'orcaslicer') {
+    url = `orcaslicer://open?file=${downloadUrl}`;
+  } else {
+    const platform = detectPlatform();
+    const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
+    url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
   }
   }
-}
 
 
-/**
- * Open a URL in Bambu Studio slicer
- * @param downloadUrl - The URL to the file to open (will be encoded)
- */
-export function openInSlicer(downloadUrl: string): void {
-  const protocol = getSlicerProtocol();
-  window.location.href = `${protocol}${encodeURIComponent(downloadUrl)}`;
+  // Use a temporary <a> element to trigger the protocol handler.
+  // This works more reliably than window.location.href for custom protocols.
+  const link = document.createElement('a');
+  link.href = url;
+  link.style.display = 'none';
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
 }
 }
 
 
 /**
 /**
@@ -63,8 +71,9 @@ export function buildDownloadUrl(path: string): string {
 /**
 /**
  * Convenience function to open an archive in the slicer
  * Convenience function to open an archive in the slicer
  * @param path - The API path to the archive
  * @param path - The API path to the archive
+ * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
  */
-export function openArchiveInSlicer(path: string): void {
+export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {
   const downloadUrl = buildDownloadUrl(path);
   const downloadUrl = buildDownloadUrl(path);
-  openInSlicer(downloadUrl);
+  openInSlicer(downloadUrl, slicer);
 }
 }

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-47EQ7Zpi.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CbUXXMeN.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Csb7GscS.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C468hjMw.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-47EQ7Zpi.css">
+    <script type="module" crossorigin src="/assets/index-CbUXXMeN.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Csb7GscS.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff