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
+98 -58
View File
@@ -333,6 +333,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
// the user picks one (or implicitly for single-plate sources).
const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
// "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
// ``--slice 0`` to the BS CLI, producing a single output 3MF whose
// ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
// one archive, one file, all plates. Distinct from the per-plate
// ``selectedPlate`` mode (which slices just that one plate). Filament
// selection in this mode covers every slot the project defines, not
// just the slots the currently-visible plate happens to use — see
// ``allProjectFilamentSlots`` below.
const [sliceAllPlates, setSliceAllPlates] = useState(false);
// Build-plate override (#1337). null = inherit from the process preset
// (the default). Set to a canonical slicer enum value to patch
// curr_bed_type into the resolved process JSON before slicing — needed
@@ -393,14 +402,25 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// Filament slot list for the active plate. Falls back to one synthetic slot
// for STL/STEP and any "no metadata available" case so the modal still
// works (single dropdown, mono-color slice).
// works (single dropdown, mono-color slice). In ``sliceAllPlates`` mode
// we keep the same slot list (the backend already returns every project
// slot via ``extract_project_filaments_from_3mf``'s fallback path when
// slice_info doesn't carry per-plate filaments) but override every
// slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
// drop the "— not used by this plate" suffix and the dropdowns become
// selectable. Across the whole project, every defined slot IS used by
// at least one plate, so this is correct in slice-all mode.
const filamentSlots = useMemo<PlateFilament[]>(() => {
const reqs = filamentReqsQuery.data?.filaments ?? [];
if (reqs.length > 0) return reqs as PlateFilament[];
return [
{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 },
];
}, [filamentReqsQuery.data]);
const base: PlateFilament[] =
reqs.length > 0
? (reqs as PlateFilament[])
: [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
if (sliceAllPlates) {
return base.map((slot) => ({ ...slot, used_in_plate: true }));
}
return base;
}, [sliceAllPlates, filamentReqsQuery.data]);
const presetsQuery = useQuery({
queryKey: ['slicerPresets'],
@@ -537,57 +557,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
}, [selectedBundle, filamentSlots]);
const enqueueMutation = useMutation({
mutationFn: async () => {
let body: SliceRequest;
if (isBundleMode) {
// Bundle dispatch path. The selected bundle's first printer is the
// implicit printer choice (every .bbscfg carries exactly one).
if (
!selectedBundle ||
!bundleProcessName ||
bundleFilamentNames.length === 0 ||
bundleFilamentNames.some((n) => n == null)
) {
throw new Error(t('slice.bundleAllRequired'));
}
const bundleSpec: SliceBundleSpec = {
bundle_id: selectedBundle.id,
printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
process_name: bundleProcessName,
filament_names: bundleFilamentNames as string[],
};
body = {
bundle: bundleSpec,
...(selectedPlate != null ? { plate: selectedPlate } : {}),
// Bed-type override (#1337) also flows through the bundle path —
// the sidecar forwards `bedType` as --curr_bed_type to the CLI.
...(bedType != null ? { bed_type: bedType } : {}),
};
} else {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired'));
}
body = {
printer_preset: printerPreset,
process_preset: processPreset,
// The first slot also goes into the legacy singular field so the
// backend's older callers / clients keep behaving the same — the
// backend validator prefers `filament_presets` when both are set.
filament_preset: filamentPresets[0] as PresetRef,
filament_presets: filamentPresets as PresetRef[],
// Always send a concrete plate number when the source is multi-plate;
// omit otherwise so the backend default applies for STL / single-plate
// 3MF sources where the concept doesn't apply.
...(selectedPlate != null ? { plate: selectedPlate } : {}),
// Bed-type override (#1337).
...(bedType != null ? { bed_type: bedType } : {}),
};
}
mutationFn: async (plate: number | null) => {
const body = buildSliceBody(plate);
if (source.kind === 'libraryFile') {
return api.sliceLibraryFile(source.id, body);
}
@@ -603,6 +574,52 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
},
});
// Body builder shared by the single-plate and slice-all paths. ``plate``
// is the 1-indexed plate number to slice, or ``null`` for STL / single-
// plate 3MF sources where the field is omitted entirely.
function buildSliceBody(plate: number | null): SliceRequest {
if (isBundleMode) {
if (
!selectedBundle ||
!bundleProcessName ||
bundleFilamentNames.length === 0 ||
bundleFilamentNames.some((n) => n == null)
) {
throw new Error(t('slice.bundleAllRequired'));
}
const bundleSpec: SliceBundleSpec = {
bundle_id: selectedBundle.id,
printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
process_name: bundleProcessName,
filament_names: bundleFilamentNames as string[],
};
return {
bundle: bundleSpec,
...(plate != null ? { plate } : {}),
// Bed-type override (#1337) also flows through the bundle path —
// the sidecar forwards `bedType` as --curr_bed_type to the CLI.
...(bedType != null ? { bed_type: bedType } : {}),
};
}
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired'));
}
return {
printer_preset: printerPreset,
process_preset: processPreset,
filament_preset: filamentPresets[0] as PresetRef,
filament_presets: filamentPresets as PresetRef[],
...(plate != null ? { plate } : {}),
...(bedType != null ? { bed_type: bedType } : {}),
};
}
// Slice button stays disabled until the preview slice / embedded-metadata
// read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
// has a picked profile.
@@ -618,6 +635,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null);
const isEnqueuing = enqueueMutation.isPending;
const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
// entire flow (matches the existing PlatePickerModal contract used by the
@@ -870,11 +889,30 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
>
{t('common.cancel')}
</button>
{canSliceAll && (
<label
className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
title={t('slice.actionAllTitle', { count: totalPlateCount })}
>
<input
type="checkbox"
checked={sliceAllPlates}
onChange={(e) => setSliceAllPlates(e.target.checked)}
disabled={isEnqueuing}
className="cursor-pointer"
/>
{t('slice.allPlatesToggle', { count: totalPlateCount })}
</label>
)}
<button
type="button"
onClick={() => {
setErrorMessage(null);
enqueueMutation.mutate();
// ``plate=0`` is the sidecar's "all plates" sentinel — passes
// ``--slice 0`` to the BS CLI which produces a single 3MF
// with one ``Metadata/plate_N.gcode`` entry per plate.
const platePayload = sliceAllPlates ? 0 : selectedPlate;
enqueueMutation.mutate(platePayload);
}}
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"
@@ -884,6 +922,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
<Loader2 className="w-4 h-4 animate-spin" />
{t('slice.enqueuing')}
</>
) : sliceAllPlates ? (
t('slice.actionAll', { count: totalPlateCount })
) : (
t('slice.action')
)}