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
+77 -32
View File
@@ -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}
/>
);
})
)}
</>
)}