Browse Source

fix(slicer): correct multi-color slice output + UX polish across the slice pipeline

  The slicer CLI silently substitutes embedded defaults for any AMS slot
  the user didn't supply a profile for. When a multi-color project (e.g.
  a MakerWorld helmet with white shell + grey support filament configured
  project-wide) was sliced for a "single-color" plate, the CLI took the
  user's white pick for slot 1 and quietly filled slot 2 with the source
  3MF's embedded grey support filament — producing a slice the user never
  asked for. Same silent-fallback class as the strip-removal bug.

  Backend `/filament-requirements` now returns the FULL project AMS slot
  list (from project_settings.config) with a `used_in_plate: bool` flag
  per entry. The flag comes from the cached preview slice for unsliced
  files; sliced files (where slice_info.config already pre-filters by
  used_g > 0) get used_in_plate=true on every entry. SliceModal renders
  one dropdown per project slot — slots flagged used_in_plate=true are
  editable, slots flagged false are auto-picked from project metadata
  via the existing colour-match scoring and disabled with a
  "-- not used by this plate" suffix. The wire format always carries a
  profile per project slot, so the CLI never falls back to embedded
  defaults.

  Adjacent UX fixes in the same session, since they all hit the same
  flow:

  - Persistent slice-progress toast: SliceJobTrackerProvider now opens
    a persistent loading toast per active job ("Slicing X -- 47s") with
    a 1Hz elapsed-time tick and replaces it with the existing transient
    success/error toast on terminal state. The previous start+finish
    toast pair left a UX dead zone where users couldn't tell whether a
    long slice was still running.

  - "Analyzing plate filaments..." spinner now shows elapsed seconds and,
    after 5s, a hint that the wait is a one-time preview slice (cached;
    re-opens are instant). Addresses "is anything happening?" on first
    open of an unsliced complex multi-color 3MF.

  - Pre-slice printer-mismatch warning + disabled Slice button: the
    plates response now exposes `source_printer_model` from
    project_settings.config; SliceModal compares against the picked
    printer profile name and surfaces an inline warning + disables Slice
    on mismatch. The CLI rejects cross-printer slices (rc=-16) and used
    to fall back to embedded settings, producing wrong-printer g-code
    that errored at print dispatch.

  - Sliced-archive card now reflects the actually-used filament list,
    not the source's project-wide AMS config:
    slice_and_persist_as_archive reads filament_type / filament_color
    from the sliced output's slice_info.config (which already gates on
    used_g > 0) instead of inheriting from the source archive. A 16+
    swatch card on what was actually a 2-color print was the visible
    symptom.

  - MakerWorld URL-paste resolver enriches each instance with
    `compatibility` + `otherCompatibility` from
    design.instances[].extention.modelInfo. The /instances/hits payload
    omits this so every instance row used to look identical; users
    blindly picked the first one regardless of whether it matched their
    printer.

  Tests: 27 SliceModal tests (2 new for the disabled-row contract +
  3 for printer-mismatch + 4 for multi-color rendering); 4 new
  SliceJobTrackerContext tests for the persistent-toast lifecycle;
  backend filament-requirements / slice-preview / threemf-tools
  suites green.

  i18n: new keys slice.queuedToast, slice.runningToast,
  slice.analyzingPlateFilamentsHint, slice.notUsedByPlate,
  slice.printerMismatch, makerworld.slicedFor, makerworld.alsoCompatible
  across all 8 UI languages (English + German fully translated, the six
  others seeded with English copies pending native translation, matching
  the project's existing flow for newly-added user-facing features).
maziggy 4 weeks ago
parent
commit
3fa0b621dd

File diff suppressed because it is too large
+ 6 - 0
CHANGELOG.md


+ 22 - 31
backend/app/api/routes/archives.py

@@ -29,7 +29,6 @@ from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
-    extract_plate_extruder_set_from_3mf,
     extract_project_filaments_from_3mf,
     extract_source_printer_model_from_3mf,
 )
@@ -3237,6 +3236,7 @@ async def get_filament_requirements(
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "tray_info_idx": tray_info_idx,
+                                            "used_in_plate": True,
                                         }
                                     )
                             break
