feat(slicing): build-plate override in the SliceModal (#1337)

Slicing an STL via the integrated slicer always defaulted to whatever
  curr_bed_type lived in the chosen process preset (typically "Cool
  Plate"), which the slicer CLI rejected for high-temp filaments with
  "Plate 1: Cool Plate does not support filament 1". The user had no
  way to switch plates without cloning the preset in BambuStudio.

  The Slice modal now exposes a Build plate dropdown with the six
  canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
  SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
  Smooth PEI Plate) plus an "Auto (use process preset)" option that
  preserves the previous behavior. Positioned between Process profile
  and Filament rows so a long filament list never pushes it off the
  modal's scrolled viewport, and always enabled regardless of whether
  the user picked a Printer Preset Bundle.

  A new bed_type field on SliceRequest flows through both dispatch
  paths:
  - Resolved-preset path: _patch_process_bed_type overwrites
    curr_bed_type on the process JSON before forwarding to the sidecar.
    Works end-to-end today, no sidecar change needed.
  - Bundle dispatch path: slice_with_bundle adds a bedType form field
    to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
    fork) needs a matching change to honor it as --curr_bed_type on
    the CLI invocation; until then the field is silently ignored and
    the slice runs with the bundle's default plate.
This commit is contained in:
maziggy
2026-05-14 15:28:41 +02:00
parent b51ef33472
commit ccf985abfc
19 changed files with 643 additions and 119 deletions
@@ -138,11 +138,15 @@ describe('SliceModal', () => {
await waitFor(() => {
expect(screen.getByText('My Custom X1C')).toBeDefined();
});
// 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
// between process and filament — it overrides curr_bed_type on the
// process preset so the related controls cluster — and defaults to "".
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
expect(selects).toHaveLength(3);
expect(selects).toHaveLength(4);
expect(selects[0].value).toBe('local:1');
expect(selects[1].value).toBe('local:2');
expect(selects[2].value).toBe('local:3');
expect(selects[2].value).toBe('');
expect(selects[3].value).toBe('local:3');
// Slice button is enabled because all three slots auto-defaulted and
// the preview-slice query has resolved (mock returns immediately).
@@ -240,6 +244,65 @@ describe('SliceModal', () => {
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
const onClose = vi.fn();
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 42,
status: 'pending',
status_url: '/api/v1/slice-jobs/42',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose,
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
const user = userEvent.setup();
// Order with the dropdown now sits between Process and Filament:
// printer (0), process (1), bed-type (2), filament (3+). Find the
// bed-type select by name rather than positional index so this stays
// green if the layout adds another control around it.
const bedSelect = screen.getAllByRole('combobox').find((el) =>
(el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
) as HTMLSelectElement;
expect(bedSelect).toBeDefined();
await user.selectOptions(bedSelect, 'Textured PEI Plate');
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
100,
expect.objectContaining({ bed_type: 'Textured PEI Plate' }),
);
});
});
it('omits bed_type when the user leaves it on Auto (no override)', async () => {
const onClose = vi.fn();
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 42,
status: 'pending',
status_url: '/api/v1/slice-jobs/42',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
onClose,
});
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
expect(body).not.toHaveProperty('bed_type');
});
});
it('lets the user override the default and pick a Standard preset', async () => {
const onClose = vi.fn();
mockApi.sliceLibraryFile.mockResolvedValue({
@@ -603,8 +666,8 @@ describe('SliceModal', () => {
});
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
// 1 printer + 1 process + 2 filament = 4 dropdowns.
expect(screen.getAllByRole('combobox')).toHaveLength(4);
// 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
expect(screen.getAllByRole('combobox')).toHaveLength(5);
});
it('pre-picks each filament slot by matching colour metadata', async () => {
@@ -686,9 +749,11 @@ describe('SliceModal', () => {
const user = userEvent.setup();
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
// Slots 0 (printer) and 1 (process) are auto-picked. Slots 2 and 3 are
// the two filament dropdowns. Swap slot-2 (was black) to white.
await user.selectOptions(selects[2], 'cloud:F-WHITE');
// Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
// (#1337). Auto-picks land on printer/process/filaments; bed-type
// defaults to "". Swap filament-1 (index 3) from the auto-picked black
// to white.
await user.selectOptions(selects[3], 'cloud:F-WHITE');
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
@@ -884,12 +949,14 @@ describe('SliceModal', () => {
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
// Both filament rows render — 1 printer + 1 process + 2 filament = 4.
// Both filament rows render — 1 printer + 1 process + 1 bed-type +
// 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
// follow at 3 and 4.
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
expect(selects).toHaveLength(4);
expect(selects).toHaveLength(5);
// Slot 1 (used) is editable, slot 2 (not used) is disabled.
expect(selects[2].disabled).toBe(false);
expect(selects[3].disabled).toBe(true);
expect(selects[3].disabled).toBe(false);
expect(selects[4].disabled).toBe(true);
// The disabled row's label calls out why it's disabled.
expect(screen.getByText(/not used by this plate/i)).toBeDefined();
});
+6
View File
@@ -1206,6 +1206,12 @@ export interface SliceRequest {
bundle?: SliceBundleSpec;
plate?: number;
export_3mf?: boolean;
// Build-plate override (#1337). When omitted, the slicer uses the process
// preset's curr_bed_type as-is. Canonical values match BambuStudio /
// OrcaSlicer's enum: "Cool Plate", "Engineering Plate", "High Temp Plate",
// "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
// "Supertack Plate".
bed_type?: string | null;
}
// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
+73
View File
@@ -261,6 +261,13 @@ 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);
// 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
// because the process preset's default plate (typically "Cool Plate") is
// incompatible with high-temp filaments like ABS / ASA / PC, and the
// user had no way to switch plates without cloning the preset.
const [bedType, setBedType] = useState<string | null>(null);
const platesQuery = useQuery({
queryKey: ['slicePlates', source.kind, source.id],
@@ -429,6 +436,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
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 (
@@ -451,6 +461,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// 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 } : {}),
};
}
if (source.kind === 'libraryFile') {
@@ -651,6 +663,17 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
/>
</>
)}
{/* Bed-type override (#1337). Always visible, always enabled.
In non-bundle mode the backend patches curr_bed_type on the
resolved process JSON before forwarding to the sidecar; in
bundle mode the same value rides through as a sidecar form
field so the bundle's materialised process JSON gets the
override applied there too. */}
<BedTypeDropdown
value={bedType}
onChange={setBedType}
disabled={isEnqueuing}
/>
{/* Filament reqs may need a server-side preview-slice for
unsliced project files (single-pass, then cached). Show a
scoped spinner so the user sees the printer/process
@@ -831,6 +854,56 @@ function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
);
}
// Build-plate options offered in the SliceModal (#1337). Values are the
// canonical strings the slicer's StaticPrintConfig validator accepts as
// `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
// enum; OrcaSlicer accepts the same set with a Supertack alias that users
// can target via the same dropdown if they re-import their presets.
const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
{ value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
{
value: 'Cool Plate (SuperTack)',
labelKey: 'slice.bedType.coolPlateSuperTack',
fallback: 'Cool Plate SuperTack',
},
{ value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
{ value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
{ value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
{ value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
];
function BedTypeDropdown({
value,
onChange,
disabled,
}: {
value: string | null;
onChange: (value: string | null) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<label className="block">
<span className="block text-xs text-bambu-gray mb-1">
{t('slice.bedType.label', 'Build plate')}
</span>
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
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.bedType.auto', 'Auto (use process preset)')}</option>
{BED_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey, opt.fallback)}
</option>
))}
</select>
</label>
);
}
interface PresetDropdownProps {
label: string;
slot: Slot;
+10
View File
@@ -3388,6 +3388,16 @@ export default {
expired: 'Bambu-Cloud-Sitzung abgelaufen — erneut anmelden, um die Cloud-Profile zu aktualisieren.',
unreachable: 'Bambu Cloud ist gerade nicht erreichbar. Lokale und Standard-Profile funktionieren weiterhin.',
},
bedType: {
label: 'Druckbett',
auto: 'Auto (aus Prozess-Profil)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3391,6 +3391,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3377,6 +3377,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3376,6 +3376,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3388,6 +3388,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3376,6 +3376,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3376,6 +3376,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman
+10
View File
@@ -3376,6 +3376,16 @@ export default {
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
bedType: {
label: 'Build plate',
auto: 'Auto (use process preset)',
coolPlate: 'Cool Plate',
coolPlateSuperTack: 'Cool Plate SuperTack',
engineering: 'Engineering Plate',
highTemp: 'High Temp Plate',
texturedPEI: 'Textured PEI Plate',
smoothPEI: 'Smooth PEI Plate',
},
},
// Spoolman