mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user