mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user