Browse Source

● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar

  Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
maziggy 1 month ago
parent
commit
6deaa513af
40 changed files with 3091 additions and 76 deletions
  1. 0 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 100 0
      backend/app/api/routes/archives.py
  4. 427 0
      backend/app/api/routes/library.py
  5. 44 0
      backend/app/api/routes/slice_jobs.py
  6. 9 0
      backend/app/core/config.py
  7. 2 0
      backend/app/main.py
  8. 25 0
      backend/app/schemas/settings.py
  9. 46 0
      backend/app/schemas/slicer.py
  10. 152 0
      backend/app/services/slice_dispatch.py
  11. 241 0
      backend/app/services/slicer_api.py
  12. 445 0
      backend/tests/integration/test_library_slice_api.py
  13. 219 0
      backend/tests/unit/services/test_slicer_api.py
  14. 7 0
      docker-compose.yml
  15. 3 0
      frontend/src/App.tsx
  16. 185 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  17. 21 2
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  18. 71 0
      frontend/src/api/client.ts
  19. 207 0
      frontend/src/components/SliceModal.tsx
  20. 111 0
      frontend/src/contexts/SliceJobTrackerContext.tsx
  21. 27 0
      frontend/src/i18n/locales/de.ts
  22. 27 0
      frontend/src/i18n/locales/en.ts
  23. 27 0
      frontend/src/i18n/locales/fr.ts
  24. 27 0
      frontend/src/i18n/locales/it.ts
  25. 27 0
      frontend/src/i18n/locales/ja.ts
  26. 27 0
      frontend/src/i18n/locales/pt-BR.ts
  27. 27 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 27 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 54 11
      frontend/src/pages/ArchivesPage.tsx
  30. 71 4
      frontend/src/pages/FileManagerPage.tsx
  31. 83 31
      frontend/src/pages/MakerworldPage.tsx
  32. 93 20
      frontend/src/pages/SettingsPage.tsx
  33. 69 6
      gcode_viewer/js/bambuddy_adapter.js
  34. 12 0
      slicer-api/.env.example
  35. 2 0
      slicer-api/.gitignore
  36. 102 0
      slicer-api/README.md
  37. 71 0
      slicer-api/docker-compose.yml
  38. 0 0
      static/assets/index-BmSEfzJC.css
  39. 0 0
      static/assets/index-Ctop_H29.js
  40. 2 2
      static/index.html

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -168,6 +168,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Plate selection for multi-plate 3MF files
 - Duplicate detection via file hash
 - Mobile-friendly with always-visible action buttons
+- **Server-side Slice button** (optional) — slice STL/3MF without a desktop slicer when the [`slicer-api/` Compose stack](slicer-api/README.md) is running; the result lands as a new `.gcode.3mf` in the same folder, with progress shown via a toast tracker that follows the job to completion
 
 ### 🌍 MakerWorld Integration
 - Paste any `makerworld.com/models/…` URL → preview, plate picker, and import without leaving Bambuddy

+ 100 - 0
backend/app/api/routes/archives.py