@@ -3267,41 +3267,32 @@ async def get_filament_requirements(
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "tray_info_idx": tray_info_idx,
+                                    "used_in_plate": True,
                                 }
                             )
 
-            # Unsliced project files: slice_info has nothing usable. Try a
-            # preview-slice via the sidecar — the slicer's own logic
-            # determines which filaments the plate actually consumes
-            # (Bambu Studio prunes painted regions whose extruder isn't
-            # used, etc.) — and parse the resulting slice_info. Cached so
-            # the modal opens fast on the second visit. Falls back to the
-            # painted-face heuristic if the sidecar isn't configured or
-            # the preview slice errors out.
-            if not filaments and plate_id is not None:
-                preview = await _try_preview_slice_filaments(
-                    db,
-                    kind="archive",
-                    source_id=archive_id,
-                    plate_id=plate_id,
-                    file_path=file_path,
-                )
-                if preview is not None:
-                    filaments = preview
-
-            # Last-resort fallback for unsliced files when preview slicing
-            # also can't help (no sidecar, slicer errored, etc.). See
-            # library.py for the matching block.
+            # Unsliced project files: see library.py for full rationale.
+            # Return the FULL project_settings.config slot list with a
+            # used_in_plate flag derived from the preview slice; the
+            # CLI needs every slot pre-filled to avoid silent default
+            # substitution.
             if not filaments:
                 project_filaments = extract_project_filaments_from_3mf(zf)
-                if plate_id is not None and project_filaments:
-                    used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
-                    if used_slots:
-                        filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
-                    else:
-                        filaments = project_filaments
-                elif project_filaments:
-                    filaments = project_filaments
+                used_slot_ids: set[int] = set()
+                if project_filaments and plate_id is not None:
+                    preview = await _try_preview_slice_filaments(
+                        db,
+                        kind="archive",
+                        source_id=archive_id,
+                        plate_id=plate_id,
+                        file_path=file_path,
+                    )
+                    if preview is not None:
+                        used_slot_ids = {f["slot_id"] for f in preview}
+                fallback_all_used = not used_slot_ids
+                for f in project_filaments:
+                    f["used_in_plate"] = fallback_all_used or f["slot_id"] in used_slot_ids
+                filaments = project_filaments
 
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])

+ 36 - 30
backend/app/api/routes/library.py

@@ -63,7 +63,6 @@ from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
-    extract_plate_extruder_set_from_3mf,
     extract_project_filaments_from_3mf,
     extract_source_printer_model_from_3mf,
 )
@@ -2475,6 +2474,11 @@ async def get_library_file_filament_requirements(
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "tray_info_idx": tray_info_idx,
+                                            # Sliced output already pre-filtered by used_g>0,
+                                            # so every entry that survives is in fact used by
+                                            # this plate. Print-dispatch consumers ignore the
+                                            # flag; SliceModal uses it to enable/disable rows.
+                                            "used_in_plate": True,
                                         }
                                     )
                             break
@@ -2503,40 +2507,42 @@ async def get_library_file_filament_requirements(
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "tray_info_idx": tray_info_idx,
+                                    "used_in_plate": True,
                                 }
                             )
 
-            # Unsliced project files: slice_info has nothing usable. Try a
-            # preview-slice via the sidecar — see archives.py for full
-            # rationale. Cached per (kind, id, plate, content_hash).
-            if not filaments and plate_id is not None:
-                preview = await _try_preview_slice_filaments(
-                    db,
-                    kind="library_file",
-                    source_id=file_id,
-                    plate_id=plate_id,
-                    file_path=file_path,
-                )
-                if preview is not None:
-                    filaments = preview
-
-            # Last-resort fallback when preview slicing also can't help (no
-            # sidecar configured, slicer errored, etc.). project_settings
-            # gives us the full AMS slot config; the plate-extruder filter
-            # narrows it to the slots the painted faces reference.
+            # Unsliced project files: slice_info had no per-plate data.
+            # Return the FULL project_settings.config AMS slot list so
+            # the slicer CLI receives a profile for every project slot
+            # (otherwise it silently fills the gap from embedded
+            # defaults — surfaces as "I picked white but the print has
+            # grey" because the source's grey support filament leaks
+            # into the output). Use the preview slice to mark which
+            # slots the picked plate actually consumes; the SliceModal
+            # disables the unused rows so the user only interacts with
+            # the dropdowns that matter, while the backend still has
+            # the complete list to pass to the CLI.
             if not filaments:
                 project_filaments = extract_project_filaments_from_3mf(zf)
