feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all

Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
This commit is contained in:
maziggy
2026-05-23 12:16:34 +02:00
parent ebba1385d3
commit 4686d108ef
27 changed files with 2024 additions and 307 deletions
@@ -543,6 +543,68 @@ describe('SliceModal', () => {
});
});
it('"Slice all plates" toggle sends plate=0 sentinel to the backend (#1493)', async () => {
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 42,
status: 'pending',
status_url: '/api/v1/slice-jobs/42',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
onClose: vi.fn(),
});
const user = userEvent.setup();
const plate1Button = await screen.findByRole('button', { name: /Plate 1.*Cube/ });
await user.click(plate1Button);
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
// The "Slice all plates" checkbox only appears for multi-plate sources.
const toggle = await screen.findByRole('checkbox', { name: /Slice all 2 plates/i });
await user.click(toggle);
// The action button's label flips to the "Slice all" form. Click it.
await user.click(screen.getByRole('button', { name: /Slice all 2 plates/i }));
await waitFor(() => {
expect(mockApi.sliceLibraryFile).toHaveBeenCalledTimes(1);
});
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
// ``plate=0`` is the BS CLI's all-plates sentinel — one slice call,
// one output 3MF with every plate's gcode inside, one archive.
expect((body as { plate?: number }).plate).toBe(0);
});
it('"Slice all plates" toggle is hidden for single-plate sources', async () => {
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'Single.3mf',
is_multi_plate: false,
plates: [
{
index: 1,
name: 'Plate 1',
objects: [],
has_thumbnail: false,
thumbnail_url: null,
print_time_seconds: null,
filament_used_grams: null,
filaments: [],
},
],
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
expect(screen.queryByRole('checkbox', { name: /Slice all/i })).toBeNull();
});
it('routes the plate fetch through getArchivePlates for archive sources', async () => {
mockApi.getArchivePlates.mockResolvedValue({
...makeMultiPlateLibraryResponse(),