● 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
+69 -6
View File
@@ -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,