-                if plate_id is not None and project_filaments:
-                    used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
-                    if used_slots:
-                        filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
-                    else:
-                        # No extruder metadata anywhere — return the full
-                        # project list rather than zero so the user still
-                        # gets to pick (over-rendering > under-rendering).
-                        filaments = project_filaments
-                elif project_filaments:
-                    filaments = project_filaments
+                used_slot_ids: set[int] = set()
+                if project_filaments and plate_id is not None:
+                    preview = await _try_preview_slice_filaments(
+                        db,
+                        kind="library_file",
+                        source_id=file_id,
+                        plate_id=plate_id,
+                        file_path=file_path,
+                    )
+                    if preview is not None:
+                        used_slot_ids = {f["slot_id"] for f in preview}
+                # Default to "every slot is used" when preview-slice
+                # didn't produce data: better to over-enable dropdowns
+                # than under-enable and have the user unable to pick a
+                # filament the plate actually uses.
+                fallback_all_used = not used_slot_ids
+                for f in project_filaments:
+                    f["used_in_plate"] = fallback_all_used or f["slot_id"] in used_slot_ids
+                filaments = project_filaments
 
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])

+ 137 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -819,4 +819,141 @@ describe('SliceModal', () => {
     const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
     expect(sliceButton.disabled).toBe(false);
   });
+
+  // The `used_in_plate` flag tells the modal which AMS slots are
+  // actually consumed by the picked plate. Slots flagged as unused
+  // are still rendered (the slicer CLI needs a profile per project
+  // slot, otherwise it silently fills the gap from embedded defaults
+  // and unwanted colours leak into the output) but disabled in the UI
+  // so the user only interacts with the dropdowns that matter.
+  it('disables filament dropdowns for slots not used by the picked plate', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Helmet.3mf',
+      is_multi_plate: false,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Helmet'],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 1200,
+          filament_used_grams: 80,
+          filaments: [],
+        },
+      ],
+    });
+    // Project has 2 AMS slots configured (white + grey support), but
+    // plate 1 only paints with white (slot 1). The backend now returns
+    // BOTH slots with used_in_plate flagging the difference.
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Helmet.3mf',
+      plate_id: 1,
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
+        { slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
+      ],
+    });
+    mockApi.getSlicerPresets.mockResolvedValue({
+      cloud: {
+        printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
+        process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
+        filament: [
+          { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+          { id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
+        ],
+      },
+      local: { printer: [], process: [], filament: [] },
+      standard: { printer: [], process: [], filament: [] },
+      cloud_status: 'ok',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Helmet.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+
+    // Both filament rows render — 1 printer + 1 process + 2 filament = 4.
+    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    expect(selects).toHaveLength(4);
+    // Slot 1 (used) is editable, slot 2 (not used) is disabled.
+    expect(selects[2].disabled).toBe(false);
+    expect(selects[3].disabled).toBe(true);
+    // The disabled row's label calls out why it's disabled.
+    expect(screen.getByText(/not used by this plate/i)).toBeDefined();
+  });
+
+  it('still sends both filaments to the backend even when one slot is disabled', async () => {
+    // The auto-pick scoring fills the disabled slot from project
+    // metadata — the slicer CLI requires a profile for every project
+    // slot, otherwise it silently fills the gap. The disabled UI is
+    // purely cosmetic; the wire format must include the full list.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Helmet.3mf',
+      is_multi_plate: false,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Helmet'],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 1200,
+          filament_used_grams: 80,
+          filaments: [],
+        },
+      ],
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Helmet.3mf',
+      plate_id: 1,
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
+        { slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
+      ],
+    });
+    mockApi.getSlicerPresets.mockResolvedValue({
+      cloud: {
+        printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
+        process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
+        filament: [
+          { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+          { id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
+        ],
+      },
+      local: { printer: [], process: [], filament: [] },
+      standard: { printer: [], process: [], filament: [] },
+      cloud_status: 'ok',
+    });
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 50,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/50',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Helmet.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];
+      // Both slots populated: slot 1 with the user's white pick, slot
+      // 2 auto-picked with grey from the colour-match scoring.
+      expect(body.filament_presets).toHaveLength(2);
+      expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
+      expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
+    });
+  });
 });

