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