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).
This commit is contained in:
maziggy
2026-04-28 14:50:28 +02:00
parent c1f69ee0cc
commit 3fa0b621dd
18 changed files with 1058 additions and 499 deletions
@@ -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}