mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49: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:
@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
|
||||
getArchivePlates: vi.fn(),
|
||||
getLibraryFileFilamentRequirements: vi.fn(),
|
||||
getArchiveFilamentRequirements: vi.fn(),
|
||||
listSlicerBundles: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
@@ -40,6 +41,7 @@ const mockApi = api as unknown as {
|
||||
getArchivePlates: ReturnType<typeof vi.fn>;
|
||||
getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
|
||||
getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
|
||||
listSlicerBundles: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
||||
@@ -119,6 +121,10 @@ describe('SliceModal', () => {
|
||||
plate_id: 1,
|
||||
filaments: [],
|
||||
});
|
||||
// Default: no bundles imported. Bundle-tier tests override this with a
|
||||
// populated array; everything else inherits the empty default so the
|
||||
// modal renders the original (preset-only) layout.
|
||||
mockApi.listSlicerBundles.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('auto-selects the highest-priority tier per slot on first load', async () => {
|
||||
@@ -957,4 +963,171 @@ describe('SliceModal', () => {
|
||||
expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bundle tier — picking an imported .bbscfg replaces the cloud/local/standard
|
||||
// dropdown set with bundle-scoped pickers and routes the slice through the
|
||||
// backend's bundle dispatch shape (no PresetRefs in the body).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Bundle tier', () => {
|
||||
const sampleBundle = {
|
||||
id: 'abc123def456abcd',
|
||||
printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
|
||||
printer: ['# Bambu Lab H2D 0.4 nozzle'],
|
||||
process: [
|
||||
'# 0.20mm Standard @BBL H2D',
|
||||
'# 0.16mm Standard @BBL H2D',
|
||||
],
|
||||
filament: [
|
||||
'# Bambu PLA Basic @BBL H2D',
|
||||
'# Bambu PETG HF @BBL H2D 0.4 nozzle',
|
||||
],
|
||||
version: '02.06.00.50',
|
||||
};
|
||||
|
||||
it('hides the bundle picker when no bundles are imported', async () => {
|
||||
// Default beforeEach already returns []; assert the picker isn't
|
||||
// rendered so users without bundles see the original layout.
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
expect(screen.queryByText(/slicer bundle/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the bundle picker when at least one bundle is imported', async () => {
|
||||
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/slicer bundle/i)).toBeDefined(),
|
||||
);
|
||||
// The bundle option is in the dropdown.
|
||||
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
||||
expect(
|
||||
Array.from(bundleSelect.options).map((o) => o.textContent),
|
||||
).toContain('# Bambu Lab H2D 0.4 nozzle');
|
||||
});
|
||||
|
||||
it('replaces preset dropdowns with bundle-scoped pickers when a bundle is selected', async () => {
|
||||
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
// First select is the bundle picker (new top-of-modal dropdown).
|
||||
await user.selectOptions(selects[0], sampleBundle.id);
|
||||
|
||||
// Wait for the bundle-mode UI to take over: process options should
|
||||
// now reflect the bundle's process names.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('# 0.20mm Standard @BBL H2D'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// The static printer label shows the bundle's printer. Both the
|
||||
// <option> in the bundle picker and the read-only <div> below
|
||||
// contain this text, so use getAllByText.
|
||||
const printerNameMatches = screen.getAllByText('# Bambu Lab H2D 0.4 nozzle');
|
||||
expect(printerNameMatches.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Cloud/local/standard preset names from the original tier no longer
|
||||
// appear in the visible dropdowns (the bundle replaced them).
|
||||
const visibleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
const allOptionTexts = visibleSelects.flatMap((sel) =>
|
||||
Array.from(sel.options).map((o) => o.textContent ?? ''),
|
||||
);
|
||||
// Cloud printer name shouldn't be in any visible dropdown anymore.
|
||||
expect(allOptionTexts).not.toContain('My Custom X1C');
|
||||
});
|
||||
|
||||
it('submits bundle dispatch shape (no PresetRefs) when a bundle is selected', async () => {
|
||||
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 99,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/99',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
await user.selectOptions(selects[0], sampleBundle.id);
|
||||
|
||||
// Wait for bundle-mode dropdowns to render.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
const [fileId, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
expect(fileId).toBe(100);
|
||||
expect(body.bundle).toEqual({
|
||||
bundle_id: sampleBundle.id,
|
||||
printer_name: '# Bambu Lab H2D 0.4 nozzle',
|
||||
process_name: '# 0.20mm Standard @BBL H2D',
|
||||
filament_names: ['# Bambu PLA Basic @BBL H2D'],
|
||||
});
|
||||
// The preset triplet must NOT be in the body — bundle dispatch
|
||||
// skips PresetRef resolution entirely on the backend.
|
||||
expect(body.printer_preset).toBeUndefined();
|
||||
expect(body.process_preset).toBeUndefined();
|
||||
expect(body.filament_presets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('switching back to "None" restores the preset triplet path', async () => {
|
||||
mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 100,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/100',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
||||
await user.selectOptions(bundleSelect, sampleBundle.id);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
|
||||
);
|
||||
|
||||
// Flip back to None.
|
||||
await user.selectOptions(bundleSelect, '');
|
||||
await waitFor(() => {
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
// After de-selecting bundle, the printer dropdown's first option
|
||||
// should be one of the original cloud/local/standard names.
|
||||
const printerOptions = Array.from(selects[1].options).map((o) => o.textContent);
|
||||
expect(printerOptions).toContain('My Custom X1C');
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
await waitFor(() => {
|
||||
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
expect(body.bundle).toBeUndefined();
|
||||
expect(body.printer_preset).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1155,6 +1155,13 @@ export interface PresetRef {
|
||||
source: PresetSource;
|
||||
id: string;
|
||||
}
|
||||
export interface SliceBundleSpec {
|
||||
bundle_id: string;
|
||||
printer_name: string;
|
||||
process_name: string;
|
||||
// Per-slot filament names in plate order. Index 0 = slot 1, etc.
|
||||
filament_names: string[];
|
||||
}
|
||||
export interface SliceRequest {
|
||||
printer_preset_id?: number;
|
||||
process_preset_id?: number;
|
||||
@@ -1167,6 +1174,11 @@ export interface SliceRequest {
|
||||
// backend validator promotes a singular into a one-element list when this
|
||||
// is omitted, so legacy single-color clients keep working unchanged.
|
||||
filament_presets?: PresetRef[];
|
||||
// Bundle dispatch: when set, the backend skips PresetRef resolution and
|
||||
// picks the JSON triplet from a sidecar-stored .bbscfg by name. Mutually
|
||||
// exclusive with the preset fields above (validator accepts both, but
|
||||
// dispatch ignores the preset side when bundle is set).
|
||||
bundle?: SliceBundleSpec;
|
||||
plate?: number;
|
||||
export_3mf?: boolean;
|
||||
}
|
||||
|
||||
@@ -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