mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
● 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user