feat(slice-modal): Bundle tier for picking presets from imported .bbscfg

Closes the loop on the bundle work: users who imported a Printer
  Preset Bundle via Settings → Slicer Bundles can now pick it in the
  SliceModal and slice through the bundle dispatch path the backend
  already supports.

  UX:
  - New "Slicer bundle" picker at the top of the modal, rendered only
    when at least one bundle is imported (GET /slicer/bundles non-empty)
  - Selecting a bundle replaces cloud/local/standard preset dropdowns
    with bundle-scoped pickers (process + per-slot filament names from
    the bundle). Printer is implicit (each .bbscfg has exactly one).
  - Submit routes through SliceRequest.bundle so the backend skips
    PresetRef resolution and asks the sidecar to materialise the JSON
    triplet from the stored bundle by name.
  - "None" leaves the modal on the original preset triplet path.

  Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
This commit is contained in:
maziggy
2026-05-06 09:55:14 +02:00
parent 7e1105dcb6
commit a50958e426
7 changed files with 924 additions and 478 deletions
+308 -47
View File
@@ -1,4 +1,4 @@
import { Cloud, CloudOff, Cog, Loader2, X } from 'lucide-react';
import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -6,7 +6,10 @@ import {
api,
type PresetRef,
type PresetSource,
type SliceBundleSpec,
type SliceJobProgress,
type SliceRequest,
type SlicerBundle,
type SlicerCloudStatus,
type UnifiedPreset,
type UnifiedPresetsBySlot,
@@ -245,6 +248,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// entry per AMS slot the plate uses. Pre-pick (effect below) initialises
// each slot from the source plate's required (type, colour).
const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
// Bundle dispatch (alternative to the preset triplet). When non-null, the
// SliceModal hides the cloud/local/standard preset dropdowns and shows
// bundle-scoped pickers (process + per-slot filament from the chosen
// bundle's contents). Submit routes through the backend's bundle dispatch
// (`SliceRequest.bundle`) which skips PresetRef resolution.
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | null)[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// null = plate not yet picked (or single-plate / non-3MF — picker is skipped
// and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
@@ -321,6 +332,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
enabled: !platesQuery.isLoading && !needsPlatePicker,
});
// Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
// configured / no bundles imported yet; the bundle picker hides itself
// in that case so users without bundles see the original modal layout.
const bundlesQuery = useQuery({
queryKey: ['slicerBundles'],
queryFn: api.listSlicerBundles,
staleTime: 60_000,
enabled: !platesQuery.isLoading && !needsPlatePicker,
// Bundle listing is a hard 503 when the sidecar is offline; don't
// retry tight loops in that case.
retry: false,
});
const selectedBundle: SlicerBundle | null = useMemo(() => {
if (!selectedBundleId || !bundlesQuery.data) return null;
return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
}, [selectedBundleId, bundlesQuery.data]);
const isBundleMode = selectedBundle != null;
// Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
// presets first arrive; subsequent re-renders preserve any manual choice.
useEffect(() => {
@@ -348,29 +377,82 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
});
}, [presetsQuery.data, filamentSlots]);
// Bundle-mode auto-pick: when the user picks a bundle (or the slot count
// changes after the picker is open), default the process to the bundle's
// first listed process and every filament slot to the bundle's first
// listed filament. Plain string match — bundles store delta files keyed
// by user preset name, no scoring needed since the user picks per-slot
// afterwards if the default is wrong.
useEffect(() => {
if (!selectedBundle) {
// Reset bundle picks when bundle is cleared so re-selection
// re-defaults rather than carrying stale values.
setBundleProcessName(null);
setBundleFilamentNames([]);
return;
}
setBundleProcessName((current) => {
// Preserve a manual pick if it still exists in the bundle; otherwise
// re-default. Same shape as the preset auto-pick effect above.
if (current && selectedBundle.process.includes(current)) return current;
return selectedBundle.process[0] ?? null;
});
setBundleFilamentNames((current) => {
if (current.length === filamentSlots.length && current.every((n) => n != null)) {
return current;
}
const fallback = selectedBundle.filament[0] ?? null;
return filamentSlots.map((_, i) => current[i] ?? fallback);
});
}, [selectedBundle, filamentSlots]);
const enqueueMutation = useMutation({
mutationFn: async () => {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
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', 'Bundle process and every filament slot must be picked'));
}
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 } : {}),
};
} else {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
}
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 } : {}),
};
}
const 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 } : {}),
};
if (source.kind === 'libraryFile') {
return api.sliceLibraryFile(source.id, body);
}
@@ -392,10 +474,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// is desktop-Studio only. If we can match the source's printer model to a
// SliceModal-known model and the user's chosen printer profile names a
// different model, surface a warning before they click Slice.
//
// For bundle mode, the bundle's printer_preset_name plays the same role
// as the picked PresetRef's resolved name in preset mode.
const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
const printerProfileName = printerPreset
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
: null;
const printerProfileName = isBundleMode
? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
: printerPreset
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
: null;
// Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
// nozzle"). The CLI compat check uses the model prefix; substring match
// catches both standard and locally-imported user-named profiles that
@@ -417,13 +504,19 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// auto-pick fills these once filamentSlots arrives)
// - no printer-mismatch warning is up (clicking would silently fall
// back to embedded settings and produce a wrong-printer file)
const isReady =
printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null) &&
!printerMismatch;
const isReady = isBundleMode
? selectedBundle != null &&
bundleProcessName != null &&
filamentReqsQuery.isSuccess &&
bundleFilamentNames.length > 0 &&
bundleFilamentNames.every((n) => n != null) &&
!printerMismatch
: printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null) &&
!printerMismatch;
const isEnqueuing = enqueueMutation.isPending;
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
@@ -501,22 +594,63 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{presetsQuery.data && (
<>
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
<PresetDropdown
label={t('slice.printer', 'Printer profile')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
disabled={isEnqueuing}
/>
<PresetDropdown
label={t('slice.process', 'Process profile')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing}
/>
{/* Bundle picker — only renders when at least one .bbscfg has
been imported via Settings → Slicer Bundles. Lets the user
trade the cloud/local/standard tier for a single curated
triplet from a previously-uploaded BambuStudio bundle. */}
{bundlesQuery.data && bundlesQuery.data.length > 0 && (
<BundlePicker
bundles={bundlesQuery.data}
selectedId={selectedBundleId}
onChange={setSelectedBundleId}
disabled={isEnqueuing}
/>
)}
{/* Preset triplet — hidden when a bundle is selected so the
user only sees one tier at a time. The bundle's process +
filament dropdowns render below in their stead. */}
{!isBundleMode && (
<>
<PresetDropdown
label={t('slice.printer', 'Printer profile')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
disabled={isEnqueuing}
/>
<PresetDropdown
label={t('slice.process', 'Process profile')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing}
/>
</>
)}
{isBundleMode && selectedBundle && (
<>
{/* Bundle's printer is implicit (each .bbscfg has exactly
one). Show it as a read-only label so the user can
verify the printer they're slicing for. */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('slice.printer', 'Printer profile')}
</label>
<div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
{selectedBundle.printer_preset_name}
</div>
</div>
<BundleStringDropdown
label={t('slice.process', 'Process profile')}
options={selectedBundle.process}
value={bundleProcessName}
onChange={setBundleProcessName}
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
@@ -526,6 +660,40 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
requestId={previewRequestId}
sourceName={source.filename}
/>
) : isBundleMode && selectedBundle ? (
filamentSlots.map((slot, idx) => {
const isUsed = slot.used_in_plate !== false;
const baseLabel =
filamentSlots.length > 1
? t('slice.filamentSlot', {
index: idx + 1,
type: slot.type,
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
})
: t('slice.filament', 'Filament profile');
const label = isUsed
? baseLabel
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
return (
<BundleStringDropdown
key={`bundle-filament-${idx}`}
label={label}
options={selectedBundle.filament}
value={bundleFilamentNames[idx] ?? null}
onChange={(name) =>
setBundleFilamentNames((current) => {
const next = current.length === filamentSlots.length
? [...current]
: filamentSlots.map((_, i) => current[i] ?? null);
next[idx] = name;
return next;
})
}
disabled={isEnqueuing || !isUsed}
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
/>
);
})
) : (
filamentSlots.map((slot, idx) => {
// Slots flagged by the backend as not used by the
@@ -734,3 +902,96 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchCo
</label>
);
}
// Top-of-modal bundle picker. The "None" option leaves the user on the
// cloud/local/standard tier path; selecting a bundle id flips the modal
// into bundle dispatch mode (see SliceModal state above).
interface BundlePickerProps {
bundles: SlicerBundle[];
selectedId: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
}
function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
const { t } = useTranslation();
return (
<label className="block">
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
<Package className="w-3.5 h-3.5" />
{t('slice.bundle', 'Slicer bundle')}
</span>
<select
value={selectedId ?? ''}
onChange={(e) => onChange(e.target.value || null)}
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.bundleNone', '— None (pick presets individually) —')}
</option>
{bundles.map((b) => (
<option key={b.id} value={b.id}>
{b.printer_preset_name}
</option>
))}
</select>
</label>
);
}
// Plain-string dropdown used for bundle-mode process / filament selectors.
// Bundles store presets as a flat list of names within their printer-tied
// directory, so a `<select>` of strings is enough — no source tier, no
// optgroups. Same swatch / disabled affordances as the cloud/local/standard
// PresetDropdown above so the visual rhythm of the form stays consistent.
interface BundleStringDropdownProps {
label: string;
options: string[];
value: string | null;
onChange: (next: string | null) => void;
disabled?: boolean;
swatchColor?: string;
}
function BundleStringDropdown({
label,
options,
value,
onChange,
disabled,
swatchColor,
}: BundleStringDropdownProps) {
const { t } = useTranslation();
return (
<label className="block">
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
{swatchColor && (
<span
className="inline-block w-3 h-3 rounded-sm border border-black/20"
style={{ backgroundColor: swatchColor || 'transparent' }}
aria-hidden
/>
)}
<span>{label}</span>
</span>
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled || options.length === 0}
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="">
{options.length === 0
? t('slice.noPresetsForSlot', 'No presets available')
: t('slice.selectPreset', '— Select a preset —')}
</option>
{options.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
);
}