+ 264 - 0
frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx

@@ -0,0 +1,264 @@
+/**
+ * Tests for SliceJobTrackerProvider's persistent progress toast.
+ *
+ * The tracker shows a persistent loading toast (`slice-job-{id}`) that
+ * updates every second with elapsed time + phase label, then is replaced
+ * by a transient success/error toast on terminal state. Without the
+ * persistent toast, long slices on large models produce a "is it still
+ * running?" UX gap between the start toast and the completion toast.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { type ReactNode } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { SliceJobTrackerProvider, useSliceJobTracker } from '../../contexts/SliceJobTrackerContext';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSliceJob: vi.fn(),
+  },
+}));
+
+const mockApi = api as unknown as { getSliceJob: ReturnType<typeof vi.fn> };
+
+function Wrapper({ children }: { children: ReactNode }) {
+  // A fresh QueryClient per test so invalidateQueries calls don't leak
+  // between tests.
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  return (
+    <QueryClientProvider client={queryClient}>
+      <ToastProvider>
+        <SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
+      </ToastProvider>
+    </QueryClientProvider>
+  );
+}
+
+function TrackTrigger({ id, name }: { id: number; name: string }) {
+  const { trackJob } = useSliceJobTracker();
+  return (
+    <button onClick={() => trackJob(id, 'libraryFile', name)}>
+      track-{id}
+    </button>
+  );
+}
+
+describe('SliceJobTrackerProvider — persistent progress toast', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('renders a persistent toast immediately when a job is tracked', () => {
+    // Job stays running indefinitely — completion path is its own test.
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 1,
+      status: 'running',
+      kind: 'library_file',
+      source_id: 100,
+      source_name: 'BigCube.stl',
+      created_at: new Date().toISOString(),
+      started_at: new Date().toISOString(),
+      completed_at: null,
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={1} name="BigCube.stl" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-1').click();
+    });
+
+    // Initial frame: the "Queued" phase before the first poll lands. The
+    // toast must be on screen at t=0 without waiting for any tick.
+    expect(screen.getByText(/BigCube\.stl/)).toBeDefined();
+    expect(screen.getByText(/0s/)).toBeDefined();
+  });
+
+  it('updates elapsed time each second while the job is running', async () => {
+    let resolveFirstPoll: () => void = () => {};
+    const firstPollLanded = new Promise<void>((resolve) => {
+      resolveFirstPoll = resolve;
+    });
+
+    mockApi.getSliceJob.mockImplementation(async () => {
+      resolveFirstPoll();
+      return {
+        job_id: 2,
+        status: 'running',
+        kind: 'library_file',
+        source_id: 101,
+        source_name: 'TallTower.stl',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: null,
+      };
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={2} name="TallTower.stl" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-2').click();
+    });
+
+    // Advance fake time past the 1.5s poll so the phase flips
+    // pending→running before we check the message.
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await firstPollLanded;
+    // Let any pending promise microtasks drain on the test loop.
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    // Tick another 5 seconds — elapsed should now be ~6s total since
+    // the start, and the toast must reflect it.
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+
+    // 1500ms (poll) + 5000ms (tick) = 6500ms ≈ 6s rounded down.
+    expect(screen.getByText(/6s/)).toBeDefined();
+    expect(screen.getByText(/TallTower\.stl/)).toBeDefined();
+  });
+
+  it('replaces the persistent toast with a transient success toast on completion', async () => {
+    let pollCount = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      pollCount += 1;
+      // First poll: running. Second poll: completed.
+      if (pollCount === 1) {
+        return {
+          job_id: 3,
+          status: 'running',
+          kind: 'library_file',
+          source_id: 102,
+          source_name: 'Done.stl',
+          created_at: new Date().toISOString(),
+          started_at: new Date().toISOString(),
+          completed_at: null,
+        };
+      }
+      return {
+        job_id: 3,
+        status: 'completed',
+        kind: 'library_file',
+        source_id: 102,
+        source_name: 'Done.stl',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={3} name="Done.stl" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-3').click();
+    });
+
+    // Drive both polls (each 1.5s). After the second, completeJob should
+    // dismiss the persistent toast and show the transient success toast.
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+    });
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+
+    // The progress toast text "Slicing Done.stl —" / "Queued: Done.stl"
+    // should be gone; the success toast "Sliced Done.stl" should be up.
+    expect(screen.queryByText(/Slicing Done\.stl —/)).toBeNull();
+    expect(screen.queryByText(/Queued: Done\.stl/)).toBeNull();
+    expect(screen.getByText(/Sliced Done\.stl/)).toBeDefined();
+  });
+
+  it('replaces the persistent toast with a transient error toast on failure', async () => {
+    let pollCount = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      pollCount += 1;
+      if (pollCount === 1) {
+        return {
+          job_id: 4,
+          status: 'running',
+          kind: 'library_file',
+          source_id: 103,
+          source_name: 'Broken.stl',
+          created_at: new Date().toISOString(),
+          started_at: new Date().toISOString(),
+          completed_at: null,
+        };
+      }
+      return {
+        job_id: 4,
+        status: 'failed',
+        kind: 'library_file',
+        source_id: 103,
+        source_name: 'Broken.stl',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+        error_status: 500,
+        error_detail: 'sidecar segfault',
+      };
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={4} name="Broken.stl" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-4').click();
+    });
+
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+    });
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+
+    expect(screen.queryByText(/Slicing Broken\.stl —/)).toBeNull();
+    // The failure detail must surface in the toast — generic "failed"
+    // strings stripped of the sidecar reason were the original UX
+    // complaint that motivated `_format_sidecar_error` on the backend.
+    expect(screen.getByText(/sidecar segfault/)).toBeDefined();
+  });
+});

+ 77 - 32
frontend/src/components/SliceModal.tsx

@@ -103,6 +103,41 @@ function fromRefValue(raw: string): PresetRef | null {
   return { source, id };
 }
 
+// Inline spinner for the filament-requirements query. The backend runs a
+// preview slice on first open of an unsliced project file (cached after);
+// on a complex multi-color model that's a real slice — multi-second to
+// multi-minute. The static "Analyzing plate filaments…" string left
+// users wondering whether anything was happening, so the spinner now
+// shows elapsed seconds and a hint that explains the wait. After ~5s it
+// also surfaces a "this is a one-time slice — repeat opens are instant"
+// note so users don't worry it'll be slow forever.
+function FilamentAnalysisSpinner() {
+  const { t } = useTranslation();
+  const [elapsed, setElapsed] = useState(0);
+  useEffect(() => {
+    const startedAt = Date.now();
+    const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
+    return () => clearInterval(id);
+  }, []);
+  return (
+    <div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
+      <div className="flex items-center gap-2">
+        <Loader2 className="w-4 h-4 animate-spin" />
+        {t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…')}
+        <span className="text-xs tabular-nums">{elapsed}s</span>
+      </div>
+      {elapsed >= 5 && (
+        <div className="text-xs text-bambu-gray/70 pl-6">
+          {t(
+            'slice.analyzingPlateFilamentsHint',
+            'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -371,39 +406,49 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   scoped spinner so the user sees the printer/process
                   dropdowns instead of an opaque "Loading presets…" wait. */}
               {filamentReqsQuery.isLoading ? (
-                <div className="flex items-center gap-2 text-bambu-gray text-sm py-2">
-                  <Loader2 className="w-4 h-4 animate-spin" />
-                  {t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…')}
-                </div>
+                <FilamentAnalysisSpinner />
               ) : (
-                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}
-                  />
-                ))
+                filamentSlots.map((slot, idx) => {
+                  // Slots flagged by the backend as not used by the
+                  // picked plate are auto-picked from project metadata
+                  // and disabled — the slicer CLI still needs a
+                  // profile per project slot, but the user shouldn't
+                  // have to think about slots their plate doesn't
+                  // paint with. used_in_plate defaults to true when
+                  // missing (sliced 3MFs and the no-flag legacy path).
+                  const isUsed = slot.used_in_plate !== false;
+                  const baseLabel =
+                    filamentSlots.length > 1
+                      ? t('slice.filamentSlot', {
+                          index: idx + 1,
+                          type: slot.type,
+                          defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
+                        })
+                      : t('slice.filament', 'Filament profile');
+                  const label = isUsed
+                    ? baseLabel
+                    : `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
+                  return (
+                    <PresetDropdown
+                      key={`filament-${idx}`}
+                      label={label}
+                      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 || !isUsed}
+                      swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                    />
+                  );
+                })
               )}
             </>
           )}

+ 75 - 6
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -6,11 +6,16 @@
  * shows toasts on terminal state. Lives at app level so polling continues
  * across navigation — slice can run in the background while the user does
  * other things.
+ *
+ * Each tracked job also gets a persistent toast (`slice-job-{id}`) with a
+ * spinner + elapsed-time counter that updates every second so the user has
+ * a continuous visual indicator while a long slice is running. The toast
+ * is replaced by a transient success/error toast on terminal state.
  */
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQueryClient } from '@tanstack/react-query';
-import { api, type SliceJobState } from '../api/client';
+import { api, type SliceJobState, type SliceJobStatus } from '../api/client';
 import { useToast } from './ToastContext';
 
 interface TrackedJob {
@@ -27,10 +32,24 @@ interface SliceJobTrackerContextValue {
 const SliceJobTrackerContext = createContext<SliceJobTrackerContextValue | null>(null);
 
 const POLL_INTERVAL_MS = 1500;
+const TICK_INTERVAL_MS = 1000;
+
+const toastIdFor = (jobId: number) => `slice-job-${jobId}`;
+
+function formatElapsed(seconds: number): string {
+  const s = Math.max(0, Math.floor(seconds));
+  if (s < 60) return `${s}s`;
+  const m = Math.floor(s / 60);
+  const remS = s % 60;
+  if (m < 60) return `${m}m ${remS}s`;
+  const h = Math.floor(m / 60);
+  const remM = m % 60;
+  return `${h}h ${remM}m`;
+}
 
 export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const { t } = useTranslation();
-  const { showToast } = useToast();
+  const { showToast, showPersistentToast, dismissToast } = useToast();
   const queryClient = useQueryClient();
   const [activeJobs, setActiveJobs] = useState<TrackedJob[]>([]);
 
@@ -39,17 +58,52 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const activeJobsRef = useRef<TrackedJob[]>([]);
   activeJobsRef.current = activeJobs;
 
+  // Per-job start time + latest phase, kept in refs so the 1s tick
+  // doesn't need to re-render on every update. Keyed by job id.
+  const startedAtRef = useRef<Map<number, number>>(new Map());
+  const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
+
+  const renderProgressToast = useCallback(
+    (job: TrackedJob) => {
+      const startedAt = startedAtRef.current.get(job.id);
+      if (startedAt == null) return;
+      const elapsedSecs = (Date.now() - startedAt) / 1000;
+      const phase = phaseRef.current.get(job.id) ?? 'pending';
+      const messageKey = phase === 'pending' ? 'slice.queuedToast' : 'slice.runningToast';
+      const fallback =
+        phase === 'pending'
+          ? 'Queued: {{name}} — {{elapsed}}'
+          : 'Slicing {{name}} — {{elapsed}}';
+      showPersistentToast(
+        toastIdFor(job.id),
+        t(messageKey, fallback, { name: job.sourceName, elapsed: formatElapsed(elapsedSecs) }),
+        'loading',
+      );
+    },
+    [showPersistentToast, t],
+  );
+
   const trackJob = useCallback(
     (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
-      showToast(t('slice.startedToast', 'Slicing {{name}} in the background…', { name: sourceName }), 'info');
+      startedAtRef.current.set(id, Date.now());
+      phaseRef.current.set(id, 'pending');
+      // Render the initial frame immediately so the user sees the toast
+      // before the first tick lands (~1s delay otherwise).
+      renderProgressToast({ id, kind, sourceName });
     },
-    [showToast, t],
+    [renderProgressToast],
   );
 
   const completeJob = useCallback(
     (job: TrackedJob, state: SliceJobState) => {
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
+      startedAtRef.current.delete(job.id);
+      phaseRef.current.delete(job.id);
+
+      // Replace the persistent progress toast with a transient
+      // success/error toast (auto-dismisses after 3s, same as showToast).
+      dismissToast(toastIdFor(job.id));
 
       if (state.status === 'completed') {
         // `used_embedded_settings` still comes back on the result for tests
@@ -70,19 +124,21 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
     },
-    [queryClient, showToast, t],
+    [dismissToast, queryClient, showToast, t],
   );
 
+  // Status polling. Updates phase on each successful poll and triggers
+  // completeJob on terminal states.
   useEffect(() => {
     if (activeJobs.length === 0) return;
     let cancelled = false;
     const interval = setInterval(async () => {
       if (cancelled) return;
-      // Snapshot the current list so concurrent updates don't surprise us.
       const snapshot = [...activeJobsRef.current];
       for (const job of snapshot) {
         try {
           const state = await api.getSliceJob(job.id);
+          phaseRef.current.set(job.id, state.status);
           if (state.status === 'completed' || state.status === 'failed') {
             completeJob(job, state);
           }
@@ -97,6 +153,19 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
     };
   }, [activeJobs.length, completeJob]);
 
+  // 1Hz tick that re-renders each persistent progress toast with the
+  // current elapsed time. Independent of the status poll so the counter
+  // stays smooth even while the backend is slow to respond.
+  useEffect(() => {
+    if (activeJobs.length === 0) return;
+    const tick = setInterval(() => {
+      for (const job of activeJobsRef.current) {
+        renderProgressToast(job);
+      }
+    }, TICK_INTERVAL_MS);
+    return () => clearInterval(tick);
+  }, [activeJobs.length, renderProgressToast]);
+
   return (
     <SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
       {children}

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -3254,6 +3254,8 @@ export default {
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
     analyzingPlateFilaments: 'Plattenfilamente werden analysiert…',
+    analyzingPlateFilamentsHint: 'Es wird ein Probeschnitt ausgeführt, um die belegten AMS-Slots dieser Platte zu ermitteln. Wird zwischengespeichert — erneutes Öffnen ist sofort.',
+    notUsedByPlate: '— wird von dieser Platte nicht verwendet',
     printerMismatch: 'Dieses 3MF wurde für {{source}} gesliced, du hast aber {{target}} ausgewählt. Der Slicer-CLI kann ein 3MF nicht für einen anderen Drucker neu slicen — öffne die Quelle in Bambu Studio, ändere den Drucker und exportiere neu.',
     noPresetsForSlot: 'Keine Profile verfügbar',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
@@ -3262,6 +3264,8 @@ export default {
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     startedToast: '{{name}} wird im Hintergrund gesliced…',
+    queuedToast: 'Warteschlange: {{name}} — {{elapsed}}',
+    runningToast: '{{name}} wird gesliced — {{elapsed}}',
     completedToast: '{{name}} wurde gesliced',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -3257,6 +3257,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3265,6 +3267,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -3176,6 +3176,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3184,6 +3186,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -3175,6 +3175,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3183,6 +3185,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -3214,6 +3214,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3222,6 +3224,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3189,6 +3189,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3197,6 +3199,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3241,6 +3241,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3249,6 +3251,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3241,6 +3241,8 @@ export default {
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
+    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
@@ -3249,6 +3251,8 @@ export default {
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
+    queuedToast: 'Queued: {{name}} — {{elapsed}}',
+    runningToast: 'Slicing {{name}} — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 9 - 0
frontend/src/types/plates.ts

@@ -4,6 +4,15 @@ export interface PlateFilament {
   color: string;
   used_grams: number;
   used_meters: number;
+  // True when this AMS slot is consumed by the picked plate. False
+  // means the slot is configured project-wide but the picked plate
+  // doesn't paint with it. Sliced 3MFs (.gcode.3mf) report only used
+  // filaments — the field is true for every entry. Unsliced project
+  // files report ALL project slots; SliceModal disables the unused
+  // rows so the user only interacts with the dropdowns that matter,
+  // while the backend still passes the complete list to the slicer
+  // CLI to prevent silent fallback to embedded defaults.
+  used_in_plate?: boolean;
 }
 
 export interface PlateMetadata {

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B6Qs-684.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BZTbdNTm.js"></script>
+    <script type="module" crossorigin src="/assets/index-B6Qs-684.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Bbpbjxtl.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff