mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
silently truncated multi-color 3MFs to a single profile, producing wrong
colours on every multi-filament print. End-to-end fix across sidecar,
backend, and frontend.
Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
- /slice accepts up to 16 repeated filamentProfile parts; slicing
service materializes each and joins paths with `;` for
--load-filaments.
- /profiles/bundled emits filament_type and filament_colour per leaf
so the bundled tier carries metadata into the modal.
Bambuddy backend:
- SliceRequest gains filament_presets: list[PresetRef]. Validator
accepts three shapes (multi-color array, source-aware singular,
legacy bare-int id) and lands them all on a populated array before
the route handler runs — fully backwards-compatible.
- SlicerApiService.slice_with_profiles takes filament_profile_jsons:
list[str] and sends one filamentProfile multipart part per profile
(in submission order) so the sidecar receives N profiles cleanly.
- New service slice_preview runs the sidecar's slice_without_profiles
against an unsliced project file's embedded settings, parses the
result's slice_info.config, and returns the canonical per-plate
filament list. Cached by (kind, source_id, plate_id, content_hash)
with LRU eviction at 256 entries, per-key asyncio.Lock prevents
thundering-herd; transient sidecar failures are NOT cached so they
retry naturally; parse failures ARE cached (deterministic property
of the input, no point re-running).
- /filament-requirements endpoint chain: slice_info.config (existing,
sliced files) → preview-slice (new, unsliced project files) →
project_settings.config + painted-face heuristic with 5% noise
threshold (sidecar-down fallback).
- threemf_tools gains extract_project_filaments_from_3mf and
extract_plate_extruder_set_from_3mf — the latter unions object
top-level extruder, per-part overrides, and painted-face quadtree
leaves (1-E nibbles in paint_color attrs of <triangle> elements
inside per-object .model files).
- Cloud preset listing no longer fetches per-preset detail (Bambu's
rate limit at ~10/sec returns 429 on every request for users with
50+ presets). Unified-listing dedup pass instead backfills metadata
cross-tier so a cloud entry that wins dedup over a same-named local
entry inherits the local's filament_type / filament_colour.
Frontend:
- SliceModal multi-step: plate-picker first when the source is a
multi-plate 3MF, then preset dropdowns. One filament dropdown per
AMS slot the plate actually uses, each pre-picked by metadata
match against user's local + standard presets via existing
colorsAreSimilar / normalizeColorForCompare utils.
- SliceModal-only tier priority is now local → cloud → standard
(was cloud → local → standard). Other consumers of /slicer/presets
keep the existing cloud-first order.
- Submits filament_presets array; backfills the legacy singular
filament_preset from the array's first entry for stale-tab
compatibility.
- i18n keys added across all 8 locales: slice.filamentSlot,
slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
expired,unreachable}, slice.noPresetsForSlot,
slice.allPresetsRequired (en + de fully translated; six others
seeded with English copies pending native translation, matching
the project's existing flow).
Permissions: no new endpoint paths added. Preview-slice runs inside
/filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
widened.
Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
precedence; 9 unit tests for slice_preview cache behaviour (LRU
eviction with lock cleanup, content-hash invalidation, concurrent
thundering-herd guard, no-cache-poison on transient sidecar failure);
15 unit tests for the two new threemf_tools helpers (5 + 10 cases
including the 60/40 painted-threshold regression pin); a multi-filament
wire-format test pinning the multipart part count + order; 22 frontend
SliceModal tests covering plate picker, multi-color render,
metadata-aware pre-pick, manual override, and the new tier order.
This commit is contained in:
@@ -22,6 +22,10 @@ vi.mock('../../api/client', () => ({
|
||||
sliceLibraryFile: vi.fn(),
|
||||
sliceArchive: vi.fn(),
|
||||
getSliceJob: vi.fn(),
|
||||
getLibraryFilePlates: vi.fn(),
|
||||
getArchivePlates: vi.fn(),
|
||||
getLibraryFileFilamentRequirements: vi.fn(),
|
||||
getArchiveFilamentRequirements: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
@@ -32,6 +36,10 @@ const mockApi = api as unknown as {
|
||||
sliceLibraryFile: ReturnType<typeof vi.fn>;
|
||||
sliceArchive: ReturnType<typeof vi.fn>;
|
||||
getSliceJob: ReturnType<typeof vi.fn>;
|
||||
getLibraryFilePlates: ReturnType<typeof vi.fn>;
|
||||
getArchivePlates: ReturnType<typeof vi.fn>;
|
||||
getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
|
||||
getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
||||
@@ -84,6 +92,33 @@ describe('SliceModal', () => {
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
});
|
||||
// Default: single-plate (or non-3MF). Multi-plate tests override this.
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue({
|
||||
file_id: 100,
|
||||
filename: 'Cube.stl',
|
||||
plates: [],
|
||||
is_multi_plate: false,
|
||||
});
|
||||
mockApi.getArchivePlates.mockResolvedValue({
|
||||
archive_id: 100,
|
||||
filename: 'Cube.3mf',
|
||||
plates: [],
|
||||
is_multi_plate: false,
|
||||
});
|
||||
// Default: no per-plate filament metadata available (mirrors STL or
|
||||
// unsliced source). Multi-color tests override this.
|
||||
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
|
||||
file_id: 100,
|
||||
filename: 'Cube.stl',
|
||||
plate_id: 1,
|
||||
filaments: [],
|
||||
});
|
||||
mockApi.getArchiveFilamentRequirements.mockResolvedValue({
|
||||
archive_id: 100,
|
||||
filename: 'Cube.3mf',
|
||||
plate_id: 1,
|
||||
filaments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects the highest-priority tier per slot on first load', async () => {
|
||||
@@ -92,44 +127,45 @@ describe('SliceModal', () => {
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
// The cloud tier wins — printer dropdown should land on the cloud entry.
|
||||
// SliceModal-specific tier priority: imported (local) wins over cloud
|
||||
// and standard so the user's curated picks come first.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Custom X1C')).toBeDefined();
|
||||
});
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects).toHaveLength(3);
|
||||
expect(selects[0].value).toBe('cloud:PFUcloud-printer');
|
||||
expect(selects[1].value).toBe('cloud:PFUcloud-process');
|
||||
expect(selects[2].value).toBe('cloud:PFUcloud-filament');
|
||||
expect(selects[0].value).toBe('local:1');
|
||||
expect(selects[1].value).toBe('local:2');
|
||||
expect(selects[2].value).toBe('local:3');
|
||||
|
||||
// Slice button is enabled because all three slots auto-defaulted.
|
||||
const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
|
||||
expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('renders Cloud / Imported / Standard sections via <optgroup>', async () => {
|
||||
it('renders Imported / Cloud / Standard sections via <optgroup>', async () => {
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
||||
|
||||
const printerSelect = screen.getAllByRole('combobox')[0];
|
||||
const groups = printerSelect.querySelectorAll('optgroup');
|
||||
expect(Array.from(groups).map((g) => g.label)).toEqual([
|
||||
'Cloud',
|
||||
'Imported',
|
||||
'Cloud',
|
||||
'Standard',
|
||||
]);
|
||||
|
||||
// The cloud entry sits inside the Cloud group, the local entry inside
|
||||
// Imported, the standard entry inside Standard — pin the assignment so
|
||||
// a future render-shape change can't quietly mix them.
|
||||
const cloudGroup = groups[0];
|
||||
expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
|
||||
const localGroup = groups[1];
|
||||
// Each entry sits inside its own tier's group — pin the assignment so
|
||||
// a future render-shape change can't quietly mix them. Order matches
|
||||
// SLICE_MODAL_TIER_ORDER (local → cloud → standard).
|
||||
const localGroup = groups[0];
|
||||
expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined();
|
||||
const cloudGroup = groups[1];
|
||||
expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
|
||||
const standardGroup = groups[2];
|
||||
expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
|
||||
});
|
||||
@@ -184,10 +220,14 @@ describe('SliceModal', () => {
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
// SliceModal-specific tier priority puts imported (local) above cloud,
|
||||
// so the auto-pick lands on the local entries even when a cloud entry
|
||||
// with the same slot is also available in the listing.
|
||||
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
|
||||
printer_preset: { source: 'cloud', id: 'PFUcloud-printer' },
|
||||
process_preset: { source: 'cloud', id: 'PFUcloud-process' },
|
||||
filament_preset: { source: 'cloud', id: 'PFUcloud-filament' },
|
||||
printer_preset: { source: 'local', id: '1' },
|
||||
process_preset: { source: 'local', id: '2' },
|
||||
filament_preset: { source: 'local', id: '3' },
|
||||
filament_presets: [{ source: 'local', id: '3' }],
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
@@ -324,4 +364,331 @@ describe('SliceModal', () => {
|
||||
// No status-role banner should be rendered on the happy path.
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
// ----- Multi-plate flow -----------------------------------------------
|
||||
|
||||
function makeMultiPlateLibraryResponse() {
|
||||
return {
|
||||
file_id: 100,
|
||||
filename: 'Multi.3mf',
|
||||
is_multi_plate: true,
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Cube'],
|
||||
object_count: 1,
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 600,
|
||||
filament_used_grams: 10,
|
||||
filaments: [],
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
name: 'Plate 2',
|
||||
objects: ['Pyramid'],
|
||||
object_count: 1,
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 800,
|
||||
filament_used_grams: 12,
|
||||
filaments: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('shows the plate picker first for multi-plate library files', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
// Plate picker renders one button per plate — the accessible name
|
||||
// joins the heading ("Plate N — name") with the object summary line.
|
||||
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
||||
expect(screen.getByRole('button', { name: /Plate 2.*Pyramid/ })).toBeDefined();
|
||||
// Profile dropdowns must NOT be visible yet — the user has to pick a
|
||||
// plate first.
|
||||
expect(screen.queryByRole('combobox')).toBeNull();
|
||||
});
|
||||
|
||||
it('skips the plate picker for single-plate sources', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue({
|
||||
file_id: 100,
|
||||
filename: 'Single.3mf',
|
||||
is_multi_plate: false,
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: [],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: null,
|
||||
filament_used_grams: null,
|
||||
filaments: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
// Should jump straight to the profile dropdowns.
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
});
|
||||
|
||||
it('passes the picked plate to the slice request', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
// Step 1: pick Plate 2.
|
||||
const plate2Button = await screen.findByRole('button', { name: /Plate 2.*Pyramid/ });
|
||||
await user.click(plate2Button);
|
||||
|
||||
// Step 2: profile dropdowns are now visible.
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
// Step 3: submit and verify the plate index made it into the body.
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.objectContaining({ plate: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes the plate fetch through getArchivePlates for archive sources', async () => {
|
||||
mockApi.getArchivePlates.mockResolvedValue({
|
||||
...makeMultiPlateLibraryResponse(),
|
||||
archive_id: 100,
|
||||
filename: 'Multi.3mf',
|
||||
});
|
||||
renderWithTracker({
|
||||
source: { kind: 'archive', id: 100, filename: 'Multi.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
||||
expect(mockApi.getArchivePlates).toHaveBeenCalledWith(100);
|
||||
expect(mockApi.getLibraryFilePlates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancelling the plate picker closes the entire slice flow', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
|
||||
onClose,
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /Plate 1.*Cube/ });
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: /^Close$/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the plate field when the source is single-plate', async () => {
|
||||
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: vi.fn(),
|
||||
});
|
||||
|
||||
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] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
expect(body).not.toHaveProperty('plate');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- Multi-color flow ------------------------------------------------
|
||||
|
||||
function makeMultiColorPlateResponse() {
|
||||
// Single-plate 3MF that uses two filament slots — mirrors the realistic
|
||||
// "I have a multi-color file with one plate" case. Multi-plate is a
|
||||
// separate axis that's already covered above.
|
||||
return {
|
||||
file_id: 100,
|
||||
filename: 'TwoColor.3mf',
|
||||
is_multi_plate: false,
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Logo'],
|
||||
object_count: 1,
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 600,
|
||||
filament_used_grams: 20,
|
||||
filaments: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeMultiColorRequirementsResponse() {
|
||||
return {
|
||||
file_id: 100,
|
||||
filename: 'TwoColor.3mf',
|
||||
plate_id: 1,
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3 },
|
||||
{ slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 10, used_meters: 3 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeColorAwarePresets(): UnifiedPresetsResponse {
|
||||
// Two filament presets in cloud: one black PLA, one white PLA. Pre-pick
|
||||
// should match each plate slot to the same-colour preset so the user
|
||||
// doesn't have to manually align them.
|
||||
return {
|
||||
cloud: {
|
||||
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
|
||||
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
|
||||
filament: [
|
||||
{ id: 'F-BLACK', name: 'Cloud PLA Black', source: 'cloud', filament_type: 'PLA', filament_colour: '#000000' },
|
||||
{ id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
|
||||
],
|
||||
},
|
||||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
it('renders one filament dropdown per plate slot when the source is multi-color', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
||||
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
||||
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
||||
// 1 printer + 1 process + 2 filament = 4 dropdowns.
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('pre-picks each filament slot by matching colour metadata', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
||||
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
||||
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
// Slot 1 was black plate → cloud black preset; slot 2 was white →
|
||||
// cloud white preset. Pre-pick aligns them by metadata so the user
|
||||
// doesn't have to swap them manually.
|
||||
expect(body.filament_presets).toEqual([
|
||||
{ source: 'cloud', id: 'F-BLACK' },
|
||||
{ source: 'cloud', id: 'F-WHITE' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('still sends the legacy filament_preset for single-color flows', async () => {
|
||||
// Backwards-compat with backends / proxies that read the singular field.
|
||||
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: vi.fn(),
|
||||
});
|
||||
|
||||
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] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
// Single-color path mirrors the array's first entry into the legacy
|
||||
// singular so older backend clients that only know about
|
||||
// `filament_preset` still work.
|
||||
expect(body.filament_preset).toEqual(body.filament_presets[0]);
|
||||
expect(body.filament_presets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the user override a pre-picked filament slot', async () => {
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
|
||||
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
|
||||
mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
||||
|
||||
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');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
|
||||
expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
|
||||
// Slot 1 stayed at the auto-picked white.
|
||||
expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1129,6 +1129,11 @@ export interface SliceRequest {
|
||||
printer_preset?: PresetRef;
|
||||
process_preset?: PresetRef;
|
||||
filament_preset?: PresetRef;
|
||||
// Multi-color: one PresetRef per plate slot, in plate order. Always
|
||||
// preferred over the singular `filament_preset` when both are sent; the
|
||||
// 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[];
|
||||
plate?: number;
|
||||
export_3mf?: boolean;
|
||||
}
|
||||
@@ -1139,6 +1144,13 @@ export interface UnifiedPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
source: PresetSource;
|
||||
// Populated for the filament slot only — used by the SliceModal multi-color
|
||||
// pre-pick to score presets against each plate slot's required (type,
|
||||
// colour). Optional because the bundled / standard tier rarely carries a
|
||||
// colour (colour is a runtime spool attribute on Bambu) and older API
|
||||
// responses pre-date these fields entirely.
|
||||
filament_type?: string | null;
|
||||
filament_colour?: string | null;
|
||||
}
|
||||
export interface UnifiedPresetsBySlot {
|
||||
printer: UnifiedPreset[];
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
type UnifiedPresetsResponse,
|
||||
} from '../api/client';
|
||||
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
||||
import { PlatePickerModal } from './PlatePickerModal';
|
||||
import type { PlateFilament } from '../types/plates';
|
||||
import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
|
||||
|
||||
export type SliceSource =
|
||||
| { kind: 'libraryFile'; id: number; filename: string }
|
||||
@@ -24,10 +27,16 @@ interface SliceModalProps {
|
||||
|
||||
type Slot = 'printer' | 'process' | 'filament';
|
||||
|
||||
// SliceModal-specific tier priority: local (imported) → cloud → standard.
|
||||
// Imported profiles are surfaced first because they're the user's curated
|
||||
// picks (often colour/type-tagged), cloud is second since names alone can't
|
||||
// drive metadata-aware match, standard is the bundled fallback. This is
|
||||
// distinct from the listing endpoint's dedup order and only affects what
|
||||
// the SliceModal renders / pre-picks.
|
||||
const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const;
|
||||
|
||||
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
||||
// Cloud > local > standard. The endpoint already deduplicates by name, so
|
||||
// no name-collision handling needed here — first non-empty tier wins.
|
||||
for (const tier of ['cloud', 'local', 'standard'] as const) {
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
const list = by[tier][slot];
|
||||
if (list.length > 0) {
|
||||
return { source: list[0].source, id: list[0].id };
|
||||
@@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TIER_BONUS: Record<PresetSource, number> = {
|
||||
local: 1.5,
|
||||
cloud: 1.0,
|
||||
standard: 0.5,
|
||||
};
|
||||
|
||||
function pickFilamentForSlot(
|
||||
by: UnifiedPresetsResponse,
|
||||
required: { type: string; color: string },
|
||||
): PresetRef | null {
|
||||
// Score every filament preset against the plate slot's required (type,
|
||||
// colour) and pick the highest. Mirrors the AMS slot-mapping match in the
|
||||
// print/schedule modal: type match dominates, exact-colour-match bumps over
|
||||
// similar-colour-match, and a small per-tier bonus breaks ties so cloud
|
||||
// user customisations win over standard bundled fallbacks of equal merit.
|
||||
const reqType = required.type.trim().toUpperCase();
|
||||
const reqColor = normalizeColorForCompare(required.color);
|
||||
|
||||
let best: { ref: PresetRef; score: number } | null = null;
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
for (const p of by[tier].filament) {
|
||||
let score = 0;
|
||||
const presetType = (p.filament_type ?? '').trim().toUpperCase();
|
||||
const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
|
||||
if (reqType && presetType && reqType === presetType) score += 10;
|
||||
if (reqColor && presetColor) {
|
||||
if (presetColor === reqColor) score += 5;
|
||||
else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
|
||||
}
|
||||
score += TIER_BONUS[tier];
|
||||
if (best == null || score > best.score) {
|
||||
best = { ref: { source: p.source, id: p.id }, score };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to plain priority pick if every preset scored 0+tier (i.e. no
|
||||
// metadata matched). The fallback is exactly the single-color default —
|
||||
// first preset in the highest-priority non-empty tier.
|
||||
if (best == null) return pickDefault(by, 'filament');
|
||||
return best.ref;
|
||||
}
|
||||
|
||||
function toRefValue(ref: PresetRef | null): string {
|
||||
// The HTML `<select>` value space is flat strings; encode source + id so
|
||||
// the same preset name can live in multiple tiers without collision.
|
||||
@@ -58,36 +109,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
|
||||
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
||||
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
||||
const [filamentPreset, setFilamentPreset] = useState<PresetRef | null>(null);
|
||||
// One filament ref per plate slot, in plate order. For STL / single-plate /
|
||||
// single-color sources this is a one-element array; multi-color 3MFs get one
|
||||
// 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)[]>([]);
|
||||
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
|
||||
// the user picks one (or implicitly for single-plate sources).
|
||||
const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
|
||||
|
||||
const platesQuery = useQuery({
|
||||
queryKey: ['slicePlates', source.kind, source.id],
|
||||
queryFn: async () => {
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.getLibraryFilePlates(source.id);
|
||||
}
|
||||
return api.getArchivePlates(source.id);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const isMultiPlate =
|
||||
!!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
|
||||
// Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
|
||||
// at submit time so the backend's existing default behaviour is preserved.
|
||||
const needsPlatePicker = isMultiPlate && selectedPlate == null;
|
||||
|
||||
// Per-plate filament requirements via the same endpoint the print/schedule
|
||||
// modal uses. Reusing it here keeps the SliceModal honest with whatever
|
||||
// logic that endpoint applies (slice_info parsing, future enhancements for
|
||||
// unsliced project files, dual-nozzle fields, etc.) instead of duplicating
|
||||
// extraction. plate_id is always sent: single-plate falls through to plate
|
||||
// 1 server-side; multi-plate uses the user's pick.
|
||||
const effectivePlateId = selectedPlate ?? 1;
|
||||
const filamentReqsQuery = useQuery({
|
||||
queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
|
||||
queryFn: async () => {
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId);
|
||||
}
|
||||
return api.getArchiveFilamentRequirements(source.id, effectivePlateId);
|
||||
},
|
||||
enabled: !needsPlatePicker,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// 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).
|
||||
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 presetsQuery = useQuery({
|
||||
queryKey: ['slicerPresets'],
|
||||
queryFn: () => api.getSlicerPresets(),
|
||||
staleTime: 60_000,
|
||||
// Don't fetch presets while the plate picker is on screen — saves a
|
||||
// round-trip if the user cancels out of the plate step.
|
||||
enabled: !platesQuery.isLoading && !needsPlatePicker,
|
||||
});
|
||||
|
||||
// Default selection: cloud > local > standard. Runs only on the first
|
||||
// successful load; subsequent re-renders preserve the user's manual choice.
|
||||
// Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
|
||||
// presets first arrive; subsequent re-renders preserve any manual choice.
|
||||
useEffect(() => {
|
||||
if (!presetsQuery.data) return;
|
||||
if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
|
||||
if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
|
||||
if (filamentPreset == null) setFilamentPreset(pickDefault(presetsQuery.data, 'filament'));
|
||||
// Intentionally exclude state-setters and current selections from deps —
|
||||
// we only want the auto-pick to fire once when data first arrives.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presetsQuery.data]);
|
||||
|
||||
// Filament pre-pick: re-runs whenever the active filament-slot count
|
||||
// changes (plate selection, single-plate metadata arriving). For each slot
|
||||
// we score every available filament preset against the slot's required
|
||||
// (type, colour) and keep the highest match. Slot count mismatch → reset
|
||||
// and re-pick everything; same length → preserve any user override.
|
||||
useEffect(() => {
|
||||
if (!presetsQuery.data) return;
|
||||
const data = presetsQuery.data;
|
||||
setFilamentPresets((current) => {
|
||||
if (current.length === filamentSlots.length && current.every((r) => r != null)) {
|
||||
return current;
|
||||
}
|
||||
return filamentSlots.map((slot) =>
|
||||
pickFilamentForSlot(data, { type: slot.type, color: slot.color }),
|
||||
);
|
||||
});
|
||||
}, [presetsQuery.data, filamentSlots]);
|
||||
|
||||
const enqueueMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!printerPreset || !processPreset || !filamentPreset) {
|
||||
throw new Error('All three presets must be selected');
|
||||
if (
|
||||
!printerPreset ||
|
||||
!processPreset ||
|
||||
filamentPresets.length === 0 ||
|
||||
filamentPresets.some((r) => r == null)
|
||||
) {
|
||||
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
|
||||
}
|
||||
const body = {
|
||||
printer_preset: printerPreset,
|
||||
process_preset: processPreset,
|
||||
filament_preset: filamentPreset,
|
||||
// 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);
|
||||
@@ -104,9 +241,29 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const isReady = printerPreset != null && processPreset != null && filamentPreset != null;
|
||||
const isReady =
|
||||
printerPreset != null &&
|
||||
processPreset != null &&
|
||||
filamentPresets.length > 0 &&
|
||||
filamentPresets.every((r) => r != null);
|
||||
const isEnqueuing = enqueueMutation.isPending;
|
||||
|
||||
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
|
||||
// entire flow (matches the existing PlatePickerModal contract used by the
|
||||
// archive g-code-viewer entry point).
|
||||
if (needsPlatePicker && platesQuery.data) {
|
||||
return (
|
||||
<PlatePickerModal
|
||||
plates={platesQuery.data.plates}
|
||||
onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 (or only step for single-plate / non-3MF / load-failure): preset
|
||||
// picker. While the plates query is in-flight we still render the shell
|
||||
// because the presets query is gated on it; the loader covers both.
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -127,6 +284,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
</h3>
|
||||
<p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
|
||||
{source.filename}
|
||||
{selectedPlate != null
|
||||
? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -141,7 +301,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{presetsQuery.isLoading && (
|
||||
{(platesQuery.isLoading || presetsQuery.isLoading || filamentReqsQuery.isLoading) && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('slice.loadingPresets', 'Loading presets…')}
|
||||
@@ -176,14 +336,34 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
onChange={setProcessPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
<PresetDropdown
|
||||
label={t('slice.filament', 'Filament profile')}
|
||||
slot="filament"
|
||||
data={presetsQuery.data}
|
||||
value={filamentPreset}
|
||||
onChange={setFilamentPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
{filamentSlots.map((slot, idx) => (
|
||||
<PresetDropdown
|
||||
key={`filament-${idx}`}
|
||||
label={
|
||||
filamentSlots.length > 1
|
||||
? t('slice.filamentSlot', {
|
||||
index: idx + 1,
|
||||
type: slot.type,
|
||||
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
|
||||
})
|
||||
: t('slice.filament', 'Filament profile')
|
||||
}
|
||||
slot="filament"
|
||||
data={presetsQuery.data}
|
||||
value={filamentPresets[idx] ?? null}
|
||||
onChange={(ref) =>
|
||||
setFilamentPresets((current) => {
|
||||
const next = current.length === filamentSlots.length
|
||||
? [...current]
|
||||
: filamentSlots.map((_, i) => current[i] ?? null);
|
||||
next[idx] = ref;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
disabled={isEnqueuing}
|
||||
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -271,15 +451,22 @@ interface PresetDropdownProps {
|
||||
value: PresetRef | null;
|
||||
onChange: (ref: PresetRef | null) => void;
|
||||
disabled?: boolean;
|
||||
// Optional colour swatch shown next to the label — used for multi-color
|
||||
// filament slots so the user can see at a glance which slot they're
|
||||
// configuring against the source 3MF's per-slot colour.
|
||||
swatchColor?: string;
|
||||
}
|
||||
|
||||
function PresetDropdown({ label, slot, data, value, onChange, disabled }: PresetDropdownProps) {
|
||||
function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
|
||||
// Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then
|
||||
// standard fallback. Sections with no entries collapse out so a user
|
||||
// without cloud / local presets only sees the tiers they actually have.
|
||||
const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
|
||||
{ key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
||||
{ key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
||||
{ key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
||||
{ key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
||||
];
|
||||
return tiers
|
||||
@@ -294,7 +481,16 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled }: Preset
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs text-bambu-gray mb-1">{label}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
|
||||
{swatchColor && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
|
||||
style={{ backgroundColor: swatchColor || 'transparent' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<select
|
||||
value={toRefValue(value)}
|
||||
onChange={(e) => onChange(fromRefValue(e.target.value))}
|
||||
|
||||
@@ -3250,15 +3250,28 @@ export default {
|
||||
printer: 'Drucker-Profil',
|
||||
process: 'Prozess-Profil',
|
||||
filament: 'Filament-Profil',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Profil auswählen —',
|
||||
loadingPresets: 'Profile werden geladen…',
|
||||
noPresetsForSlot: 'Keine Profile verfügbar',
|
||||
presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
|
||||
allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
|
||||
enqueuing: 'Slice-Auftrag wird übermittelt…',
|
||||
queued: 'In Warteschlange…',
|
||||
failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
|
||||
startedToast: '{{name}} wird im Hintergrund gesliced…',
|
||||
completedToast: '{{name}} wurde gesliced',
|
||||
failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
|
||||
tier: {
|
||||
local: 'Importiert',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'In Bambu Cloud anmelden (Einstellungen → Profile → Cloud), um deine Cloud-Profile zu sehen.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3253,15 +3253,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3172,15 +3172,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3171,15 +3171,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3210,15 +3210,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3185,15 +3185,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3237,15 +3237,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
@@ -3237,15 +3237,28 @@ export default {
|
||||
printer: 'Printer profile',
|
||||
process: 'Process profile',
|
||||
filament: 'Filament profile',
|
||||
filamentSlot: 'Filament {{index}} ({{type}})',
|
||||
selectPreset: '— Select a preset —',
|
||||
loadingPresets: 'Loading presets…',
|
||||
noPresetsForSlot: 'No presets available',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
enqueuing: 'Submitting slice job…',
|
||||
queued: 'Queued…',
|
||||
failed: 'Slicing failed. Check the slicer sidecar logs.',
|
||||
startedToast: 'Slicing {{name}} in the background…',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
||||
Reference in New Issue
Block a user