@@ -25,6 +25,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
@@ -2818,6 +2819,10 @@ async def get_archive_plates(
         raise HTTPException(404, "Archive file not found")
 
     plates = []
+    # Initialize so the `has_gcode = bool(gcode_files)` after the try/except
+    # never raises NameError when the archive isn't a valid zip (e.g. plain
+    # .gcode file from a sliced-archive flow that didn't request 3MF output).
+    gcode_files: list[str] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
@@ -3231,6 +3236,101 @@ async def get_filament_requirements(
     }
 
 
+@router.post("/{archive_id}/slice", status_code=202)
+async def slice_archive(
+    archive_id: int,
+    request: SliceRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """Enqueue a slice job for an archive's source. Returns 202 + job_id;
+    the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
+
+    Source preference: ``source_3mf_path`` (the un-sliced project file the
+    user originally sent to slice) → ``file_path`` (the sliced 3MF/gcode that
+    actually printed).
+    """
+    from backend.app.api.routes.library import slice_and_persist_as_archive
+    from backend.app.core.database import async_session
+    from backend.app.services.slice_dispatch import (
+        http_exception_to_job_error,
+        slice_dispatch,
+    )
+
+    archive = await db.get(PrintArchive, archive_id)
+    if archive is None:
+        raise HTTPException(status_code=404, detail="Archive not found")
+
+    src_relative = archive.source_3mf_path or archive.file_path
+    if not src_relative:
+        raise HTTPException(
+            status_code=400,
+            detail="Archive has no source file to slice",
+        )
+
+    src_path = Path(settings.base_dir) / src_relative
+    if not src_path.exists():
+        raise HTTPException(status_code=404, detail="Archive source file missing on disk")
+
+    raw_filename = archive.filename or src_path.name
+    src_lower = raw_filename.lower()
+    if not (
+        src_lower.endswith(".stl")
+        or src_lower.endswith(".3mf")
+        or src_lower.endswith(".step")
+        or src_lower.endswith(".stp")
+    ):
+        raise HTTPException(
+            status_code=400,
+            detail="Archive's source file must be STL, 3MF, or STEP to slice",
+        )
+
+    # Match the library route: derive the sliced output's filename from
+    # `print_name` when set, so the new archive row's display name lines
+    # up with the source's display.
+    src_ext = Path(raw_filename).suffix.lower() or ".3mf"
+    src_filename = (
+        f"{archive.print_name.strip()}{src_ext}" if archive.print_name and archive.print_name.strip() else raw_filename
+    )
+
+    model_bytes = src_path.read_bytes()
+    archive_id_local = archive.id
+    user_id = current_user.id if current_user else None
+
+    async def _run():
+        async with async_session() as task_db:
+            # Re-fetch the source archive on the background-task session.
+            src_archive = await task_db.get(PrintArchive, archive_id_local)
+            if src_archive is None:
+                raise http_exception_to_job_error(
+                    HTTPException(status_code=404, detail="Archive disappeared during slice")
+                )
+            try:
+                response = await slice_and_persist_as_archive(
+                    task_db,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    request=request,
+                    source_archive=src_archive,
+                    current_user_id=user_id,
+                )
+            except HTTPException as exc:
+                raise http_exception_to_job_error(exc) from exc
+        return response.model_dump()
+
+    job = await slice_dispatch.enqueue(
+        kind="archive",
+        source_id=archive.id,
+        source_name=archive.print_name or archive.filename or f"archive {archive.id}",
+        run=_run,
+    )
+    return {
+        "job_id": job.id,
+        "status": job.status,
+        "status_url": f"/api/v1/slice-jobs/{job.id}",
+    }
+
+
 @router.post("/{archive_id}/reprint")
 async def reprint_archive(
     archive_id: int,

+ 427 - 0
backend/app/api/routes/library.py

@@ -57,6 +57,7 @@ from backend.app.schemas.library import (
     ZipExtractResponse,
     ZipExtractResult,
 )
+from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
@@ -2369,6 +2370,432 @@ async def get_library_file_filament_requirements(
     }
 
 
+def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
+    """Remove ``Metadata/project_settings.config`` from a 3MF.
+
+    Bambuddy supplies the slicer profile triplet via the sidecar's
+    ``--load-settings`` path; the 3MF's embedded settings would otherwise be
+    validated by the CLI first and can fail with sentinel-value range
+    checks (`prime_tower_brim_width: -1 not in range`, etc.) regardless of
+    what we pass via ``--load-settings``. Stripping the embedded config
+    forces the CLI to use the supplied profiles only. Geometry, color, and
+    multi-part data inside the 3MF are preserved.
+    """
+    from io import BytesIO
+
+    src = BytesIO(zip_bytes)
+    dst = BytesIO()
+    with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+        for item in zin.infolist():
+            if item.filename == "Metadata/project_settings.config":
+                continue
+            zout.writestr(item, zin.read(item.filename))
+    return dst.getvalue()
+
+
+async def _run_slicer_with_fallback(
+    db: AsyncSession,
+    *,
+    model_bytes: bytes,
+    model_filename: str,
+    request: SliceRequest,
+):
+    """Validate presets, dispatch to the right sidecar, run the slicer with
+    the auto-fallback for 3MF inputs whose `--load-settings` path crashes the
+    CLI. Returns ``(SliceResult, used_embedded_settings: bool)``. Raises
+    ``HTTPException`` for any caller-facing error.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.local_preset import LocalPreset
+    from backend.app.services.slicer_api import (
+        SlicerApiServerError,
+        SlicerApiService,
+        SlicerApiUnavailableError,
+        SlicerInputError,
+    )
+
+    # Profile triplet — every slot must match the expected preset_type
+    presets: dict[str, str] = {}
+    for pid, expected_type, key in (
+        (request.printer_preset_id, "printer", "printer"),
+        (request.process_preset_id, "process", "process"),
+        (request.filament_preset_id, "filament", "filament"),
+    ):
+        preset = await db.get(LocalPreset, pid)
+        if preset is None or preset.preset_type != expected_type:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Invalid {key} preset id (expected preset_type='{expected_type}')",
+            )
+        presets[key] = preset.setting
+
+    # Slicer routing — pick the sidecar URL by preferred_slicer.
+    # The per-install URL setting (Settings UI → Slicer card) wins; an
+    # empty value falls back to the SLICER_API_URL / BAMBU_STUDIO_API_URL
+    # env defaults defined in core/config.py.
+    preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
+    if preferred == "orcaslicer":
+        configured = await get_setting(db, "orcaslicer_api_url")
+        api_url = (configured or app_settings.slicer_api_url).strip()
+    elif preferred == "bambu_studio":
+        configured = await get_setting(db, "bambu_studio_api_url")
+        api_url = (configured or app_settings.bambu_studio_api_url).strip()
+    else:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Unknown preferred_slicer setting: '{preferred}'. Expected 'orcaslicer' or 'bambu_studio'.",
+        )
+
+    is_3mf = model_filename.lower().endswith(".3mf")
+    primary_bytes = model_bytes
+    if is_3mf:
+        try:
+            primary_bytes = _strip_3mf_embedded_settings(model_bytes)
+        except (zipfile.BadZipFile, KeyError) as exc:
+            raise HTTPException(status_code=400, detail=f"Source 3MF is corrupt: {exc}") from exc
+
+    used_embedded_settings = False
+    service = SlicerApiService(api_url)
+    try:
+        try:
+            result = await service.slice_with_profiles(
+                model_bytes=primary_bytes,
+                model_filename=model_filename,
+                printer_profile_json=presets["printer"],
+                process_profile_json=presets["process"],
+                filament_profile_json=presets["filament"],
+                plate=request.plate,
+                export_3mf=request.export_3mf,
+            )
+        except SlicerApiServerError as exc:
+            if not is_3mf:
+                raise
+            logger.warning(
+                "Slicer CLI rejected --load-settings for %s (%s); retrying with embedded settings",
+                model_filename,
+                exc,
+            )
+            result = await service.slice_without_profiles(
+                model_bytes=model_bytes,
+                model_filename=model_filename,
+                plate=request.plate,
+                export_3mf=request.export_3mf,
+            )
+            used_embedded_settings = True
+    except SlicerInputError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    except SlicerApiServerError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+    except SlicerApiUnavailableError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+    finally:
+        await service.close()
+
+    return result, used_embedded_settings
+
+
+async def slice_and_persist(
+    db: AsyncSession,
+    *,
+    model_bytes: bytes,
+    model_filename: str,
+    folder_id: int | None,
+    extra_metadata: dict | None,
+    request: SliceRequest,
+    current_user_id: int | None,
+) -> SliceResponse:
+    """Slice a model and save the result as a new ``LibraryFile`` in
+    ``folder_id`` (same folder as the source by convention).
+
+    Always exports as ``.gcode.3mf`` so the existing library thumbnail
+    pipeline works on the new file. Plain ``.gcode`` would have no
+    embedded thumbnail to extract.
+    """
+    from backend.app.services.archive import ThreeMFParser
+
+    library_request = request.model_copy(update={"export_3mf": True})
+
+    result, used_embedded_settings = await _run_slicer_with_fallback(
+        db,
+        model_bytes=model_bytes,
+        model_filename=model_filename,
+        request=library_request,
+    )
+
+    base_name = model_filename.rsplit(".", 1)[0]
+    out_filename = f"{base_name}.gcode.3mf"
+    unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
+    out_path = get_library_files_dir() / unique_name
+    out_path.write_bytes(result.content)
+
+    # Extract thumbnail from the produced 3MF so the library card shows a
+    # preview. Failures here aren't fatal — the file is still useful
+    # without a thumbnail.
+    thumbnail_relative: str | None = None
+    parsed_metadata: dict = {}
+    try:
+        parser = ThreeMFParser(str(out_path))
+        parsed = parser.parse()
+        thumb_data = parsed.get("_thumbnail_data")
+        thumb_ext = parsed.get("_thumbnail_ext", ".png")
+        if thumb_data:
+            thumb_filename = f"{uuid.uuid4().hex}{thumb_ext}"
+            thumb_path = get_library_thumbnails_dir() / thumb_filename
+            thumb_path.write_bytes(thumb_data)
+            thumbnail_relative = to_relative_path(thumb_path)
+        cleaned = _clean_3mf_metadata(parsed)
+        if isinstance(cleaned, dict):
+            parsed_metadata = cleaned
+    except Exception as exc:
+        logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
+
+    # The parsed 3MF metadata carries a `print_name` lifted from the source
+    # file's embedded settings (BambuStudio always sets this; OrcaSlicer
+    # often leaves it blank). The FileManager listing prefers print_name
+    # over filename for display, which makes a sliced row indistinguishable
+    # from its source. Drop print_name so the listing falls back to the
+    # actual filename — which already ends in ".gcode.3mf" and self-describes
+    # as the sliced output.
+    metadata: dict = {k: v for k, v in parsed_metadata.items() if k != "print_name"}
+    metadata.update(
+        {
+            "print_time_seconds": result.print_time_seconds,
+            "filament_used_g": result.filament_used_g,
+            "filament_used_mm": result.filament_used_mm,
+        }
+    )
+    if used_embedded_settings:
+        metadata["used_embedded_settings"] = True
+    if extra_metadata:
+        metadata.update(extra_metadata)
+
+    new_file = LibraryFile(
+        folder_id=folder_id,
+        filename=out_filename,
+        file_path=to_relative_path(out_path),
+        # Sliced output is a `.gcode.3mf` zip with embedded G-code, but the
+        # user-facing meaning is "ready-to-print G-code" — using "gcode"
+        # gives it the same badge as plain .gcode files and distinguishes
+        # it from un-sliced `.3mf` source models.
+        file_type="gcode",
+        file_size=len(result.content),
+        file_hash=hashlib.sha256(result.content).hexdigest(),
+        thumbnail_path=thumbnail_relative,
+        file_metadata=metadata,
+        source_type="sliced",
+        created_by_id=current_user_id,
+    )
+    db.add(new_file)
+    await db.commit()
+    await db.refresh(new_file)
+
+    return SliceResponse(
+        library_file_id=new_file.id,
+        name=new_file.filename,
+        print_time_seconds=result.print_time_seconds,
+        filament_used_g=result.filament_used_g,
+        filament_used_mm=result.filament_used_mm,
+        used_embedded_settings=used_embedded_settings,
+    )
+
+
+async def slice_and_persist_as_archive(
+    db: AsyncSession,
+    *,
+    model_bytes: bytes,
+    model_filename: str,
+    request: SliceRequest,
+    source_archive,  # PrintArchive — hint kept loose to avoid cyclic import
+    current_user_id: int | None,
+):
+    """Slice a model and save the result as a new ``PrintArchive`` row,
+    inheriting printer / project / makerworld metadata from the source
+    archive. Always exports as a `.gcode.3mf` so the existing thumbnail
+    and plates infrastructure (which expects a zip-shaped 3MF) works on
+    the new archive. Returns ``SliceArchiveResponse``.
+    """
+    from backend.app.models.archive import PrintArchive
+    from backend.app.schemas.slicer import SliceArchiveResponse
+    from backend.app.services.archive import ThreeMFParser
+
+    # Archive sinks always want a 3MF. The library route still respects the
+    # caller's `export_3mf` flag; here we override.
+    archive_request = request.model_copy(update={"export_3mf": True})
+
+    result, used_embedded_settings = await _run_slicer_with_fallback(
+        db,
+        model_bytes=model_bytes,
+        model_filename=model_filename,
+        request=archive_request,
+    )
+
+    base_name = model_filename.rsplit(".", 1)[0]
+    out_filename = f"{base_name}.gcode.3mf"
+
+    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+    printer_folder = str(source_archive.printer_id) if source_archive.printer_id is not None else "unassigned"
+    archive_subdir = f"{timestamp}_{base_name}_sliced"
+    archive_dir = app_settings.archive_dir / printer_folder / archive_subdir
+    archive_dir.mkdir(parents=True, exist_ok=True)
+    out_path = archive_dir / out_filename
+    out_path.write_bytes(result.content)
+
+    # Extract a thumbnail from the produced 3MF so the new archive card has
+    # a preview. The 3MF parser pulls Metadata/plate_*.png; failures here
+    # shouldn't fail the whole slice — the archive row is still useful
+    # without a thumbnail.
+    thumbnail_path: str | None = None
+    parsed_metadata: dict = {}
+    try:
+        parser = ThreeMFParser(str(out_path))
+        parsed = parser.parse()
+        thumb_data = parsed.get("_thumbnail_data")
+        thumb_ext = parsed.get("_thumbnail_ext", ".png")
+        if thumb_data:
+            thumb_dest = archive_dir / f"thumbnail{thumb_ext}"
+            thumb_dest.write_bytes(thumb_data)
+            thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
+        parsed_metadata = {k: v for k, v in parsed.items() if not k.startswith("_")}
+    except Exception as exc:
+        logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
+
+    metadata = dict(source_archive.extra_data) if source_archive.extra_data else {}
+    metadata.update(parsed_metadata)
+    metadata.update(
+        {
+            "sliced_from_archive_id": source_archive.id,
+            "print_time_seconds": result.print_time_seconds,
+            "filament_used_g": result.filament_used_g,
+            "filament_used_mm": result.filament_used_mm,
+        }
+    )
+    if used_embedded_settings:
+        metadata["used_embedded_settings"] = True
+
+    new_archive = PrintArchive(
+        printer_id=source_archive.printer_id,
+        project_id=source_archive.project_id,
+        filename=out_filename,
+        file_path=str(out_path.relative_to(app_settings.base_dir)),
+        file_size=len(result.content),
+        content_hash=hashlib.sha256(result.content).hexdigest(),
+        thumbnail_path=thumbnail_path,
+        # Inherit identity from the source archive so the new entry shows
+        # up alongside its sibling in the archives list.
+        print_name=(source_archive.print_name or base_name) + " (re-sliced)",
+        print_time_seconds=result.print_time_seconds,
+        filament_used_grams=result.filament_used_g or None,
+        filament_type=source_archive.filament_type,
+        filament_color=source_archive.filament_color,
+        layer_height=source_archive.layer_height,
+        nozzle_diameter=source_archive.nozzle_diameter,
+        sliced_for_model=source_archive.sliced_for_model,
+        makerworld_url=source_archive.makerworld_url,
+        designer=source_archive.designer,
+        # Sliced-but-not-printed: keep status default ("completed") so it
+        # surfaces in the normal archives list, but do not stamp
+        # started/completed_at — the user hasn't actually printed it yet.
+        extra_data=metadata,
+        created_by_id=current_user_id,
+    )
+    db.add(new_archive)
+    await db.commit()
+    await db.refresh(new_archive)
+
+    return SliceArchiveResponse(
+        archive_id=new_archive.id,
+        name=new_archive.print_name or out_filename,
+        print_time_seconds=result.print_time_seconds,
+        filament_used_g=result.filament_used_g,
+        filament_used_mm=result.filament_used_mm,
+        used_embedded_settings=used_embedded_settings,
+    )
+
+
+@router.post("/files/{file_id}/slice", status_code=202)
+async def slice_library_file(
+    file_id: int,
+    request: SliceRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Enqueue a slice job for a library file. Returns 202 + job_id; the
+    slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
+    """
+    from backend.app.core.database import async_session
+    from backend.app.services.slice_dispatch import (
+        http_exception_to_job_error,
+        slice_dispatch,
+    )
+
+    src_result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    lib_file = src_result.scalar_one_or_none()
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    src_lower = (lib_file.filename or "").lower()
+    if not (
+        src_lower.endswith(".stl")
+        or src_lower.endswith(".3mf")
+        or src_lower.endswith(".step")
+        or src_lower.endswith(".stp")
+    ):
+        raise HTTPException(status_code=400, detail="Source file must be STL, 3MF, or STEP")
+
+    src_path = Path(app_settings.base_dir) / lib_file.file_path
+    if not src_path.exists():
+        raise HTTPException(status_code=404, detail="Source file missing on disk")
+
+    # Capture inputs the bg task needs — the request DB session is closed
+    # before the background task runs.
+    model_bytes = src_path.read_bytes()
+    folder_id = lib_file.folder_id
+    source_lib_file_id = lib_file.id
+    user_id = current_user.id if current_user else None
+
+    # If the source has a `print_name` in its metadata (BambuStudio always
+    # sets this; OrcaSlicer often leaves it blank), derive the sliced
+    # output's filename from it instead of the raw filename. The source
+    # row's display already prefers print_name, so the sliced row's
+    # filename ("Piggo the piggy bank.gcode.3mf") will match the source's
+    # display name ("Piggo the piggy bank") with the gcode extension added.
+    src_print_name = None
+    if lib_file.file_metadata:
+        candidate = lib_file.file_metadata.get("print_name")
+        if isinstance(candidate, str) and candidate.strip():
+            src_print_name = candidate.strip()
+    src_ext = Path(lib_file.filename).suffix.lower() or ".3mf"
+    model_filename = f"{src_print_name}{src_ext}" if src_print_name else lib_file.filename
+
+    async def _run():
+        async with async_session() as task_db:
+            try:
+                response = await slice_and_persist(
+                    task_db,
+                    model_bytes=model_bytes,
+                    model_filename=model_filename,
+                    folder_id=folder_id,
+                    extra_metadata={"sliced_from_library_file_id": source_lib_file_id},
+                    request=request,
+                    current_user_id=user_id,
+                )
+            except HTTPException as exc:
+                raise http_exception_to_job_error(exc) from exc
+        return response.model_dump()
+
+    job = await slice_dispatch.enqueue(
+        kind="library_file",
+        source_id=lib_file.id,
+        source_name=lib_file.filename,
+        run=_run,
+    )
+    return {
+        "job_id": job.id,
+        "status": job.status,
+        "status_url": f"/api/v1/slice-jobs/{job.id}",
+    }
+
+
 @router.post("/files/{file_id}/print")
 async def print_library_file(
     file_id: int,

+ 44 - 0
backend/app/api/routes/slice_jobs.py

@@ -0,0 +1,44 @@
+"""Polling endpoint for the in-memory slice-job dispatcher.
+
+POST /library/files/{id}/slice and POST /archives/{id}/slice return a
+job_id and a status_url pointing here. The frontend polls this until
+status flips to `completed` or `failed`.
+"""
+
+from fastapi import APIRouter, HTTPException
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.slice_dispatch import slice_dispatch
+
+router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
+
+
+@router.get("/{job_id}")
+async def get_slice_job(
+    job_id: int,
+    # Job IDs are sequential integers and the body leaks source filenames
+    # plus the resulting library_file_id / archive_id. Gate on LIBRARY_READ
+    # — same baseline a user needs to see slice sources or results.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+):
+    job = slice_dispatch.get(job_id)
+    if job is None:
+        raise HTTPException(status_code=404, detail="Slice job not found or expired")
+    body: dict = {
+        "job_id": job.id,
+        "status": job.status,
+        "kind": job.kind,
+        "source_id": job.source_id,
+        "source_name": job.source_name,
+        "created_at": job.created_at.isoformat(),
+        "started_at": job.started_at.isoformat() if job.started_at else None,
+        "completed_at": job.completed_at.isoformat() if job.completed_at else None,
+    }
+    if job.status == "completed":
+        body["result"] = job.result
+    elif job.status == "failed":
+        body["error_status"] = job.error_status
+        body["error_detail"] = job.error_detail
+    return body

+ 9 - 0
backend/app/core/config.py

@@ -73,6 +73,15 @@ class Settings(BaseSettings):
     # API
     api_prefix: str = "/api/v1"
 
+    # Slicer API sidecars. Defaults match the docker-compose.yml ports in the
+    # orca-slicer-api fork (https://github.com/maziggy/orca-slicer-api):
+    #   OrcaSlicer  → port 3003 (default profile)
+    #   BambuStudio → port 3001 (built locally via Dockerfile.bambu-studio)
+    # The slice route picks which one based on the user's preferred_slicer
+    # setting.
+    slicer_api_url: str = "http://localhost:3003"
+    bambu_studio_api_url: str = "http://localhost:3001"
+
     class Config:
         env_file = ".env"
         env_file_encoding = "utf-8"

+ 2 - 0
backend/app/main.py

@@ -47,6 +47,7 @@ from backend.app.api.routes import (
     printers,
     projects,
     settings as settings_routes,
+    slice_jobs,
     smart_plugs,
     spoolbuddy,
     spoolman,
@@ -4781,6 +4782,7 @@ app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
+app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)

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

@@ -183,6 +183,28 @@ class AppSettings(BaseModel):
         description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
     )
 
+    # Slicer dispatch mode: when True, "Slice" actions open the in-app
+    # SliceModal and call the slicer-API sidecar. When False (default), they
+    # hand off to the user's local desktop slicer via URI scheme — preserving
+    # the original Bambuddy behavior for users who don't run a sidecar.
+    use_slicer_api: bool = Field(
+        default=False,
+        description="Use the slicer-API sidecar for slicing instead of the desktop slicer URI scheme",
+    )
+
+    # Slicer-API sidecar base URLs. Per-installation, configured via the
+    # Settings UI (the "Slicer" card). Empty string means "fall back to the
+    # SLICER_API_URL / BAMBU_STUDIO_API_URL env vars" — which themselves
+    # default to the docker-compose ports in core/config.py.
+    orcaslicer_api_url: str = Field(
+        default="",
+        description="OrcaSlicer sidecar URL (e.g. http://localhost:3003). Empty falls back to the SLICER_API_URL env var.",
+    )
+    bambu_studio_api_url: str = Field(
+        default="",
+        description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
+    )
+
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_token: str = Field(
@@ -352,6 +374,9 @@ class AppSettingsUpdate(BaseModel):
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
+    use_slicer_api: bool | None = None
+    orcaslicer_api_url: str | None = None
+    bambu_studio_api_url: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

+ 46 - 0
backend/app/schemas/slicer.py

@@ -0,0 +1,46 @@
+"""Pydantic schemas for slice requests."""
+
+from pydantic import BaseModel, Field
+
+
+class SliceRequest(BaseModel):
+    """Body for `POST /library/files/{file_id}/slice`."""
+
+    printer_preset_id: int = Field(..., description="LocalPreset id with preset_type='printer'")
+    process_preset_id: int = Field(..., description="LocalPreset id with preset_type='process'")
+    filament_preset_id: int = Field(..., description="LocalPreset id with preset_type='filament'")
+    plate: int | None = Field(
+        default=None,
+        ge=1,
+        description="Plate number to slice (1-indexed). Defaults to plate 1 on the sidecar.",
+    )
+    export_3mf: bool = Field(
+        default=False,
+        description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
+    )
+
+
+class SliceResponse(BaseModel):
+    """Response from `POST /library/files/{file_id}/slice`. The result lands
+    in the user's library as a new ``LibraryFile`` (in the same folder as
+    the source)."""
+
+    library_file_id: int
+    name: str
+    print_time_seconds: int
+    filament_used_g: float
+    filament_used_mm: float
+    used_embedded_settings: bool = False
+
+
+class SliceArchiveResponse(BaseModel):
+    """Response from `POST /archives/{archive_id}/slice`. The result lands
+    in the user's archives as a new ``PrintArchive`` row, inheriting
+    printer / project metadata from the source archive."""
+
+    archive_id: int
+    name: str
+    print_time_seconds: int
+    filament_used_g: float
+    filament_used_mm: float
+    used_embedded_settings: bool = False

+ 152 - 0
backend/app/services/slice_dispatch.py

@@ -0,0 +1,152 @@
+"""In-memory background dispatcher for slice jobs.
+
+Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
+but tailored for slicing: jobs are independent (no printer-busy gating),
+short-lived (typically 5-60s), and the result is a `LibraryFile` or
+`PrintArchive` row rather than a printer-side dispatch.
+
+The frontend kicks off a slice via `POST /library/files/{id}/slice` or
+`POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls
+`GET /slice-jobs/{id}` until status is `completed` or `failed`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Literal
+
+logger = logging.getLogger(__name__)
+
+
+SliceJobStatus = Literal["pending", "running", "completed", "failed"]
+
+
+@dataclass(slots=True)
+class SliceJob:
+    id: int
+    kind: Literal["library_file", "archive"]
+    source_id: int
+    source_name: str
+    status: SliceJobStatus = "pending"
+    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+    started_at: datetime | None = None
+    completed_at: datetime | None = None
+    # On success: the body returned to the caller — usually a SliceResponse
+    # or SliceArchiveResponse dict.
+    result: dict[str, Any] | None = None
+    # On failure: HTTP status + error message.
+    error_status: int | None = None
+    error_detail: str | None = None
+
+
+# Retention: keep finished jobs around for 30 minutes so the polling client
+# always sees a terminal state on its next tick. After that, the next access
+# sweep prunes them.
+_RETENTION_SECONDS = 30 * 60
+
+
+class SliceDispatchService:
+    def __init__(self) -> None:
+        self._jobs: dict[int, SliceJob] = {}
+        self._next_id: int = 1
+        self._lock = asyncio.Lock()
+        self._tasks: dict[int, asyncio.Task] = {}
+
+    async def enqueue(
+        self,
+        *,
+        kind: Literal["library_file", "archive"],
+        source_id: int,
+        source_name: str,
+        run: Callable[[], Awaitable[dict[str, Any]]],
+    ) -> SliceJob:
+        """Register a new slice job and start it on the event loop.
+
+        ``run`` is an async callable that performs the actual slice + save
+        and returns the response body the caller will receive once status
+        flips to ``completed``.
+        """
+        async with self._lock:
+            job = SliceJob(
+                id=self._next_id,
+                kind=kind,
+                source_id=source_id,
+                source_name=source_name,
+            )
+            self._next_id += 1
+            self._jobs[job.id] = job
+            self._sweep_locked()
+
+        task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
+        self._tasks[job.id] = task
+        return job
+
+    async def _run_job(
+        self,
+        job: SliceJob,
+        run: Callable[[], Awaitable[dict[str, Any]]],
+    ) -> None:
+        job.started_at = datetime.now(timezone.utc)
+        job.status = "running"
+        try:
+            result = await run()
+            job.result = result
+            job.status = "completed"
+        except _SliceJobError as exc:
+            # Caller-controlled HTTP error — propagate status + detail.
+            job.status = "failed"
+            job.error_status = exc.status_code
+            job.error_detail = exc.detail
+        except Exception as exc:
+            logger.exception("Slice job %s failed unexpectedly", job.id)
+            job.status = "failed"
+            job.error_status = 500
+            job.error_detail = f"Unexpected error: {exc}"
+        finally:
+            job.completed_at = datetime.now(timezone.utc)
+            self._tasks.pop(job.id, None)
+
+    def get(self, job_id: int) -> SliceJob | None:
+        return self._jobs.get(job_id)
+
+    def _sweep_locked(self) -> None:
+        """Drop finished jobs older than the retention window. Caller holds
+        the lock."""
+        now = datetime.now(timezone.utc)
+        stale_ids = [
+            jid
+            for jid, job in self._jobs.items()
+            if job.status in ("completed", "failed")
+            and job.completed_at is not None
+            and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
+        ]
+        for jid in stale_ids:
+            self._jobs.pop(jid, None)
+
+
+class _SliceJobError(Exception):
+    """Raised inside a slice job's `run` callable to surface a specific
+    HTTP status + detail. The dispatcher catches these and stores them on
+    the job. Callers convert ``HTTPException`` to this on the boundary.
+    """
+
+    def __init__(self, status_code: int, detail: str) -> None:
+        super().__init__(detail)
+        self.status_code = status_code
+        self.detail = detail
+
+
+def http_exception_to_job_error(exc) -> _SliceJobError:
+    """Convert a starlette ``HTTPException`` into the dispatcher's error
+    type. Handles the common case where slice helpers raise FastAPI's
+    ``HTTPException`` for validation / sidecar failures.
+    """
+    return _SliceJobError(exc.status_code, str(exc.detail))
+
+
+# Module-level singleton, started/stopped by main.py's lifespan.
+slice_dispatch = SliceDispatchService()

+ 241 - 0
backend/app/services/slicer_api.py

@@ -0,0 +1,241 @@
+"""HTTP client for an OrcaSlicer / BambuStudio API sidecar.
+
+Bambuddy stores user printer/process/filament profiles itself (cloud-synced
+or locally imported), so the slice flow always sends the model file plus an
+explicit JSON profile triplet to the sidecar's `/slice` endpoint. The sidecar
+shape mirrors `AFKFelix/orca-slicer-api` (multipart upload, `--load-settings`
+under the hood, response body is raw G-code or 3MF with metadata in the
+`X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
+"""
+
+import logging
+from typing import NamedTuple
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+class SlicerApiError(Exception):
+    """Base error from the slicer API sidecar."""
+
+
+class SlicerApiUnavailableError(SlicerApiError):
+    """Sidecar is unreachable (connection error, no response)."""
+
+
+class SlicerApiServerError(SlicerApiError):
+    """Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
+    non-zero (range-validation reject, segfault on complex models, etc.).
+    Distinguished from `SlicerApiUnavailableError` so the caller can decide
+    whether to retry with a different request shape (e.g. a 3MF embedded-
+    settings fallback)."""
+
+
+class SlicerInputError(SlicerApiError):
+    """Sidecar rejected the input as invalid (4xx)."""
+
+
+class SliceResult(NamedTuple):
+    """Result of a slice operation."""
+
+    content: bytes
+    print_time_seconds: int
+    filament_used_g: float
+    filament_used_mm: float
+
+
+_shared_http_client: httpx.AsyncClient | None = None
+
+
+def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
+    """Register an app-scoped client so per-request services can pool transport."""
+    global _shared_http_client
+    _shared_http_client = client
+
+
+def _guess_model_content_type(filename: str) -> str:
+    lower = filename.lower()
+    if lower.endswith(".stl"):
+        return "model/stl"
+    if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
+        return "model/3mf"
+    if lower.endswith(".step") or lower.endswith(".stp"):
+        return "model/step"
+    return "application/octet-stream"
+
+
+class SlicerApiService:
+    """Talks to an OrcaSlicer / BambuStudio API sidecar."""
+
+    def __init__(
+        self,
+        base_url: str,
+        *,
+        client: httpx.AsyncClient | None = None,
+        timeout_seconds: float = 300.0,
+    ) -> None:
+        self.base_url = base_url.rstrip("/")
+        self.timeout_seconds = timeout_seconds
+        if client is not None:
+            self._client = client
+            self._owns_client = False
+        elif _shared_http_client is not None:
+            self._client = _shared_http_client
+            self._owns_client = False
+        else:
+            self._client = httpx.AsyncClient(timeout=timeout_seconds)
+            self._owns_client = True
+
+    async def close(self) -> None:
+        if self._owns_client:
+            await self._client.aclose()
+
+    async def __aenter__(self) -> "SlicerApiService":
+        return self
+
+    async def __aexit__(self, *_: object) -> None:
+        await self.close()
+
+    async def health(self) -> dict:
+        """GET /health — used to surface a clear "sidecar offline" error before
+        accepting a slice request from the user."""
+        try:
+            response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+        if response.status_code >= 400:
+            raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
+        return response.json()
+
+    async def slice_with_profiles(
+        self,
+        *,
+        model_bytes: bytes,
+        model_filename: str,
+        printer_profile_json: str,
+        process_profile_json: str,
+        filament_profile_json: str,
+        plate: int | None = None,
+        export_3mf: bool = False,
+    ) -> SliceResult:
+        """POST /slice with model + printer/process/filament profile triplet.
+
+        Raises:
+            SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
+            SlicerApiUnavailableError: connection error or 5xx from sidecar.
+        """
+        files = {
+            "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
+            "printerProfile": ("printer.json", printer_profile_json.encode("utf-8"), "application/json"),
+            "presetProfile": ("preset.json", process_profile_json.encode("utf-8"), "application/json"),
+            "filamentProfile": ("filament.json", filament_profile_json.encode("utf-8"), "application/json"),
+        }
+        data: dict[str, str] = {}
+        if plate is not None:
+            data["plate"] = str(plate)
+        if export_3mf:
+            data["exportType"] = "3mf"
+
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/slice",
+                files=files,
+                data=data,
+                timeout=self.timeout_seconds,
+            )
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+
+        if response.status_code >= 500:
+            try:
+                msg = response.json().get("message", "")
+            except Exception:
+                msg = response.text
+            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
+        if response.status_code >= 400:
+            try:
+                msg = response.json().get("message", "")
+            except Exception:
+                msg = response.text
+            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
+
+        return SliceResult(
+            content=response.content,
+            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
+            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
+            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
+        )
+
+    async def slice_without_profiles(
+        self,
+        *,
+        model_bytes: bytes,
+        model_filename: str,
+        plate: int | None = None,
+        export_3mf: bool = False,
+    ) -> SliceResult:
+        """POST /slice with only the model file and no profile triplet.
+
+        For 3MF inputs this lets the slicer fall back on the file's embedded
+        `Metadata/project_settings.config`. Used as a fallback when
+        `slice_with_profiles` triggers a CLI segfault or other 5xx —
+        complex H2D / multi-extruder models hit upstream bugs in both the
+        OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
+        """
+        files = {
+            "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
+        }
+        data: dict[str, str] = {}
+        if plate is not None:
+            data["plate"] = str(plate)
+        if export_3mf:
+            data["exportType"] = "3mf"
+
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/slice",
+                files=files,
+                data=data,
+                timeout=self.timeout_seconds,
+            )
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+
+        if response.status_code >= 500:
+            try:
+                msg = response.json().get("message", "")
+            except Exception:
+                msg = response.text
+            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
+        if response.status_code >= 400:
+            try:
+                msg = response.json().get("message", "")
+            except Exception:
+                msg = response.text
+            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
+
+        return SliceResult(
+            content=response.content,
+            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
+            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
+            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
+        )
+
+
+def _safe_int(value: str | None) -> int:
+    if not value:
+        return 0
+    try:
+        return int(float(value))
+    except (TypeError, ValueError):
+        return 0
+
+
+def _safe_float(value: str | None) -> float:
+    if not value:
+        return 0.0
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return 0.0

+ 445 - 0
backend/tests/integration/test_library_slice_api.py

@@ -0,0 +1,445 @@
+"""Integration tests for the slice-via-API flow.
+
+Routes under test:
+- POST /library/files/{id}/slice  (returns 202 + job_id; bg task does the work)
+- POST /archives/{id}/slice        (same shape; result lands in archives table)
+- GET /slice-jobs/{id}             (poll for terminal state)
+
+The synchronous validation paths (404 missing source, 400 wrong file type)
+are tested directly. The bg-task paths poll until the job finishes and then
+assert on the captured state.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+import zipfile
+from collections.abc import Callable
+
+import httpx
+import pytest
+from httpx import AsyncClient
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.library import LibraryFile
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.settings import Settings as SettingsModel
+from backend.app.services import slicer_api as slicer_api_module
+from backend.app.services.slice_dispatch import slice_dispatch
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
+    """Build a tiny in-memory 3MF zip that has a `Metadata/project_settings.config`
+    entry. Used to verify the strip-before-forwarding behavior."""
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        zf.writestr(
+            "Metadata/project_settings.config",
+            json.dumps(settings_payload or {"prime_tower_brim_width": "-1"}),
+        )
+    return buf.getvalue()
+
+
+def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
+    """Pin a MockTransport-backed httpx client onto the slicer_api singleton
+    so per-request `SlicerApiService` instances reuse it instead of opening
+    a real connection."""
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10.0)
+    slicer_api_module.set_shared_http_client(client)
+    return client
+
+
+async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
+    """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
+
+    The dispatcher runs work as an asyncio task on the same event loop, so
+    poll-with-sleep here is enough — a few yields and the task finishes.
+    """
+    deadline = asyncio.get_event_loop().time() + timeout
+    while asyncio.get_event_loop().time() < deadline:
+        r = await client.get(f"/api/v1/slice-jobs/{job_id}")
+        if r.status_code != 200:
+            raise AssertionError(f"slice-jobs poll failed: {r.status_code} {r.text}")
+        body = r.json()
+        if body["status"] in ("completed", "failed"):
+            return body
+        await asyncio.sleep(0.05)
+    raise AssertionError(f"slice job {job_id} did not finish in {timeout}s")
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def slice_test_setup(db_session, tmp_path):
+    """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
+    storage_dir = tmp_path / "library" / "files"
+    storage_dir.mkdir(parents=True, exist_ok=True)
+    src_path = storage_dir / "Cube.stl"
+    src_path.write_bytes(b"solid Cube\nendsolid\n")
+
+    original_base_dir = app_settings.base_dir
+    app_settings.base_dir = tmp_path
+
+    src_file = LibraryFile(
+        filename="Cube.stl",
+        file_path=str(src_path.relative_to(tmp_path)),
+        file_type="stl",
+        file_size=src_path.stat().st_size,
+    )
+    db_session.add(src_file)
+
+    presets = {}
+    for kind in ("printer", "process", "filament"):
+        p = LocalPreset(
+            name=f"Test {kind}",
+            preset_type=kind,
+            source="orcaslicer",
+            setting=json.dumps({"name": f"Test {kind}", "type": kind}),
+        )
+        db_session.add(p)
+        presets[kind] = p
+
+    db_session.add(SettingsModel(key="preferred_slicer", value="orcaslicer"))
+    await db_session.commit()
+
+    for p in presets.values():
+        await db_session.refresh(p)
+    await db_session.refresh(src_file)
+
+    yield {
+        "src_file_id": src_file.id,
+        "printer_id": presets["printer"].id,
+        "process_id": presets["process"].id,
+        "filament_id": presets["filament"].id,
+        "tmp_path": tmp_path,
+    }
+
+    app_settings.base_dir = original_base_dir
+    slicer_api_module.set_shared_http_client(None)
+
+
+# ---------------------------------------------------------------------------
+# POST /library/files/{id}/slice — synchronous validation paths
+# ---------------------------------------------------------------------------
+
+
+class TestSliceValidation:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_404_when_source_missing(self, async_client: AsyncClient, slice_test_setup):
+        _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
+        response = await async_client.post(
+            "/api/v1/library/files/999999/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_400_for_wrong_file_type(self, async_client: AsyncClient, db_session, slice_test_setup):
+        gcode_path = slice_test_setup["tmp_path"] / "library" / "files" / "out.gcode"
+        gcode_path.write_bytes(b"; gcode\n")
+        gfile = LibraryFile(
+            filename="out.gcode",
+            file_path=str(gcode_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="gcode",
+            file_size=10,
+        )
+        db_session.add(gfile)
+        await db_session.commit()
+        await db_session.refresh(gfile)
+
+        _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
+        response = await async_client.post(
+            f"/api/v1/library/files/{gfile.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 400
+        assert "STL, 3MF, or STEP" in response.json()["detail"]
+
+
+# ---------------------------------------------------------------------------
+# POST /library/files/{id}/slice — async dispatch + bg job
+# ---------------------------------------------------------------------------
+
+
+class TestSliceLibraryFile:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_happy_path_returns_202_then_job_completes_with_library_file(
+        self, async_client: AsyncClient, slice_test_setup
+    ):
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["url"] = str(request.url)
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "656",
+                    "x-filament-used-g": "0.94",
+                    "x-filament-used-mm": "302.5",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202, response.text
+        body = response.json()
+        assert body["status"] == "pending"
+        assert body["status_url"].startswith("/api/v1/slice-jobs/")
+
+        final = await _wait_for_job(async_client, body["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["library_file_id"] != slice_test_setup["src_file_id"]
+        assert final["result"]["print_time_seconds"] == 656
+        assert captured["url"].endswith("/slice")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
+        self, async_client: AsyncClient, slice_test_setup
+    ):
+        _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                # Swap printer/filament — both exist but wrong preset_type.
+                "printer_preset_id": slice_test_setup["filament_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["printer_id"],
+            },
+        )
+        assert response.status_code == 202
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed"
+        assert final["error_status"] == 400
+        assert "preset_type" in (final["error_detail"] or "")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_preferred_slicer_fails_with_400(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        await db_session.execute(
+            SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
+        )
+        await db_session.commit()
+
+        _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed"
+        assert final["error_status"] == 400
+        assert "preferred_slicer" in (final["error_detail"] or "")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
+        def handler(_: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("connection refused")
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed"
+        assert final["error_status"] == 502
+        assert "unreachable" in (final["error_detail"] or "").lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        # When the slicer CLI fails on the --load-settings path (segfault
+        # on complex H2D models), Bambuddy retries with no profile triplet
+        # so the CLI uses the file's embedded settings.
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
+        threemf = LibraryFile(
+            filename="complex.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            # First call: profile triplet present → simulate CLI 5xx
+            if call_count["n"] == 1:
+                return httpx.Response(
+                    status_code=500,
+                    json={"message": "Failed to slice the model"},
+                )
+            # Retry: no profile triplet → succeed with embedded settings
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is True
+        assert call_count["n"] == 2  # primary + fallback retry
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
+        # STL has no embedded settings — the CLI 5xx is terminal.
+        call_count = {"n": 0}
+
+        def handler(_: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            return httpx.Response(
+                status_code=500,
+                json={"message": "Failed to slice the model"},
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed"
+        assert final["error_status"] == 502
+        assert call_count["n"] == 1  # No retry for STL
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_3mf_input_strips_embedded_settings_before_forwarding(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
+        threemf = LibraryFile(
+            filename="real.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "1",
+                    "x-filament-used-g": "0",
+                    "x-filament-used-mm": "0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        # Recover the embedded zip from the multipart body — the strip
+        # removed Metadata/project_settings.config but kept geometry.
+        body = captured["body"]
+        pk = body.find(b"PK\x03\x04")
+        assert pk >= 0, "3MF body not found in multipart payload"
+        with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
+            names = set(zin.namelist())
+        assert "Metadata/project_settings.config" not in names
+        assert "3D/3dmodel.model" in names
+
+
+# ---------------------------------------------------------------------------
+# GET /slice-jobs/{id}
+# ---------------------------------------------------------------------------
+
+
+class TestSliceJobs:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_job_returns_404(self, async_client: AsyncClient):
+        # Sweep dispatcher state so a fresh ID is unknown.
+        slice_dispatch._jobs.clear()
+        r = await async_client.get("/api/v1/slice-jobs/999999")
+        assert r.status_code == 404

+ 219 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -0,0 +1,219 @@
+"""Tests for SlicerApiService."""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import (
+    SlicerApiServerError,
+    SlicerApiService,
+    SlicerApiUnavailableError,
+    SliceResult,
+    SlicerInputError,
+    _guess_model_content_type,
+)
+
+
+def _mock_client(handler) -> httpx.AsyncClient:
+    """Build an httpx.AsyncClient that routes every request through `handler`.
+    handler signature: (httpx.Request) -> httpx.Response.
+    """
+    transport = httpx.MockTransport(handler)
+    return httpx.AsyncClient(transport=transport, timeout=10.0)
+
+
+class TestGuessModelContentType:
+    """The sidecar's multer middleware rejects octet-stream for STL uploads,
+    so we guess by extension."""
+
+    def test_stl(self):
+        assert _guess_model_content_type("Cube.stl") == "model/stl"
+
+    def test_3mf(self):
+        assert _guess_model_content_type("Bank.3mf") == "model/3mf"
+
+    def test_3mf_uppercase(self):
+        assert _guess_model_content_type("Bank.3MF") == "model/3mf"
+
+    def test_step(self):
+        assert _guess_model_content_type("Cube.step") == "model/step"
+
+    def test_stp(self):
+        assert _guess_model_content_type("Cube.stp") == "model/step"
+
+    def test_unknown(self):
+        assert _guess_model_content_type("foo.bar") == "application/octet-stream"
+
+
+class TestSliceWithProfiles:
+    @pytest.mark.asyncio
+    async def test_happy_path_returns_gcode_and_metadata(self):
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["url"] = str(request.url)
+            captured["body_len"] = len(request.content)
+            captured["content_type"] = request.headers.get("content-type", "")
+            return httpx.Response(
+                status_code=200,
+                content=b"; G-CODE START\nG28\n",
+                headers={
+                    "content-type": "application/octet-stream",
+                    "x-print-time-seconds": "656",
+                    "x-filament-used-g": "0.94",
+                    "x-filament-used-mm": "302.5",
+                },
+            )
+
+        client = _mock_client(handler)
+        service = SlicerApiService("http://sidecar:3000", client=client)
+
+        result = await service.slice_with_profiles(
+            model_bytes=b"solid Cube\n",
+            model_filename="Cube.stl",
+            printer_profile_json='{"name": "p"}',
+            process_profile_json='{"name": "pr"}',
+            filament_profile_json='{"name": "f"}',
+        )
+
+        assert isinstance(result, SliceResult)
+        assert result.content == b"; G-CODE START\nG28\n"
+        assert result.print_time_seconds == 656
+        assert result.filament_used_g == 0.94
+        assert result.filament_used_mm == 302.5
+        assert captured["url"].endswith("/slice")
+        assert captured["content_type"].startswith("multipart/form-data")
+        # Roughly: model bytes (>0) + 3 profile JSONs (>0). Sanity check that
+        # all four parts hit the wire.
+        assert captured["body_len"] > 0
+
+    @pytest.mark.asyncio
+    async def test_4xx_raises_slicer_input_error(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=400,
+                json={"message": "Invalid file type for printerProfile."},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerInputError) as exc_info:
+            await service.slice_with_profiles(
+                model_bytes=b"x",
+                model_filename="Cube.stl",
+                printer_profile_json="{}",
+                process_profile_json="{}",
+                filament_profile_json="{}",
+            )
+        assert "Invalid file type" in str(exc_info.value)
+
+    @pytest.mark.asyncio
+    async def test_5xx_raises_server_error(self):
+        # 5xx from the sidecar = wrapped CLI failed (segfault, range-check
+        # reject, etc). Distinguished from connection failures so callers
+        # can retry with a different request shape.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=500,
+                json={"message": "Failed to slice the model"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiServerError) as exc_info:
+            await service.slice_with_profiles(
+                model_bytes=b"x",
+                model_filename="Cube.stl",
+                printer_profile_json="{}",
+                process_profile_json="{}",
+                filament_profile_json="{}",
+            )
+        assert "Failed to slice the model" in str(exc_info.value)
+
+    @pytest.mark.asyncio
+    async def test_connection_error_raises_unavailable(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("Connection refused")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiUnavailableError) as exc_info:
+            await service.slice_with_profiles(
+                model_bytes=b"x",
+                model_filename="Cube.stl",
+                printer_profile_json="{}",
+                process_profile_json="{}",
+                filament_profile_json="{}",
+            )
+        assert "unreachable" in str(exc_info.value).lower()
+
+    @pytest.mark.asyncio
+    async def test_passes_plate_and_export_3mf_options(self):
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF-BYTES",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.stl",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_json="{}",
+            plate=2,
+            export_3mf=True,
+        )
+
+        body = captured["body"]
+        # Multipart body should contain the form fields. Quick membership
+        # check beats parsing the multipart envelope.
+        assert b'name="plate"' in body
+        assert b"\r\n2\r\n" in body or b'name="plate"\r\n\r\n2' in body
+        assert b'name="exportType"' in body
+        assert b"3mf" in body
+
+    @pytest.mark.asyncio
+    async def test_missing_metadata_headers_default_to_zero(self):
+        # The /slice endpoint always sets these on success, but be defensive
+        # so a stripped reverse-proxy or older sidecar doesn't crash callers.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=b"; gcode")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.stl",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_json="{}",
+        )
+        assert result.print_time_seconds == 0
+        assert result.filament_used_g == 0.0
+        assert result.filament_used_mm == 0.0
+
+
+class TestHealth:
+    @pytest.mark.asyncio
+    async def test_health_returns_body(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        body = await service.health()
+        assert body["status"] == "healthy"
+
+    @pytest.mark.asyncio
+    async def test_health_unreachable_raises(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("no route")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiUnavailableError):
+            await service.health()

+ 7 - 0
docker-compose.yml

@@ -66,6 +66,13 @@ services:
       # External PostgreSQL (optional — uses SQLite by default)
       # Example: DATABASE_URL=postgresql+asyncpg://bambuddy:password@db-host:5432/bambuddy
       #- DATABASE_URL=
+      #
+      # Slicer API sidecar (optional — Settings → "Use Slicer API" toggles this on).
+      # Default points at the OrcaSlicer sidecar on the docker host; change if you
+      # run the sidecar on a different host/port. The matching docker-compose.yml
+      # for the sidecars lives in the orca-slicer-api fork
+      # (https://github.com/maziggy/orca-slicer-api).
+      #- SLICER_API_URL=http://localhost:3003
     restart: unless-stopped
 
   # Optional: External PostgreSQL database

+ 3 - 0
frontend/src/App.tsx

@@ -28,6 +28,7 @@ import { useWebSocket } from './hooks/useWebSocket';
 import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
+import { SliceJobTrackerProvider } from './contexts/SliceJobTrackerContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
@@ -157,6 +158,7 @@ function App() {
         <QueryClientProvider client={queryClient}>
           <AuthProvider>
             <ColorCatalogProvider>
+            <SliceJobTrackerProvider>
             <StreamTokenSync />
             <BrowserRouter>
               <Routes>
@@ -209,6 +211,7 @@ function App() {
                 </Route>
               </Routes>
             </BrowserRouter>
+            </SliceJobTrackerProvider>
             </ColorCatalogProvider>
           </AuthProvider>
         </QueryClientProvider>

+ 185 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -0,0 +1,185 @@
+/**
+ * Tests for SliceModal.
+ *
+ * The modal handles preset selection + enqueueing a slice job. After
+ * enqueue success it hands the job_id off to SliceJobTrackerProvider
+ * (which lives at app level) and calls onClose. Polling, toasts, and
+ * query invalidation all happen in the tracker — not here.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { SliceModal } from '../../components/SliceModal';
+import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLocalPresets: vi.fn(),
+    sliceLibraryFile: vi.fn(),
+    sliceArchive: vi.fn(),
+    getSliceJob: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  getLocalPresets: ReturnType<typeof vi.fn>;
+  sliceLibraryFile: ReturnType<typeof vi.fn>;
+  sliceArchive: ReturnType<typeof vi.fn>;
+  getSliceJob: ReturnType<typeof vi.fn>;
+};
+
+const samplePresets = {
+  printer: [{ id: 1, name: 'X1C 0.4', preset_type: 'printer' }],
+  process: [{ id: 2, name: '0.20mm Standard', preset_type: 'process' }],
+  filament: [{ id: 3, name: 'Bambu PLA Basic', preset_type: 'filament' }],
+};
+
+function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
+  return render(
+    <SliceJobTrackerProvider>
+      <SliceModal {...props} />
+    </SliceJobTrackerProvider>,
+  );
+}
+
+describe('SliceModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getLocalPresets.mockResolvedValue(samplePresets);
+    // Tracker polls — return a still-running job so the test doesn't
+    // race against terminal-state side effects (toasts, invalidation).
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 42,
+      status: 'running',
+      kind: 'library_file',
+      source_id: 100,
+      source_name: 'Cube.stl',
+      created_at: new Date().toISOString(),
+      started_at: null,
+      completed_at: null,
+    });
+  });
+
+  it('disables Slice button until all three presets are picked', async () => {
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
+
+    const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
+    expect((sliceBtn as HTMLButtonElement).disabled).toBe(true);
+
+    const user = userEvent.setup();
+    const selects = screen.getAllByRole('combobox');
+    expect(selects).toHaveLength(3);
+    await user.selectOptions(selects[0], '1');
+    await user.selectOptions(selects[1], '2');
+    expect((sliceBtn as HTMLButtonElement).disabled).toBe(true);
+    await user.selectOptions(selects[2], '3');
+    expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
+  });
+
+  it('enqueues a library-file slice job and closes the modal on success', async () => {
+    const onClose = vi.fn();
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose,
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
+
+    const user = userEvent.setup();
+    const selects = screen.getAllByRole('combobox');
+    await user.selectOptions(selects[0], '1');
+    await user.selectOptions(selects[1], '2');
+    await user.selectOptions(selects[2], '3');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
+        printer_preset_id: 1,
+        process_preset_id: 2,
+        filament_preset_id: 3,
+      });
+    });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('routes archive sources to sliceArchive instead of sliceLibraryFile', async () => {
+    const onClose = vi.fn();
+    mockApi.sliceArchive.mockResolvedValue({
+      job_id: 7,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/7',
+    });
+
+    renderWithTracker({
+      source: { kind: 'archive', id: 86, filename: 'orca.3mf' },
+      onClose,
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
+
+    const user = userEvent.setup();
+    const selects = screen.getAllByRole('combobox');
+    await user.selectOptions(selects[0], '1');
+    await user.selectOptions(selects[1], '2');
+    await user.selectOptions(selects[2], '3');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceArchive).toHaveBeenCalledWith(86, expect.any(Object));
+      expect(mockApi.sliceLibraryFile).not.toHaveBeenCalled();
+    });
+  });
+
+  it('surfaces enqueue errors inline and keeps the modal open', async () => {
+    const onClose = vi.fn();
+    mockApi.sliceLibraryFile.mockRejectedValue(new Error('Server says no'));
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose,
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
+
+    const user = userEvent.setup();
+    const selects = screen.getAllByRole('combobox');
+    await user.selectOptions(selects[0], '1');
+    await user.selectOptions(selects[1], '2');
+    await user.selectOptions(selects[2], '3');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(screen.getByRole('alert')).toHaveTextContent('Server says no');
+    });
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('shows a friendly notice when getLocalPresets fails', async () => {
+    mockApi.getLocalPresets.mockRejectedValue(new Error('500'));
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => {
+      expect(screen.getByRole('alert')).toHaveTextContent(/Failed to load presets/i);
+    });
+  });
+});

+ 21 - 2
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -37,6 +37,11 @@ const mockSettings = {
 
 describe('SettingsPage', () => {
   beforeEach(() => {
+    // BrowserRouter shares window.location across tests; reset it so a tab
+    // switch in one test (e.g. clicking "Workflow") doesn't carry into
+    // sibling tests that expect to land on the default General tab.
+    window.history.replaceState({}, '', '/');
+
     server.use(
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json(mockSettings);
@@ -119,17 +124,31 @@ describe('SettingsPage', () => {
       });
     });
 
-    it('shows preferred slicer setting', async () => {
+    it('shows preferred slicer setting on Workflow tab', async () => {
+      const user = userEvent.setup();
       render(<SettingsPage />);
 
+      await waitFor(() => {
+        expect(screen.getByText('Workflow')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Workflow'));
+
       await waitFor(() => {
         expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
       });
     });
 
-    it('shows slicer dropdown with both options', async () => {
+    it('shows slicer dropdown with both options on Workflow tab', async () => {
+      const user = userEvent.setup();
       render(<SettingsPage />);
 
+      await waitFor(() => {
+        expect(screen.getByText('Workflow')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Workflow'));
+
       await waitFor(() => {
         const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
         expect(slicerSelect.length).toBeGreaterThan(0);

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

@@ -926,6 +926,11 @@ export interface AppSettings {
   camera_view_mode: 'window' | 'embedded';
   // Preferred slicer
   preferred_slicer: 'bambu_studio' | 'orcaslicer';
+  // Use the slicer-API sidecar for slicing (in-app modal) vs desktop URI scheme
+  use_slicer_api: boolean;
+  // Per-install sidecar URLs. Empty string falls back to the env defaults.
+  orcaslicer_api_url: string;
+  bambu_studio_api_url: string;
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;
@@ -1105,6 +1110,57 @@ export interface BuiltinFilament {
   name: string;
 }
 
+// Slice request/response — POST /library/files/{id}/slice and /archives/{id}/slice
+export interface SliceRequest {
+  printer_preset_id: number;
+  process_preset_id: number;
+  filament_preset_id: number;
+  plate?: number;
+  export_3mf?: boolean;
+}
+
+export interface SliceResponse {
+  library_file_id: number;
+  name: string;
+  print_time_seconds: number;
+  filament_used_g: number;
+  filament_used_mm: number;
+  used_embedded_settings: boolean;
+}
+
+export interface SliceArchiveResponse {
+  archive_id: number;
+  name: string;
+  print_time_seconds: number;
+  filament_used_g: number;
+  filament_used_mm: number;
+  used_embedded_settings: boolean;
+}
+
+// Background slice-job lifecycle. POST /slice returns 202 + this shape;
+// the frontend polls /slice-jobs/{id} until status is terminal.
+export type SliceJobStatus = 'pending' | 'running' | 'completed' | 'failed';
+
+export interface SliceJobEnqueueResponse {
+  job_id: number;
+  status: SliceJobStatus;
+  status_url: string;
+}
+
+export interface SliceJobState {
+  job_id: number;
+  status: SliceJobStatus;
+  kind: 'library_file' | 'archive';
+  source_id: number;
+  source_name: string;
+  created_at: string;
+  started_at: string | null;
+  completed_at: string | null;
+  result?: SliceResponse | SliceArchiveResponse;
+  error_status?: number;
+  error_detail?: string;
+}
+
 // Local preset types (OrcaSlicer imports)
 export interface LocalPreset {
   id: number;
@@ -4905,6 +4961,21 @@ export const api = {
       body: JSON.stringify({ url }),
     }),
 
+  // Slicer API — slice in the background. Both endpoints return 202 + a
+  // job_id; poll /slice-jobs/{id} until status is `completed` or `failed`.
+  sliceLibraryFile: (fileId: number, body: SliceRequest) =>
+    request<SliceJobEnqueueResponse>(`/library/files/${fileId}/slice`, {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
+  sliceArchive: (archiveId: number, body: SliceRequest) =>
+    request<SliceJobEnqueueResponse>(`/archives/${archiveId}/slice`, {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
+  getSliceJob: (jobId: number) =>
+    request<SliceJobState>(`/slice-jobs/${jobId}`),
+
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),

+ 207 - 0
frontend/src/components/SliceModal.tsx

@@ -0,0 +1,207 @@
+import { Cog, Loader2, X } from 'lucide-react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { api, type LocalPreset } from '../api/client';
+import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+
+export type SliceSource =
+  | { kind: 'libraryFile'; id: number; filename: string }
+  | { kind: 'archive'; id: number; filename: string };
+
+interface SliceModalProps {
+  source: SliceSource;
+  onClose: () => void;
+}
+
+export function SliceModal({ source, onClose }: SliceModalProps) {
+  const { t } = useTranslation();
+  const { trackJob } = useSliceJobTracker();
+
+  const [printerPresetId, setPrinterPresetId] = useState<number | null>(null);
+  const [processPresetId, setProcessPresetId] = useState<number | null>(null);
+  const [filamentPresetId, setFilamentPresetId] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  const presetsQuery = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    staleTime: 60_000,
+  });
+
+  const enqueueMutation = useMutation({
+    mutationFn: async () => {
+      if (printerPresetId == null || processPresetId == null || filamentPresetId == null) {
+        throw new Error('All three presets must be selected');
+      }
+      const body = {
+        printer_preset_id: printerPresetId,
+        process_preset_id: processPresetId,
+        filament_preset_id: filamentPresetId,
+      };
+      if (source.kind === 'libraryFile') {
+        return api.sliceLibraryFile(source.id, body);
+      }
+      return api.sliceArchive(source.id, body);
+    },
+    onSuccess: (enqueue) => {
+      // Hand the job off to the global tracker — polling, toasts, and
+      // query invalidation continue across navigation.
+      trackJob(enqueue.job_id, source.kind, source.filename);
+      onClose();
+    },
+    onError: (err: unknown) => {
+      const msg = err instanceof Error ? err.message : String(err);
+      setErrorMessage(msg);
+    },
+  });
+
+  const isReady = printerPresetId != null && processPresetId != null && filamentPresetId != null;
+  const isEnqueuing = enqueueMutation.isPending;
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
+      onClick={() => {
+        if (!isEnqueuing) onClose();
+      }}
+    >
+      <div
+        className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
+          <div className="min-w-0">
+            <h3 className="text-white font-medium flex items-center gap-2">
+              <Cog className="w-4 h-4" />
+              {t('slice.title', 'Slice model')}
+            </h3>
+            <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
+              {source.filename}
+            </p>
+          </div>
+          <button
+            onClick={onClose}
+            disabled={isEnqueuing}
+            className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
+            aria-label={t('common.close', 'Close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Body */}
+        <div className="flex-1 overflow-y-auto p-4 space-y-4">
+          {presetsQuery.isLoading && (
+            <div className="flex items-center gap-2 text-bambu-gray text-sm">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              {t('slice.loadingPresets', 'Loading presets…')}
+            </div>
+          )}
+
+          {presetsQuery.isError && (
+            <div className="text-sm text-red-400" role="alert">
+              {t(
+                'slice.presetsLoadFailed',
+                'Failed to load presets. Open Settings → Profiles to import them first.',
+              )}
+            </div>
+          )}
+
+          {presetsQuery.data && (
+            <>
+              <PresetDropdown
+                label={t('slice.printer', 'Printer profile')}
+                presets={presetsQuery.data.printer}
+                value={printerPresetId}
+                onChange={setPrinterPresetId}
+                disabled={isEnqueuing}
+              />
+              <PresetDropdown
+                label={t('slice.process', 'Process profile')}
+                presets={presetsQuery.data.process}
+                value={processPresetId}
+                onChange={setProcessPresetId}
+                disabled={isEnqueuing}
+              />
+              <PresetDropdown
+                label={t('slice.filament', 'Filament profile')}
+                presets={presetsQuery.data.filament}
+                value={filamentPresetId}
+                onChange={setFilamentPresetId}
+                disabled={isEnqueuing}
+              />
+            </>
+          )}
+
+          {errorMessage && (
+            <div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
+              {errorMessage}
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
+          <button
+            type="button"
+            onClick={onClose}
+            disabled={isEnqueuing}
+            className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
+          >
+            {t('common.cancel', 'Cancel')}
+          </button>
+          <button
+            type="button"
+            onClick={() => {
+              setErrorMessage(null);
+              enqueueMutation.mutate();
+            }}
+            disabled={!isReady || isEnqueuing}
+            className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
+          >
+            {isEnqueuing ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                {t('slice.enqueuing', 'Submitting slice job…')}
+              </>
+            ) : (
+              t('slice.action', 'Slice')
+            )}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+interface PresetDropdownProps {
+  label: string;
+  presets: LocalPreset[];
+  value: number | null;
+  onChange: (id: number | null) => void;
+  disabled?: boolean;
+}
+
+function PresetDropdown({ label, presets, value, onChange, disabled }: PresetDropdownProps) {
+  const { t } = useTranslation();
+  return (
+    <label className="block">
+      <span className="block text-xs text-bambu-gray mb-1">{label}</span>
+      <select
+        value={value ?? ''}
+        onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
+        disabled={disabled}
+        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
+      >
+        <option value="">{t('slice.selectPreset', '— Select a preset —')}</option>
+        {presets.map((p) => (
+          <option key={p.id} value={p.id}>
+            {p.name}
+          </option>
+        ))}
+      </select>
+    </label>
+  );
+}

+ 111 - 0
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -0,0 +1,111 @@
+/**
+ * Background slice-job tracker.
+ *
+ * SliceModal calls `trackJob(id, kind)` after enqueuing and closes
+ * immediately. This context keeps the job-id list, polls each one, and
+ * shows toasts on terminal state. Lives at app level so polling continues
+ * across navigation — slice can run in the background while the user does
+ * other things.
+ */
+import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQueryClient } from '@tanstack/react-query';
+import { api, type SliceJobState } from '../api/client';
+import { useToast } from './ToastContext';
+
+interface TrackedJob {
+  id: number;
+  kind: 'libraryFile' | 'archive';
+  sourceName: string;
+}
+
+interface SliceJobTrackerContextValue {
+  trackJob: (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => void;
+  activeJobs: TrackedJob[];
+}
+
+const SliceJobTrackerContext = createContext<SliceJobTrackerContextValue | null>(null);
+
+const POLL_INTERVAL_MS = 1500;
+
+export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [activeJobs, setActiveJobs] = useState<TrackedJob[]>([]);
+
+  // Stable mutable ref so the polling effect can read the current list
+  // without re-subscribing every time it changes.
+  const activeJobsRef = useRef<TrackedJob[]>([]);
+  activeJobsRef.current = activeJobs;
+
+  const trackJob = useCallback(
+    (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
+      setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
+      showToast(t('slice.startedToast', 'Slicing {{name}} in the background…', { name: sourceName }), 'info');
+    },
+    [showToast, t],
+  );
+
+  const completeJob = useCallback(
+    (job: TrackedJob, state: SliceJobState) => {
+      setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
+
+      if (state.status === 'completed') {
+        if (state.result?.used_embedded_settings) {
+          showToast(t('slice.fallbackUsedEmbedded'), 'warning');
+        }
+        showToast(
+          t('slice.completedToast', 'Sliced {{name}}', { name: job.sourceName }),
+          'success',
+        );
+      } else if (state.status === 'failed') {
+        const detail = state.error_detail || t('slice.failed');
+        showToast(t('slice.failedToast', 'Slicing {{name}} failed: {{detail}}', { name: job.sourceName, detail }), 'error');
+      }
+
+      // Refresh whichever list owns the result. Both are cheap to invalidate.
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+    },
+    [queryClient, showToast, t],
+  );
+
+  useEffect(() => {
+    if (activeJobs.length === 0) return;
+    let cancelled = false;
+    const interval = setInterval(async () => {
+      if (cancelled) return;
+      // Snapshot the current list so concurrent updates don't surprise us.
+      const snapshot = [...activeJobsRef.current];
+      for (const job of snapshot) {
+        try {
+          const state = await api.getSliceJob(job.id);
+          if (state.status === 'completed' || state.status === 'failed') {
+            completeJob(job, state);
+          }
+        } catch {
+          // Transient poll failure — stay tracked, retry next tick.
+        }
+      }
+    }, POLL_INTERVAL_MS);
+    return () => {
+      cancelled = true;
+      clearInterval(interval);
+    };
+  }, [activeJobs.length, completeJob]);
+
+  return (
+    <SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
+      {children}
+    </SliceJobTrackerContext.Provider>
+  );
+}
+
+export function useSliceJobTracker(): SliceJobTrackerContextValue {
+  const ctx = useContext(SliceJobTrackerContext);
+  if (!ctx) {
+    throw new Error('useSliceJobTracker must be used inside SliceJobTrackerProvider');
+  }
+  return ctx;
+}

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

@@ -1830,6 +1830,12 @@ export default {
     embeddedOverlay: 'Eingebettetes Overlay',
     preferredSlicer: 'Bevorzugter Slicer',
     preferredSlicerDescription: 'Wähle die Slicer-Anwendung zum Öffnen von Dateien',
+    useSlicerApi: 'Slicer-API verwenden',
+    useSlicerApiDescription: 'Wenn aktiv, öffnen "Slice"-Aktionen das in-App Slicer-Modal und rufen den Slicer-API-Sidecar. Aus (Standard): Übergabe an den Desktop-Slicer per URI-Schema.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
+    bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
+    slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
     printsOnly: 'Nur Drucke',
@@ -2952,6 +2958,7 @@ export default {
     noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',
     noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',
     noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',
+    noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',
     noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',
     noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',
@@ -3235,6 +3242,26 @@ export default {
     exportToFile: 'In Datei exportieren',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Modell slicen',
+    action: 'Slice',
+    slicing: 'Slicen…',
+    printer: 'Drucker-Profil',
+    process: 'Prozess-Profil',
+    filament: 'Filament-Profil',
+    selectPreset: '— Profil auswählen —',
+    loadingPresets: 'Profile werden geladen…',
+    presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
+    fallbackUsedEmbedded: 'Slicer hat die gewählten Profile abgelehnt — stattdessen wurden die im Modell eingebetteten Einstellungen verwendet.',
+    enqueuing: 'Slice-Auftrag wird übermittelt…',
+    queued: 'In Warteschlange…',
+    failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
+    startedToast: '{{name}} wird im Hintergrund gesliced…',
+    completedToast: '{{name}} wurde gesliced',
+    failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Spoolman-Integration',

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

@@ -1833,6 +1833,12 @@ export default {
     embeddedOverlay: 'Embedded Overlay',
     preferredSlicer: 'Preferred Slicer',
     preferredSlicerDescription: 'Choose which slicer application to open files with',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
     printsOnly: 'Prints Only',
@@ -2956,6 +2962,7 @@ export default {
     noPermissionDeleteFolder: 'You do not have permission to delete folders',
     noPermissionPrint: 'You do not have permission to print',
     noPermissionAddToQueue: 'You do not have permission to add to queue',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionDownload: 'You do not have permission to download files',
     noPermissionRenameFile: 'You do not have permission to rename this file',
     noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
@@ -3238,6 +3245,26 @@ export default {
     exportToFile: 'Export to File',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Spoolman Integration',

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

@@ -1779,6 +1779,12 @@ export default {
     embeddedOverlay: 'Superposition intégrée',
     preferredSlicer: 'Slicer préféré',
     preferredSlicerDescription: 'Application pour ouvrir les fichiers',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: 'Caméras externes',
     costTracking: 'Suivi des coûts',
     printsOnly: 'Impressions uniquement',
@@ -2874,6 +2880,7 @@ export default {
     noPermissionLinkFolder: 'Pas d\'autorisation lien',
     noPermissionDeleteFolder: 'Pas d\'autorisation suppression dossier',
     noPermissionPrint: 'Pas d\'autorisation impression',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: 'Pas d\'autorisation file',
     noPermissionDownload: 'Pas d\'autorisation téléchargement',
     noPermissionRenameFile: 'Pas d\'autorisation renommage fichier',
@@ -3157,6 +3164,26 @@ export default {
     exportToFile: 'Exporter vers fichier',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Intégration Spoolman',

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

@@ -1779,6 +1779,12 @@ export default {
     embeddedOverlay: 'Overlay incorporato',
     preferredSlicer: 'Slicer preferito',
     preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: 'Camere esterne',
     costTracking: 'Tracciamento costi',
     printsOnly: 'Solo stampe',
@@ -2873,6 +2879,7 @@ export default {
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
     noPermissionPrint: 'Non hai il permesso di stampare',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
     noPermissionDownload: 'Non hai il permesso di scaricare file',
     noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
@@ -3156,6 +3163,26 @@ export default {
     exportToFile: 'Esporta su file',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Integrazione Spoolman',

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

@@ -1804,6 +1804,12 @@ export default {
     embeddedOverlay: '埋め込みオーバーレイ',
     preferredSlicer: '優先スライサー',
     preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: '外部カメラ',
     costTracking: 'コスト追跡',
     printsOnly: '印刷のみ',
@@ -2912,6 +2918,7 @@ export default {
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
     noPermissionPrint: '印刷する権限がありません',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
     noPermissionRenameFile: 'このファイル名を変更する権限がありません',
@@ -3195,6 +3202,26 @@ export default {
     exportToFile: 'ファイルにエクスポート',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Spoolman連携',

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

@@ -1779,6 +1779,12 @@ export default {
     embeddedOverlay: 'Sobreposição Incorporada',
     preferredSlicer: 'Fatiador Preferido',
     preferredSlicerDescription: 'Escolha qual aplicativo de fatiamento abrirá os arquivos',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: 'Câmeras Externas',
     costTracking: 'Rastreamento de Custos',
     printsOnly: 'Apenas Impressões',
@@ -2887,6 +2893,7 @@ export default {
     noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
     noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
     noPermissionPrint: 'Você não tem permissão para imprimir',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
     noPermissionDownload: 'Você não tem permissão para baixar arquivos',
     noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
@@ -3170,6 +3177,26 @@ export default {
     exportToFile: 'Exportar para Arquivo',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Integração com Spoolman',

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

@@ -1831,6 +1831,12 @@ export default {
     embeddedOverlay: '嵌入式叠加层',
     preferredSlicer: '首选切片软件',
     preferredSlicerDescription: '选择要用于打开文件的切片软件',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: '外部摄像头',
     costTracking: '成本追踪',
     printsOnly: '仅打印',
@@ -2939,6 +2945,7 @@ export default {
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
     noPermissionPrint: '您没有打印的权限',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: '您没有添加到队列的权限',
     noPermissionDownload: '您没有下载文件的权限',
     noPermissionRenameFile: '您没有重命名此文件的权限',
@@ -3222,6 +3229,26 @@ export default {
     exportToFile: '导出到文件',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Spoolman 集成',

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

@@ -1831,6 +1831,12 @@ export default {
     embeddedOverlay: '嵌入式疊加層',
     preferredSlicer: '首選切片軟體',
     preferredSlicerDescription: '選擇要用於開啟檔案的切片軟體',
+    useSlicerApi: 'Use Slicer API',
+    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    slicerCard: 'Slicer',
+    orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
+    bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     externalCameras: '外部攝影機',
     costTracking: '成本追蹤',
     printsOnly: '僅列印',
@@ -2939,6 +2945,7 @@ export default {
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
     noPermissionPrint: '您沒有列印的權限',
+    noPermissionSlice: 'You do not have permission to slice files',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
     noPermissionDownload: '您沒有下載檔案的權限',
     noPermissionRenameFile: '您沒有重新命名此檔案的權限',
@@ -3222,6 +3229,26 @@ export default {
     exportToFile: '匯出到檔案',
   },
 
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: 'Slice model',
+    action: 'Slice',
+    slicing: 'Slicing…',
+    printer: 'Printer profile',
+    process: 'Process profile',
+    filament: 'Filament profile',
+    selectPreset: '— Select a preset —',
+    loadingPresets: 'Loading presets…',
+    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    fallbackUsedEmbedded: "Slicer rejected the chosen profiles for this model — used the file's embedded settings instead.",
+    enqueuing: 'Submitting slice job…',
+    queued: 'Queued…',
+    failed: 'Slicing failed. Check the slicer sidecar logs.',
+    startedToast: 'Slicing {{name}} in the background…',
+    completedToast: 'Sliced {{name}}',
+    failedToast: 'Slicing {{name}} failed: {{detail}}',
+  },
+
   // Spoolman
   spoolman: {
     title: 'Spoolman 整合',

+ 54 - 11
frontend/src/pages/ArchivesPage.tsx

@@ -53,8 +53,10 @@ import {
   Play,
   ClipboardList,
   Zap,
+  Cog,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { SliceModal } from '../components/SliceModal';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
@@ -145,6 +147,7 @@ function ArchiveCard({
   isHighlighted,
   timeFormat = 'system',
   preferredSlicer = 'bambu_studio',
+  useSlicerApi = false,
   currency,
   t,
   onNavigateToArchive,
@@ -158,6 +161,7 @@ function ArchiveCard({
   isHighlighted?: boolean;
   timeFormat?: TimeFormat;
   preferredSlicer?: SlicerType;
+  useSlicerApi?: boolean;
   currency: string;
   t: TFunction;
   onNavigateToArchive?: (archiveId: number) => void;
@@ -173,6 +177,7 @@ function ArchiveCard({
   const isMobile = useIsMobile();
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
+  const [showSliceModal, setShowSliceModal] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
@@ -407,10 +412,14 @@ function ArchiveCard({
     ] : [
       {
         label: t('archives.menu.slice'),
-        icon: <ExternalLink className="w-4 h-4" />,
+        icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
         onClick: () => {
-          const filename = archive.print_name || archive.filename || 'model';
-          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+          if (useSlicerApi) {
+            setShowSliceModal(true);
+          } else {
+            const filename = archive.print_name || archive.filename || 'model';
+            openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+          }
         },
       },
     ]),
@@ -1116,18 +1125,26 @@ function ArchiveCard({
               </Button>
             </>
           ) : (
-            // Source file only - must open in slicer first
+            // Source file only - "Slice" action
             <Button
               variant="primary"
               size="sm"
               className="flex-1 min-w-0 overflow-hidden"
               onClick={() => {
-                const filename = archive.print_name || archive.filename || 'model';
-                openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+                if (useSlicerApi) {
+                  setShowSliceModal(true);
+                } else {
+                  const filename = archive.print_name || archive.filename || 'model';
+                  openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+                }
               }}
-              title={t('archives.card.openInBambuStudioToSlice')}
+              title={useSlicerApi ? t('slice.title') : t('archives.card.openInBambuStudioToSlice')}
             >
-              <ExternalLink className="w-3 h-3 flex-shrink-0" />
+              {useSlicerApi ? (
+                <Cog className="w-3 h-3 flex-shrink-0" />
+              ) : (
+                <ExternalLink className="w-3 h-3 flex-shrink-0" />
+              )}
               <span className="hidden sm:inline truncate">{t('archives.card.slice')}</span>
             </Button>
           )}
@@ -1206,6 +1223,14 @@ function ArchiveCard({
         />
       )}
 
+      {/* Slice Modal */}
+      {showSliceModal && (
+        <SliceModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowSliceModal(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
         <ConfirmModal
@@ -1448,6 +1473,7 @@ function ArchiveListRow({
   projects,
   isHighlighted,
   preferredSlicer = 'bambu_studio',
+  useSlicerApi = false,
   t,
   onNavigateToArchive,
 }: {
@@ -1459,6 +1485,7 @@ function ArchiveListRow({
   projects: ProjectListItem[] | undefined;
   isHighlighted?: boolean;
   preferredSlicer?: SlicerType;
+  useSlicerApi?: boolean;
   t: TFunction;
   onNavigateToArchive?: (archiveId: number) => void;
 }) {
@@ -1469,6 +1496,7 @@ function ArchiveListRow({
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
+  const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
@@ -1676,10 +1704,14 @@ function ArchiveListRow({
     ] : [
       {
         label: t('archives.menu.slice'),
-        icon: <ExternalLink className="w-4 h-4" />,
+        icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
         onClick: () => {
-          const filename = archive.print_name || archive.filename || 'model';
-          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+          if (useSlicerApi) {
+            setShowSliceModal(true);
+          } else {
+            const filename = archive.print_name || archive.filename || 'model';
+            openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
+          }
         },
       },
     ]),
@@ -2132,6 +2164,14 @@ function ArchiveListRow({
         />
       )}
 
+      {/* Slice Modal */}
+      {showSliceModal && (
+        <SliceModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowSliceModal(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
         <ConfirmModal
@@ -2533,6 +2573,7 @@ export function ArchivesPage() {
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
+  const useSlicerApi = settings?.use_slicer_api ?? false;
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 
   const bulkDeleteMutation = useMutation({
@@ -3408,6 +3449,7 @@ export function ArchivesPage() {
                 isHighlighted={archive.id === highlightedArchiveId}
                 timeFormat={timeFormat}
                 preferredSlicer={preferredSlicer}
+                useSlicerApi={useSlicerApi}
                 currency={currency}
                 t={t}
                 onNavigateToArchive={handleNavigateToArchive}
@@ -3449,6 +3491,7 @@ export function ArchivesPage() {
                   projects={projects}
                   isHighlighted={archive.id === highlightedArchiveId}
                   preferredSlicer={preferredSlicer}
+                  useSlicerApi={useSlicerApi}
                   t={t}
                   onNavigateToArchive={handleNavigateToArchive}
                 />

+ 71 - 4
frontend/src/pages/FileManagerPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
-import { Link, useSearchParams } from 'react-router-dom';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -31,6 +31,7 @@ import {
   Unlink,
   Archive as ArchiveIcon,
   Briefcase,
+  Cog,
   Printer,
   Pencil,
   Play,
@@ -56,6 +57,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
+import { SliceModal } from '../components/SliceModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
@@ -683,6 +685,14 @@ function isSlicedFilename(filename: string): boolean {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 
+// Files that can be fed to the slicer sidecar (model geometry inputs).
+// Excludes .gcode.* (already sliced) and any other non-model formats.
+function isSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp');
+}
+
 // File Card
 interface FileCardProps {
   file: LibraryFileListItem;
@@ -693,6 +703,8 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
+  onSlice?: (file: LibraryFileListItem) => void;
+  useSlicerApi?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -703,7 +715,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -808,6 +820,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('fileManager.schedulePrint')}
                 </button>
               )}
+              {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:upload')}
+                  title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
+                >
+                  <Cog className="w-3.5 h-3.5" />
+                  {t('slice.action')}
+                </button>
+              )}
               {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
@@ -892,6 +917,7 @@ export function FileManagerPage() {
   const { showToast } = useToast();
   const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
   const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
 
   // Read folder ID from URL query parameter
   const folderIdFromUrl = searchParams.get('folder');
@@ -910,6 +936,7 @@ export function FileManagerPage() {
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
+  const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
@@ -1947,7 +1974,19 @@ export function FileManagerPage() {
                       if (file) setScheduleFile(file);
                     }}
                     onPrint={setPrintFile}
-                    onPreview3d={setViewerFile}
+                    onSlice={setSliceFile}
+                    useSlicerApi={settings?.use_slicer_api ?? false}
+                    onPreview3d={(f) => {
+                      // Sliced files (.gcode / .gcode.3mf) open the same
+                      // full-page gcode viewer the archive card uses, so
+                      // the two paths feel consistent. STL / source 3MF
+                      // continue to use the in-app 3D model viewer modal.
+                      if (isSlicedFilename(f.filename)) {
+                        navigate(`/gcode-viewer?library_file=${f.id}`);
+                      } else {
+                        setViewerFile(f);
+                      }
+                    }}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
@@ -2083,9 +2122,30 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
+                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                        <button
+                          onClick={() => hasPermission('library:upload') && setSliceFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:upload')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
+                          disabled={!hasPermission('library:upload')}
+                        >
+                          <Cog className="w-4 h-4" />
+                        </button>
+                      )}
                       {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
                         <button
-                          onClick={() => hasPermission('library:read') && setViewerFile(file)}
+                          onClick={() => {
+                            if (!hasPermission('library:read')) return;
+                            if (isSlicedFilename(file.filename)) {
+                              navigate(`/gcode-viewer?library_file=${file.id}`);
+                            } else {
+                              setViewerFile(file);
+                            }
+                          }}
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2280,6 +2340,13 @@ export function FileManagerPage() {
         />
       )}
 
+      {sliceFile && (
+        <SliceModal
+          source={{ kind: 'libraryFile', id: sliceFile.id, filename: sliceFile.filename }}
+          onClose={() => setSliceFile(null)}
+        />
+      )}
+
       {viewerFile && (
         <ModelViewerModal
           libraryFileId={viewerFile.id}

+ 83 - 31
frontend/src/pages/MakerworldPage.tsx

@@ -15,6 +15,8 @@ import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { SliceModal, type SliceSource } from '../components/SliceModal';
+import { Cog } from 'lucide-react';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 
@@ -180,6 +182,16 @@ export function MakerworldPage() {
   const preferredSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
   const preferredSlicerName =
     preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
+  const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
+
+  // Slice-via-API modal source. When set, the SliceModal is shown for the
+  // referenced library file; it covers MakerWorld's "Slice in <Slicer>" /
+  // "Open in Slicer" actions whenever the user has Use Slicer API enabled.
+  const [sliceModalSource, setSliceModalSource] = useState<SliceSource | null>(null);
+
+  const openSliceForLibraryFile = (libraryFileId: number, filename: string) => {
+    setSliceModalSource({ kind: 'libraryFile', id: libraryFileId, filename });
+  };
 
   const resolveMutation = useMutation({
     mutationFn: (url: string) => api.resolveMakerworldUrl(url),
@@ -268,7 +280,14 @@ export function MakerworldPage() {
       if (data.profile_id) {
         setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
       }
-      await handleOpenInSlicer(data.library_file_id, data.filename, preferredSlicer);
+      // After import, branch on the user's slicer-API preference: API mode
+      // opens the in-app SliceModal; URI mode hands the file off to the
+      // local slicer GUI (the historical behavior).
+      if (useSlicerApi) {
+        openSliceForLibraryFile(data.library_file_id, data.filename);
+      } else {
+        await handleOpenInSlicer(data.library_file_id, data.filename, preferredSlicer);
+      }
     },
     onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
   });
@@ -704,26 +723,39 @@ export function MakerworldPage() {
                           <FolderOpen className="w-3.5 h-3.5" />
                           <span className="ml-1.5">{t('makerworld.viewInLibrary')}</span>
                         </Button>
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          onClick={() =>
-                            handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
-                          }
-                        >
-                          <ExternalLink className="w-3.5 h-3.5" />
-                          <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
-                        </Button>
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          onClick={() =>
-                            handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
-                          }
-                        >
-                          <ExternalLink className="w-3.5 h-3.5" />
-                          <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
-                        </Button>
+                        {useSlicerApi ? (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => openSliceForLibraryFile(imported.library_file_id, imported.filename)}
+                          >
+                            <Cog className="w-3.5 h-3.5" />
+                            <span className="ml-1.5">{t('slice.action', 'Slice')}</span>
+                          </Button>
+                        ) : (
+                          <>
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              onClick={() =>
+                                handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
+                              }
+                            >
+                              <ExternalLink className="w-3.5 h-3.5" />
+                              <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
+                            </Button>
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              onClick={() =>
+                                handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
+                              }
+                            >
+                              <ExternalLink className="w-3.5 h-3.5" />
+                              <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
+                            </Button>
+                          </>
+                        )}
                         <div className="ml-auto">
                           <Button
                             variant="ghost"
@@ -806,16 +838,29 @@ export function MakerworldPage() {
                           >
                             <FolderOpen className="w-3.5 h-3.5" />
                           </Button>
-                          <Button
-                            variant="ghost"
-                            size="sm"
-                            onClick={() =>
-                              handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
-                            }
-                            title={t('makerworld.openInBambuStudio')}
-                          >
-                            <ExternalLink className="w-3.5 h-3.5" />
-                          </Button>
+                          {useSlicerApi ? (
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              onClick={() =>
+                                openSliceForLibraryFile(item.library_file_id, item.filename)
+                              }
+                              title={t('slice.action', 'Slice')}
+                            >
+                              <Cog className="w-3.5 h-3.5" />
+                            </Button>
+                          ) : (
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              onClick={() =>
+                                handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
+                              }
+                              title={t('makerworld.openInBambuStudio')}
+                            >
+                              <ExternalLink className="w-3.5 h-3.5" />
+                            </Button>
+                          )}
                           {item.source_url && (
                             <a
                               href={item.source_url}
@@ -860,6 +905,13 @@ export function MakerworldPage() {
         />
       )}
 
+      {sliceModalSource && (
+        <SliceModal
+          source={sliceModalSource}
+          onClose={() => setSliceModalSource(null)}
+        />
+      )}
+
       {lightbox && (
         <div
           className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"

+ 93 - 20
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -64,6 +64,7 @@ registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback
 registerSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' });
 registerSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' });
 registerSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' });
+registerSettingsSearch({ labelKey: 'settings.slicerCard', labelFallback: 'Slicer', tab: 'queue', keywords: 'slicer orcaslicer bambustudio orca bambu api sidecar url docker preferred', anchor: 'card-slicer' });
 registerSettingsSearch({ labelKey: 'settings.queueDrying', tab: 'queue', keywords: 'drying presets temperature time humidity ams', anchor: 'card-drying' });
 registerSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' });
 registerSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' });
@@ -965,6 +966,9 @@ export function SettingsPage() {
       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.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
+      (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
+      (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
+      (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token ||
       (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
@@ -1046,6 +1050,9 @@ export function SettingsPage() {
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
         preferred_slicer: localSettings.preferred_slicer,
+        use_slicer_api: localSettings.use_slicer_api,
+        orcaslicer_api_url: localSettings.orcaslicer_api_url,
+        bambu_studio_api_url: localSettings.bambu_studio_api_url,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
         user_notifications_enabled: localSettings.user_notifications_enabled,
@@ -1534,25 +1541,6 @@ export function SettingsPage() {
                   {t('settings.defaultPrinterDescription')}
                 </p>
               </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">{t('settings.slicerBambuStudio')}</option>
-                    <option value="orcaslicer">{t('settings.slicerOrcaSlicer')}</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>
                   <p className="text-white">{t('settings.sidebarOrder')}</p>
@@ -4085,6 +4073,91 @@ export function SettingsPage() {
           </div>
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-3">
+          {/* Slicer */}
+          <Card id="card-slicer">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Cog className="w-4 h-4 text-bambu-green" />
+                {t('settings.slicerCard', 'Slicer')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-3">
+              <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">{t('settings.slicerBambuStudio')}</option>
+                    <option value="orcaslicer">{t('settings.slicerOrcaSlicer')}</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 gap-3">
+                <div className="min-w-0">
+                  <p className="text-white">{t('settings.useSlicerApi')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.useSlicerApiDescription')}
+                  </p>
+                </div>
+                <label className="flex items-center cursor-pointer shrink-0">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.use_slicer_api ?? false}
+                    onChange={(e) => updateSetting('use_slicer_api', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="relative w-11 h-6 bg-bambu-dark-tertiary rounded-full peer peer-checked:bg-bambu-green peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-bambu-green/50 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-5"></div>
+                </label>
+              </div>
+              {(localSettings.use_slicer_api ?? false) && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {(localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
+                      ? t('settings.orcaslicerApiUrl', 'OrcaSlicer sidecar URL')
+                      : t('settings.bambuStudioApiUrl', 'Bambu Studio sidecar URL')}
+                  </label>
+                  <input
+                    type="text"
+                    value={
+                      ((localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
+                        ? localSettings.orcaslicer_api_url
+                        : localSettings.bambu_studio_api_url) ?? ''
+                    }
+                    onChange={(e) =>
+                      updateSetting(
+                        (localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
+                          ? 'orcaslicer_api_url'
+                          : 'bambu_studio_api_url',
+                        e.target.value,
+                      )
+                    }
+                    placeholder={
+                      (localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
+                        ? 'http://localhost:3003'
+                        : 'http://localhost:3001'
+                    }
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none placeholder:text-bambu-gray/40"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t(
+                      'settings.slicerApiUrlDescription',
+                      'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+                    )}
+                  </p>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+
           {/* Auto-Drying */}
           <Card>
             <CardHeader>

+ 69 - 6
gcode_viewer/js/bambuddy_adapter.js

@@ -180,6 +180,13 @@
                 /^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)$/,
                 API_BASE + '/archives/$1/gcode'
             );
+            // OctoPrint file download  →  Bambuddy library file gcode
+            // (sliced LibraryFile — extracts embedded gcode from .gcode.3mf
+            // or returns plain .gcode). Plate is ignored upstream for now.
+            newPath = newPath.replace(
+                /^\/?downloads\/files\/local\/__bambuddy_libgcode_(\d+)(?:_plate\d+)?$/,
+                API_BASE + '/library/files/$1/gcode'
+            );
             // OctoPrint plugin static assets  →  gcode-viewer static files
             newPath = newPath.replace(
                 /^\/?plugin\/prettygcode\/static\//,
@@ -411,6 +418,57 @@
             .catch(function () { /* best-effort — default bed stays */ });
     }
 
+    // -------------------------------------------------------------------------
+    // 10b. Library file loader — invoked via /gcode-viewer/?library_file=<id>
+    // -------------------------------------------------------------------------
+    function loadLibraryFileById(fileId, plate) {
+        // Mirror loadArchiveById, but use the library gcode endpoint. The
+        // /library/files/<id>/gcode endpoint extracts the embedded gcode
+        // from a .gcode.3mf or returns a plain .gcode body. It does not
+        // accept a plate selector today — multi-plate library files would
+        // need an upstream change. The plate suffix here is preserved in
+        // currentFilename for display only.
+        var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
+        currentFileId = 'libfile_' + fileId + plateSuffix;
+        currentFilename = 'Library file #' + fileId + (plateSuffix ? (' (plate ' + plate + ')') : '');
+        currentFileDate = Date.now();
+        gcodeLayerMap = null;
+        lastFedLayer = -1;
+        stopPlayback(true);
+        updateFilenameDisplay(currentFilename);
+        var playBtn = document.getElementById('bb-play-btn');
+        if (playBtn) playBtn.disabled = false;
+        if (viewModel && viewModel.fromCurrentData) {
+            viewModel.fromCurrentData({
+                job: {
+                    file: {
+                        path: '__bambuddy_libgcode_' + fileId + plateSuffix,
+                        date: currentFileDate,
+                    },
+                    estimatedPrintTime: null,
+                },
+                state: { text: 'Operational', flags: { printing: false } },
+                progress: { filepos: null, completion: 0 },
+                currentZ: null,
+                logs: [],
+            });
+        }
+
+        // Fetch metadata for the filename display. There is no
+        // /library/files/<id>/capabilities endpoint, so the bed stays at
+        // whatever the default fakePrinterProfile is set to.
+        apiFetch('/library/files/' + fileId, {})
+            .then(function (r) { return r.ok ? r.json() : null; })
+            .then(function (meta) {
+                if (meta && (meta.print_name || meta.filename)) {
+                    currentFilename = (meta.print_name || meta.filename) +
+                        (plateSuffix ? (' (plate ' + plate + ')') : '');
+                    updateFilenameDisplay(currentFilename);
+                }
+            })
+            .catch(function () { /* best-effort — filename stays "Library file #N" */ });
+    }
+
     // -------------------------------------------------------------------------
     // 11. Initialise after DOM + scripts are ready
     // -------------------------------------------------------------------------
@@ -548,19 +606,23 @@
     function onDomReady() {
         setTimeout(function () {
             init();
-            // If the viewer was opened with ?archive=<id>[&plate=<N>], load that
-            // archive's gcode for the requested plate once the viewmodel is ready.
+            // If the viewer was opened with ?archive=<id>[&plate=<N>] or
+            // ?library_file=<id>[&plate=<N>], load that source's gcode once
+            // the viewmodel is ready.
             try {
                 var params = new URLSearchParams(window.location.search);
                 var archiveParam = params.get('archive');
+                var libParam = params.get('library_file');
                 var plateParam = params.get('plate');
+                var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
+                    ? parseInt(plateParam, 10)
+                    : undefined;
                 if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
                     var archiveId = parseInt(archiveParam, 10);
-                    var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
-                        ? parseInt(plateParam, 10)
-                        : undefined;
-                    // Allow a tick for init() to finish wiring viewModel.fromCurrentData
                     setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
+                } else if (libParam && /^[1-9][0-9]*$/.test(libParam)) {
+                    var libId = parseInt(libParam, 10);
+                    setTimeout(function () { loadLibraryFileById(libId, plateId); }, 50);
                 }
             } catch (e) { /* URLSearchParams unsupported — skip */ }
         }, 200);
@@ -577,6 +639,7 @@
     // -------------------------------------------------------------------------
     window.BambuddyPrettyGCode = {
         loadArchive: loadArchiveById,
+        loadLibraryFile: loadLibraryFileById,
         getViewModel: function () { return viewModel; },
         play: startPlayback,
         stop: stopPlayback,

+ 12 - 0
slicer-api/.env.example

@@ -0,0 +1,12 @@
+# Copy to .env and tweak. Defaults match what Bambuddy expects out of the box.
+
+# Host ports. Bambuddy's virtual-printer feature already binds 3000 and
+# 3002, so the OrcaSlicer sidecar sits on 3003. Move them if you run
+# this stack on a different host or want to free those ports for VPs.
+ORCA_API_PORT=3003
+BAMBU_API_PORT=3001
+
+# Slicer versions. Pinned for reproducibility — bump these when you want
+# a newer slicer and accept a fresh ~220 MB BambuStudio download.
+ORCA_VERSION=2.3.2
+BAMBU_VERSION=02.06.00.51

+ 2 - 0
slicer-api/.gitignore

@@ -0,0 +1,2 @@
+.env
+data/

+ 102 - 0
slicer-api/README.md

@@ -0,0 +1,102 @@
+# Slicer-API sidecar (optional)
+
+Self-contained Docker Compose stack that runs HTTP wrappers around the
+OrcaSlicer and/or Bambu Studio CLI. Bambuddy's **Slice** action calls
+these to slice models server-side, no desktop slicer required.
+
+This folder is **optional**. Bambuddy works without it — Slice falls back
+to opening the model in the user's local desktop slicer via URI scheme.
+Enable the API path by:
+
+1. Starting one or both services here
+2. **Settings → Slicer → Use Slicer API** = on
+3. Set **Slicer sidecar URL** for whichever slicer you've started
+
+## Quick start
+
+```bash
+cd slicer-api/
+cp .env.example .env       # edit ports / versions if you like
+
+# OrcaSlicer only (default profile):
+docker compose up -d
+curl http://localhost:3003/health
+
+# Both slicers:
+docker compose --profile bambu up -d
+curl http://localhost:3001/health   # bambu-studio-api
+curl http://localhost:3003/health   # orca-slicer-api
+```
+
+First build downloads the slicer's AppImage (~110 MB OrcaSlicer, ~220 MB
+BambuStudio) and compiles the Node wrapper. Takes 3–8 minutes per service.
+Subsequent runs reuse the local image — instant start.
+
+## Ports
+
+| Service | Default host port | Why this port |
+|---|---|---|
+| `orca-slicer-api` | **3003** | Bambuddy's virtual-printer feature reserves 3000 and 3002 |
+| `bambu-studio-api` | **3001** | First free port in that range |
+
+Override via `ORCA_API_PORT` / `BAMBU_API_PORT` in `.env`.
+
+## Bambuddy wiring
+
+In the Bambuddy UI: **Settings → Slicer**:
+
+- **Preferred Slicer**: pick OrcaSlicer or Bambu Studio.
+- **Use Slicer API**: turn on.
+- **Sidecar URL**: paste the full URL of the chosen slicer's sidecar.
+  Default values match the Compose defaults:
+  - OrcaSlicer: `http://localhost:3003`
+  - Bambu Studio: `http://localhost:3001`
+
+Leaving the URL field blank uses the `SLICER_API_URL` /
+`BAMBU_STUDIO_API_URL` environment defaults from Bambuddy's config.
+
+## Where the source lives
+
+Both images build from the
+[`maziggy/orca-slicer-api`](https://github.com/maziggy/orca-slicer-api)
+fork (`bambuddy/profile-resolver` branch). The Compose file uses
+Docker's git build context, so you don't need to clone it manually —
+Docker pulls the repo at build time.
+
+The fork patches AFKFelix's upstream wrapper with the `inherits:`
+chain resolver, `from: "User"` → `"system"` rewrite, `# ` clone-prefix
+strip, and sentinel-value strip — all empirically required to slice
+real GUI exports without segfaulting the CLI. Once those land
+upstream, this Compose file can be flipped to pull from
+`ghcr.io/afkfelix/orca-slicer-api` directly.
+
+## Updating
+
+Bump the versions in `.env`, then:
+
+```bash
+docker compose --profile bambu build --no-cache
+docker compose --profile bambu up -d
+```
+
+`--no-cache` is needed because the Dockerfile downloads the AppImage
+inline; Docker won't re-fetch it on a version change otherwise.
+
+## Troubleshooting
+
+- **`address already in use` on port 3000 or 3002** — Bambuddy's
+  virtual-printer feature owns those. Don't change `ORCA_API_PORT` to
+  3000 or 3002.
+- **`/health` reports `version: "unknown"`** — cosmetic. The bundled
+  binary works; the wrapper just couldn't parse the version string from
+  the slicer's `--help` output (BambuStudio's format differs from
+  OrcaSlicer's, which is what the wrapper was tuned for).
+- **Slice returns "Failed to slice the model"** — the wrapper hides the
+  CLI's stderr. Re-run inside the container to see it:
+
+  ```bash
+  docker exec orca-slicer-api /app/squashfs-root/AppRun --slice 1 \
+      --load-settings "/path/to/printer.json;/path/to/preset.json" \
+      --load-filaments /path/to/filament.json \
+      --allow-newer-file --outputdir /tmp/out /path/to/model.3mf
+  ```

+ 71 - 0
slicer-api/docker-compose.yml

@@ -0,0 +1,71 @@
+# Optional slicer-API sidecar stack for Bambuddy.
+#
+# Both services are HTTP wrappers around a slicer CLI: the same Node code
+# (`maziggy/orca-slicer-api`, `bambuddy/profile-resolver` branch) bundled
+# with a different binary in each image. Bambuddy talks to them via the
+# URLs configured in Settings -> Slicer.
+#
+#   bambu-studio-api  → host port 3001 (BambuStudio CLI)
+#   orca-slicer-api   → host port 3003 (OrcaSlicer CLI)
+#
+# Bambuddy's virtual-printer feature reserves host ports 3000 and 3002,
+# which is why the OrcaSlicer sidecar sits on 3003. Override either port
+# in `.env` (see `.env.example`) if you don't run Bambuddy on this host.
+#
+# Usage:
+#   cd slicer-api/
+#   docker compose up -d                       # starts OrcaSlicer only
+#   docker compose --profile bambu up -d       # starts both
+#
+# First build pulls the source from the fork over git (~5 min — downloads
+# the BambuStudio AppImage, ~220 MB) and caches the image locally.
+# Subsequent runs reuse the cache. Pin the slicer versions via .env.
+
+services:
+  orca-slicer-api:
+    build:
+      context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver
+      dockerfile: Dockerfile
+      args:
+        ORCA_VERSION: "${ORCA_VERSION:-2.3.2}"
+    image: bambuddy-orca-slicer-api:orca${ORCA_VERSION:-2.3.2}
+    container_name: orca-slicer-api
+    restart: unless-stopped
+    ports:
+      - "${ORCA_API_PORT:-3003}:3000"
+    volumes:
+      - ./data/orca:/app/data
+    environment:
+      NODE_ENV: production
+      PORT: "3000"
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
+      interval: 30s
+      timeout: 5s
+      start_period: 10s
+      retries: 3
+
+  bambu-studio-api:
+    build:
+      context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver
+      dockerfile: Dockerfile.bambu-studio
+      args:
+        BAMBU_VERSION: "${BAMBU_VERSION:-02.06.00.51}"
+    image: bambuddy-bambu-studio-api:bambu${BAMBU_VERSION:-02.06.00.51}
+    container_name: bambu-studio-api
+    restart: unless-stopped
+    ports:
+      - "${BAMBU_API_PORT:-3001}:3000"
+    volumes:
+      - ./data/bambu:/app/data
+    environment:
+      NODE_ENV: production
+      PORT: "3000"
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
+      interval: 30s
+      timeout: 5s
+      start_period: 10s
+      retries: 3
+    profiles:
+      - bambu

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BmSEfzJC.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Ctop_H29.js


+ 2 - 2
static/index.html

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

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