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:
maziggy
2026-04-28 12:20:59 +02:00
parent 69b6b5a334
commit 988c00554e
26 changed files with 2488 additions and 526 deletions
@@ -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' });
});
});
});
+12
View File
@@ -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[];
+221 -25
View File
@@ -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))}
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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