mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)
The initial slicer integration only saw DB-backed local imports — users
without imported profiles got an empty Slice modal even when their
Bambu Cloud account or the slicer sidecar carried perfectly usable
presets. The Slice modal now pulls from three tiers in priority order:
- cloud: user's own Bambu Cloud presets, fetched live.
- local: DB-backed imports.
- standard: slicer-bundled stock profiles via the sidecar's new
GET /profiles/bundled endpoint.
Listing endpoint: GET /api/v1/slicer/presets
- Name-based dedup, cloud > local > standard, within-tier order
preserved exactly. A preset that exists in multiple tiers only
renders in the highest-priority one.
- cloud_status (ok / not_authenticated / expired / unreachable)
drives a precise modal banner instead of an unexplained empty
list.
- Cloud branch: per-user cache, 5 min TTL, key
(user_id, sha256(token)[:16]) so logout/login or token rotation
auto-invalidates without callback wiring from the cloud-auth
routes.
- Bundled branch: global cache, 1 h TTL.
- Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
so BambuStudio installs see the bambu sidecar's bundled list, not
OrcaSlicer's.
Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice
- Body now accepts source-aware {source, id} triplets per slot:
printer_preset: PresetRef
process_preset: PresetRef
filament_preset: PresetRef
- Legacy *_preset_id integer fields kept for backwards-compat. The
schema validator normalises bare ints into
PresetRef(source='local', id=str(int)) so the route handler only
deals with one shape.
New preset_resolver service fetches the JSON content per source:
- cloud: BambuCloudService.get_setting_detail(id), unwraps the
`setting` envelope (falls back to top-level for minor
shape variants).
- local: DB read with preset_type slot validation (existing path,
factored into the new helper).
- standard: minimal {name, inherits, from: "system"} stub — the
sidecar's profile-resolver flattens it against
BUNDLED_PROFILES_PATH/<category>/<name>.json with no
preset-content round-trip from Bambuddy.
PERMISSIONS
- Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
user who can slice can populate the dropdowns).
- Cloud branch in BOTH the listing helper and the resolver checks
CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
to slice with a cloud preset) even if a leftover User.cloud_token
survived a permission revocation. Cloud listing path
short-circuits the token lookup entirely on the gate-fail branch.
FRONTEND — SliceModal
- Calls api.getSlicerPresets() instead of api.getLocalPresets().
- Dropdowns render <optgroup> per tier with localised section
labels (Cloud / Imported / Standard).
- Default selection follows cloud > local > standard priority on
first load (auto-pick fires once when the data arrives, manual
choices stick after that).
- Cloud-status banner renders three variants
(sign-in / expired / unreachable) only when status != 'ok'.
- Slice button submits source-aware refs; legacy integer payload
is preserved server-side for older clients.
3MF PROFILE-PATH HARDENING (shipped together because they touch the
same code paths)
(1) Strip widened. _strip_3mf_embedded_settings only removed
Metadata/project_settings.config. Real-world Bambu Studio /
OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
and cut_information.xml — any single leftover trips the CLI's
input validation and the slice falls back to embedded settings,
making the SliceModal's profile picker theatrical for 3MF inputs.
Now removes all four configs via a centralised
_STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
geometry (3D/3dmodel.model), thumbnails, multi-part data
preserved.
(2) Sidecar 5xx error capture. slicer_api.py was reading only
`message` from sidecar 5xx responses and dropping `details`, so
every CLI failure surfaced as the unhelpful generic
"Failed to slice the model". New _format_sidecar_error helper
combines both fields, falls back to plain-text body for
non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
duplicated extraction blocks. Pairs with the orca-slicer-api
fork's bambuddy/profile-resolver branch which now emits
`details` on AppError responses (d9c6121) and captures CLI
stderr in the failure path (fb928c8).
CARE TAKEN — additive on existing surfaces
- main.py: +1 import, +1 router register
- slicer_api.py: +list_bundled_profiles, +_format_sidecar_error
(dedupes the 4 message-extraction blocks);
no existing method behaviour changed
- library.py: resolver swap inside _run_slicer_with_fallback,
user_id threaded through two callers,
strip widened
- schemas/slicer.py: PresetRef added, *_preset fields added,
legacy *_preset_id kept; validator normalises
- 4 new files: schema, route, resolver, tests
- No existing route URL changed, no existing field removed, no
behaviour change for clients still sending bare integer ids.
TESTS
- 17 unit tests for the listing endpoint helpers
- 11 unit tests for the source-aware resolver
- 6 schema tests for SliceRequest legacy + new shapes
- 3 unit tests for the new sidecar error-detail capture
- Strip integration test extended to assert all 4 configs go and
geometry stays
- 12 frontend tests for SliceModal covering tier-priority
auto-selection, <optgroup> grouping, fallback paths, source-aware
payload on submit, manual override across tiers, archive vs
library routing, error display, all three banner variants
Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
frontend production build clean.
Pairs with three already-pushed commits on the orca-slicer-api fork's
bambuddy/profile-resolver branch:
- 5fd6bc6 feat(profiles): add GET /profiles/bundled
- d9c6121 fix(error): include causeMessage in JSON response as `details`
- fb928c8 fix(slicing): include CLI stdout/stderr in failure causeMessage
This commit is contained in:
@@ -1,23 +1,24 @@
|
||||
/**
|
||||
* Tests for SliceModal.
|
||||
*
|
||||
* The modal handles preset selection + enqueueing a slice job. After
|
||||
* enqueue success it hands the job_id off to SliceJobTrackerProvider
|
||||
* (which lives at app level) and calls onClose. Polling, toasts, and
|
||||
* query invalidation all happen in the tracker — not here.
|
||||
* The modal handles preset selection across three tiers (cloud / local /
|
||||
* standard) + enqueueing a slice job. After enqueue success it hands the
|
||||
* job_id off to SliceJobTrackerProvider (which lives at app level) and
|
||||
* calls onClose. Polling, toasts, and query invalidation all happen in
|
||||
* the tracker — not here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { SliceModal } from '../../components/SliceModal';
|
||||
import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
|
||||
import { api } from '../../api/client';
|
||||
import { api, type UnifiedPresetsResponse } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getLocalPresets: vi.fn(),
|
||||
getSlicerPresets: vi.fn(),
|
||||
sliceLibraryFile: vi.fn(),
|
||||
sliceArchive: vi.fn(),
|
||||
getSliceJob: vi.fn(),
|
||||
@@ -27,17 +28,39 @@ vi.mock('../../api/client', () => ({
|
||||
}));
|
||||
|
||||
const mockApi = api as unknown as {
|
||||
getLocalPresets: ReturnType<typeof vi.fn>;
|
||||
getSlicerPresets: ReturnType<typeof vi.fn>;
|
||||
sliceLibraryFile: ReturnType<typeof vi.fn>;
|
||||
sliceArchive: ReturnType<typeof vi.fn>;
|
||||
getSliceJob: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const samplePresets = {
|
||||
printer: [{ id: 1, name: 'X1C 0.4', preset_type: 'printer' }],
|
||||
process: [{ id: 2, name: '0.20mm Standard', preset_type: 'process' }],
|
||||
filament: [{ id: 3, name: 'Bambu PLA Basic', preset_type: 'filament' }],
|
||||
};
|
||||
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
||||
return {
|
||||
cloud: { printer: [], process: [], filament: [] },
|
||||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fullThreeTier: UnifiedPresetsResponse = makeUnified({
|
||||
cloud: {
|
||||
printer: [{ id: 'PFUcloud-printer', name: 'My Custom X1C', source: 'cloud' }],
|
||||
process: [{ id: 'PFUcloud-process', name: 'My 0.16mm Tweaked', source: 'cloud' }],
|
||||
filament: [{ id: 'PFUcloud-filament', name: 'My PLA Black', source: 'cloud' }],
|
||||
},
|
||||
local: {
|
||||
printer: [{ id: '1', name: 'Imported X1C 0.4', source: 'local' }],
|
||||
process: [{ id: '2', name: 'Imported 0.20mm', source: 'local' }],
|
||||
filament: [{ id: '3', name: 'Imported PLA Basic', source: 'local' }],
|
||||
},
|
||||
standard: {
|
||||
printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
|
||||
process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
|
||||
filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
|
||||
},
|
||||
});
|
||||
|
||||
function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
|
||||
return render(
|
||||
@@ -50,9 +73,7 @@ function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
|
||||
describe('SliceModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.getLocalPresets.mockResolvedValue(samplePresets);
|
||||
// Tracker polls — return a still-running job so the test doesn't
|
||||
// race against terminal-state side effects (toasts, invalidation).
|
||||
mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
|
||||
mockApi.getSliceJob.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'running',
|
||||
@@ -65,28 +86,86 @@ describe('SliceModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Slice button until all three presets are picked', async () => {
|
||||
it('auto-selects the highest-priority tier per slot on first load', async () => {
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
|
||||
|
||||
const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
|
||||
expect((sliceBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// The cloud tier wins — printer dropdown should land on the cloud entry.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Custom X1C')).toBeDefined();
|
||||
});
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects).toHaveLength(3);
|
||||
await user.selectOptions(selects[0], '1');
|
||||
await user.selectOptions(selects[1], '2');
|
||||
expect((sliceBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
await user.selectOptions(selects[2], '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');
|
||||
|
||||
// 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('enqueues a library-file slice job and closes the modal on success', async () => {
|
||||
it('renders Cloud / Imported / 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());
|
||||
|
||||
const printerSelect = screen.getAllByRole('combobox')[0];
|
||||
const groups = printerSelect.querySelectorAll('optgroup');
|
||||
expect(Array.from(groups).map((g) => g.label)).toEqual([
|
||||
'Cloud',
|
||||
'Imported',
|
||||
'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];
|
||||
expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined();
|
||||
const standardGroup = groups[2];
|
||||
expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
|
||||
});
|
||||
|
||||
it('falls back to local when cloud is empty (auto-pick respects priority)', async () => {
|
||||
mockApi.getSlicerPresets.mockResolvedValue(
|
||||
makeUnified({
|
||||
local: fullThreeTier.local,
|
||||
standard: fullThreeTier.standard,
|
||||
}),
|
||||
);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects[0].value).toBe('local:1');
|
||||
});
|
||||
|
||||
it('falls back to standard when both cloud and local are empty', async () => {
|
||||
mockApi.getSlicerPresets.mockResolvedValue(
|
||||
makeUnified({ standard: fullThreeTier.standard }),
|
||||
);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
||||
});
|
||||
|
||||
it('sends source-aware refs (not legacy bare ints) on submit', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
@@ -99,25 +178,51 @@ describe('SliceModal', () => {
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
await user.selectOptions(selects[0], '1');
|
||||
await user.selectOptions(selects[1], '2');
|
||||
await user.selectOptions(selects[2], '3');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
|
||||
printer_preset_id: 1,
|
||||
process_preset_id: 2,
|
||||
filament_preset_id: 3,
|
||||
printer_preset: { source: 'cloud', id: 'PFUcloud-printer' },
|
||||
process_preset: { source: 'cloud', id: 'PFUcloud-process' },
|
||||
filament_preset: { source: 'cloud', id: 'PFUcloud-filament' },
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('lets the user override the default and pick a Standard preset', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.objectContaining({
|
||||
printer_preset: { source: 'standard', id: 'Bambu Lab X1 Carbon 0.4 nozzle' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes archive sources to sliceArchive instead of sliceLibraryFile', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceArchive.mockResolvedValue({
|
||||
@@ -131,13 +236,9 @@ describe('SliceModal', () => {
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
await user.selectOptions(selects[0], '1');
|
||||
await user.selectOptions(selects[1], '2');
|
||||
await user.selectOptions(selects[2], '3');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -155,13 +256,9 @@ describe('SliceModal', () => {
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined());
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
await user.selectOptions(selects[0], '1');
|
||||
await user.selectOptions(selects[1], '2');
|
||||
await user.selectOptions(selects[2], '3');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -170,8 +267,8 @@ describe('SliceModal', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a friendly notice when getLocalPresets fails', async () => {
|
||||
mockApi.getLocalPresets.mockRejectedValue(new Error('500'));
|
||||
it('shows a friendly notice when getSlicerPresets fails', async () => {
|
||||
mockApi.getSlicerPresets.mockRejectedValue(new Error('500'));
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
@@ -182,4 +279,49 @@ describe('SliceModal', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Failed to load presets/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a "sign in" banner when cloud_status is not_authenticated', async () => {
|
||||
mockApi.getSlicerPresets.mockResolvedValue(
|
||||
makeUnified({
|
||||
cloud_status: 'not_authenticated',
|
||||
local: fullThreeTier.local,
|
||||
standard: fullThreeTier.standard,
|
||||
}),
|
||||
);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Sign in to Bambu Cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an "expired" banner when cloud_status is expired', async () => {
|
||||
mockApi.getSlicerPresets.mockResolvedValue(
|
||||
makeUnified({
|
||||
cloud_status: 'expired',
|
||||
local: fullThreeTier.local,
|
||||
}),
|
||||
);
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/expired/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the banner entirely when cloud_status is ok', async () => {
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
// No status-role banner should be rendered on the happy path.
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1111,14 +1111,47 @@ export interface BuiltinFilament {
|
||||
}
|
||||
|
||||
// Slice request/response — POST /library/files/{id}/slice and /archives/{id}/slice
|
||||
//
|
||||
// Two preset shapes are accepted per slot:
|
||||
// - Legacy bare integer ids (`*_preset_id`) — pre-cloud-tier clients.
|
||||
// - Source-aware refs (`*_preset: PresetRef`) — new SliceModal that picks
|
||||
// across cloud / local / standard tiers. Source-aware refs win when both
|
||||
// are present in the same payload.
|
||||
export type PresetSource = 'cloud' | 'local' | 'standard';
|
||||
export interface PresetRef {
|
||||
source: PresetSource;
|
||||
id: string;
|
||||
}
|
||||
export interface SliceRequest {
|
||||
printer_preset_id: number;
|
||||
process_preset_id: number;
|
||||
filament_preset_id: number;
|
||||
printer_preset_id?: number;
|
||||
process_preset_id?: number;
|
||||
filament_preset_id?: number;
|
||||
printer_preset?: PresetRef;
|
||||
process_preset?: PresetRef;
|
||||
filament_preset?: PresetRef;
|
||||
plate?: number;
|
||||
export_3mf?: boolean;
|
||||
}
|
||||
|
||||
// GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
|
||||
export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
|
||||
export interface UnifiedPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
source: PresetSource;
|
||||
}
|
||||
export interface UnifiedPresetsBySlot {
|
||||
printer: UnifiedPreset[];
|
||||
process: UnifiedPreset[];
|
||||
filament: UnifiedPreset[];
|
||||
}
|
||||
export interface UnifiedPresetsResponse {
|
||||
cloud: UnifiedPresetsBySlot;
|
||||
local: UnifiedPresetsBySlot;
|
||||
standard: UnifiedPresetsBySlot;
|
||||
cloud_status: SlicerCloudStatus;
|
||||
}
|
||||
|
||||
export interface SliceResponse {
|
||||
library_file_id: number;
|
||||
name: string;
|
||||
@@ -4976,6 +5009,12 @@ export const api = {
|
||||
getSliceJob: (jobId: number) =>
|
||||
request<SliceJobState>(`/slice-jobs/${jobId}`),
|
||||
|
||||
// Unified slicer-preset listing — cloud + local + standard, deduped by name.
|
||||
// Used by the SliceModal; see UnifiedPresetsResponse for the shape and
|
||||
// backend/app/api/routes/slicer_presets.py for the priority rules.
|
||||
getSlicerPresets: () =>
|
||||
request<UnifiedPresetsResponse>('/slicer/presets'),
|
||||
|
||||
// Local Presets (OrcaSlicer imports)
|
||||
getLocalPresets: () =>
|
||||
request<LocalPresetsResponse>('/local-presets/'),
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Cog, Loader2, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Cloud, CloudOff, Cog, Loader2, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, type LocalPreset } from '../api/client';
|
||||
import {
|
||||
api,
|
||||
type PresetRef,
|
||||
type PresetSource,
|
||||
type SlicerCloudStatus,
|
||||
type UnifiedPreset,
|
||||
type UnifiedPresetsBySlot,
|
||||
type UnifiedPresetsResponse,
|
||||
} from '../api/client';
|
||||
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
||||
|
||||
export type SliceSource =
|
||||
@@ -14,30 +22,72 @@ interface SliceModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Slot = 'printer' | 'process' | 'filament';
|
||||
|
||||
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) {
|
||||
const list = by[tier][slot];
|
||||
if (list.length > 0) {
|
||||
return { source: list[0].source, id: list[0].id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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.
|
||||
return ref ? `${ref.source}:${ref.id}` : '';
|
||||
}
|
||||
|
||||
function fromRefValue(raw: string): PresetRef | null {
|
||||
if (!raw) return null;
|
||||
const idx = raw.indexOf(':');
|
||||
if (idx < 0) return null;
|
||||
const source = raw.slice(0, idx) as PresetSource;
|
||||
const id = raw.slice(idx + 1);
|
||||
if (source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
|
||||
return { source, id };
|
||||
}
|
||||
|
||||
export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackJob } = useSliceJobTracker();
|
||||
|
||||
const [printerPresetId, setPrinterPresetId] = useState<number | null>(null);
|
||||
const [processPresetId, setProcessPresetId] = useState<number | null>(null);
|
||||
const [filamentPresetId, setFilamentPresetId] = useState<number | null>(null);
|
||||
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
||||
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
||||
const [filamentPreset, setFilamentPreset] = useState<PresetRef | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const presetsQuery = useQuery({
|
||||
queryKey: ['localPresets'],
|
||||
queryFn: () => api.getLocalPresets(),
|
||||
queryKey: ['slicerPresets'],
|
||||
queryFn: () => api.getSlicerPresets(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Default selection: cloud > local > standard. Runs only on the first
|
||||
// successful load; subsequent re-renders preserve the user's 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]);
|
||||
|
||||
const enqueueMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (printerPresetId == null || processPresetId == null || filamentPresetId == null) {
|
||||
if (!printerPreset || !processPreset || !filamentPreset) {
|
||||
throw new Error('All three presets must be selected');
|
||||
}
|
||||
const body = {
|
||||
printer_preset_id: printerPresetId,
|
||||
process_preset_id: processPresetId,
|
||||
filament_preset_id: filamentPresetId,
|
||||
printer_preset: printerPreset,
|
||||
process_preset: processPreset,
|
||||
filament_preset: filamentPreset,
|
||||
};
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.sliceLibraryFile(source.id, body);
|
||||
@@ -45,8 +95,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
return api.sliceArchive(source.id, body);
|
||||
},
|
||||
onSuccess: (enqueue) => {
|
||||
// Hand the job off to the global tracker — polling, toasts, and
|
||||
// query invalidation continue across navigation.
|
||||
trackJob(enqueue.job_id, source.kind, source.filename);
|
||||
onClose();
|
||||
},
|
||||
@@ -56,7 +104,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const isReady = printerPresetId != null && processPresetId != null && filamentPresetId != null;
|
||||
const isReady = printerPreset != null && processPreset != null && filamentPreset != null;
|
||||
const isEnqueuing = enqueueMutation.isPending;
|
||||
|
||||
return (
|
||||
@@ -104,32 +152,36 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
<div className="text-sm text-red-400" role="alert">
|
||||
{t(
|
||||
'slice.presetsLoadFailed',
|
||||
'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presetsQuery.data && (
|
||||
<>
|
||||
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
|
||||
<PresetDropdown
|
||||
label={t('slice.printer', 'Printer profile')}
|
||||
presets={presetsQuery.data.printer}
|
||||
value={printerPresetId}
|
||||
onChange={setPrinterPresetId}
|
||||
slot="printer"
|
||||
data={presetsQuery.data}
|
||||
value={printerPreset}
|
||||
onChange={setPrinterPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
<PresetDropdown
|
||||
label={t('slice.process', 'Process profile')}
|
||||
presets={presetsQuery.data.process}
|
||||
value={processPresetId}
|
||||
onChange={setProcessPresetId}
|
||||
slot="process"
|
||||
data={presetsQuery.data}
|
||||
value={processPreset}
|
||||
onChange={setProcessPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
<PresetDropdown
|
||||
label={t('slice.filament', 'Filament profile')}
|
||||
presets={presetsQuery.data.filament}
|
||||
value={filamentPresetId}
|
||||
onChange={setFilamentPresetId}
|
||||
slot="filament"
|
||||
data={presetsQuery.data}
|
||||
value={filamentPreset}
|
||||
onChange={setFilamentPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
</>
|
||||
@@ -176,30 +228,92 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'ok') return null;
|
||||
|
||||
// Map each non-ok status to the appropriate icon + tone. None of these are
|
||||
// hard errors — the user can still slice using local + standard presets,
|
||||
// so we use info / warn styling rather than error red.
|
||||
const config: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud; key: string; fallback: string }> = {
|
||||
not_authenticated: {
|
||||
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
||||
icon: Cloud,
|
||||
key: 'slice.cloud.notAuthenticated',
|
||||
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
},
|
||||
expired: {
|
||||
tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
|
||||
icon: CloudOff,
|
||||
key: 'slice.cloud.expired',
|
||||
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
},
|
||||
unreachable: {
|
||||
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
||||
icon: CloudOff,
|
||||
key: 'slice.cloud.unreachable',
|
||||
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
};
|
||||
const { tone, icon: Icon, key, fallback } = config[status];
|
||||
return (
|
||||
<div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{t(key, fallback)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetDropdownProps {
|
||||
label: string;
|
||||
presets: LocalPreset[];
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
slot: Slot;
|
||||
data: UnifiedPresetsResponse;
|
||||
value: PresetRef | null;
|
||||
onChange: (ref: PresetRef | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function PresetDropdown({ label, presets, value, onChange, disabled }: PresetDropdownProps) {
|
||||
function PresetDropdown({ label, slot, data, value, onChange, disabled }: PresetDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
|
||||
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: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
||||
];
|
||||
return tiers
|
||||
.map(({ key, label: lk, fallback }) => ({
|
||||
tierLabel: t(lk, fallback),
|
||||
entries: (data[key] as UnifiedPresetsBySlot)[slot],
|
||||
}))
|
||||
.filter((s) => s.entries.length > 0);
|
||||
}, [data, slot, t]);
|
||||
|
||||
const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0);
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs text-bambu-gray mb-1">{label}</span>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
disabled={disabled}
|
||||
value={toRefValue(value)}
|
||||
onChange={(e) => onChange(fromRefValue(e.target.value))}
|
||||
disabled={disabled || totalEntries === 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="">{t('slice.selectPreset', '— Select a preset —')}</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
<option value="">
|
||||
{totalEntries === 0
|
||||
? t('slice.noPresetsForSlot', 'No presets available')
|
||||
: t('slice.selectPreset', '— Select a preset —')}
|
||||
</option>
|
||||
{sections.map((section) => (
|
||||
<optgroup key={section.tierLabel} label={section.tierLabel}>
|
||||
{section.entries.map((p) => (
|
||||
<option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user