● 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).
This commit is contained in:
maziggy
2026-04-27 15:28:37 +02:00
parent d92f48e3c6
commit 6deaa513af
40 changed files with 3556 additions and 539 deletions
+207
View File
@@ -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>
);
}