| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070 |
- import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { useMutation, useQuery } from '@tanstack/react-query';
- import {
- api,
- type PresetRef,
- type PresetSource,
- type SliceBundleSpec,
- type SliceJobProgress,
- type SliceRequest,
- type SlicerBundle,
- type SlicerCloudStatus,
- type UnifiedPreset,
- type UnifiedPresetsBySlot,
- type UnifiedPresetsResponse,
- } from '../api/client';
- import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
- import { useToast } from '../contexts/ToastContext';
- 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 }
- | { kind: 'archive'; id: number; filename: string };
- interface SliceModalProps {
- source: SliceSource;
- onClose: () => void;
- }
- 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 {
- 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 };
- }
- }
- 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.
- return ref ? `${ref.source}:${ref.id}` : '';
- }
- function fromRefValue(raw: string): PresetRef | null {
- if (!raw) return null;
- const idx = raw.indexOf(':');
- if (idx < 0) return null;
- const source = raw.slice(0, idx) as PresetSource;
- const id = raw.slice(idx + 1);
- if (source !== 'cloud' && source !== 'local' && source !== 'standard') return 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, polls the sidecar's --pipe progress (via the
- // /slicer/preview-progress proxy) for live stage + percent, and after ~5s
- // surfaces a "this is a one-time slice — repeat opens are instant"
- // note so users don't worry it'll be slow forever.
- //
- // requestId: a UUID generated by the modal when the filament-requirements
- // fetch starts. Forwarded to the sidecar via the API call AND used here
- // to poll the matching progress snapshot. Same id, two consumers.
- function FilamentAnalysisSpinner({
- requestId,
- sourceName,
- }: {
- requestId: string;
- sourceName: string;
- }) {
- const { t } = useTranslation();
- const { showPersistentToast, dismissToast } = useToast();
- const [elapsed, setElapsed] = useState(0);
- const [progress, setProgress] = useState<SliceJobProgress | null>(null);
- // Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
- let prettyName = sourceName;
- try {
- prettyName = decodeURIComponent(sourceName);
- } catch {
- /* keep raw on malformed encoding */
- }
- // Elapsed-time tick.
- useEffect(() => {
- const startedAt = Date.now();
- const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
- return () => clearInterval(id);
- }, []);
- // Progress polling — once per second while the spinner is mounted.
- // Mirrors the slice-job tracker's cadence. Sidecar 404s during the
- // race window between fetch start and progressStore.start() are
- // swallowed by the API method (returns null) so we keep polling.
- useEffect(() => {
- let cancelled = false;
- const id = setInterval(async () => {
- if (cancelled) return;
- const snap = await api.getPreviewSliceProgress(requestId);
- if (!cancelled && snap) setProgress(snap);
- }, 1000);
- return () => {
- cancelled = true;
- clearInterval(id);
- };
- }, [requestId]);
- // Mirror the spinner's contents into a persistent toast so the user
- // sees activity even when their cursor is elsewhere on the page.
- // Dismissed in the parent's effect when the requirements arrive.
- const toastId = `slice-preview-${requestId}`;
- useEffect(() => {
- const hasUseful = progress && progress.stage && progress.total_percent > 0;
- const elapsedStr = formatElapsed(elapsed);
- if (hasUseful) {
- showPersistentToast(
- toastId,
- t(
- 'slice.previewWithProgress',
- 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
- {
- name: prettyName,
- stage: progress!.stage,
- percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
- elapsed: elapsedStr,
- },
- ),
- 'loading',
- );
- } else {
- showPersistentToast(
- toastId,
- t('slice.previewToast', 'Analyzing {{name}} — {{elapsed}}', {
- name: prettyName,
- elapsed: elapsedStr,
- }),
- 'loading',
- );
- }
- return () => {
- dismissToast(toastId);
- };
- }, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
- const stage = progress?.stage;
- const percent = progress?.total_percent;
- const inlineLabel =
- stage && typeof percent === 'number' && percent > 0
- ? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
- : t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…');
- 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" />
- {inlineLabel}
- <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>
- );
- }
- 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 SliceModal({ source, onClose }: SliceModalProps) {
- const { t } = useTranslation();
- const { trackJob } = useSliceJobTracker();
- const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
- const [processPreset, setProcessPreset] = 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)[]>([]);
- // Bundle dispatch (alternative to the preset triplet). When non-null, the
- // SliceModal hides the cloud/local/standard preset dropdowns and shows
- // bundle-scoped pickers (process + per-slot filament from the chosen
- // bundle's contents). Submit routes through the backend's bundle dispatch
- // (`SliceRequest.bundle`) which skips PresetRef resolution.
- const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
- const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
- const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | 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);
- // Build-plate override (#1337). null = inherit from the process preset
- // (the default). Set to a canonical slicer enum value to patch
- // curr_bed_type into the resolved process JSON before slicing — needed
- // because the process preset's default plate (typically "Cool Plate") is
- // incompatible with high-temp filaments like ABS / ASA / PC, and the
- // user had no way to switch plates without cloning the preset.
- const [bedType, setBedType] = useState<string | 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;
- // Generate a request_id per (source, plate) pair so the backend's
- // preview-slice and the FilamentAnalysisSpinner's progress poll share
- // the same id. useMemo keeps it stable across renders within the same
- // pair; switching plates regenerates so a stale poll doesn't bleed
- // progress between plates.
- const previewRequestId = useMemo(() => {
- const random =
- typeof crypto !== 'undefined' && 'randomUUID' in crypto
- ? crypto.randomUUID()
- : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
- // Tag the id with the (source, plate) so logs/Network panel show which
- // pair owns the poll. Also lets the lint rule see the deps in use.
- return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
- }, [source.kind, source.id, effectivePlateId]);
- const filamentReqsQuery = useQuery({
- queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
- queryFn: async () => {
- if (source.kind === 'libraryFile') {
- return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
- }
- return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
- },
- 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,
- });
- // Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
- // configured / no bundles imported yet; the bundle picker hides itself
- // in that case so users without bundles see the original modal layout.
- const bundlesQuery = useQuery({
- queryKey: ['slicerBundles'],
- queryFn: api.listSlicerBundles,
- staleTime: 60_000,
- enabled: !platesQuery.isLoading && !needsPlatePicker,
- // Bundle listing is a hard 503 when the sidecar is offline; don't
- // retry tight loops in that case.
- retry: false,
- });
- const selectedBundle: SlicerBundle | null = useMemo(() => {
- if (!selectedBundleId || !bundlesQuery.data) return null;
- return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
- }, [selectedBundleId, bundlesQuery.data]);
- const isBundleMode = selectedBundle != null;
- // 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'));
- // 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]);
- // Bundle-mode auto-pick: when the user picks a bundle (or the slot count
- // changes after the picker is open), default the process to the bundle's
- // first listed process and every filament slot to the bundle's first
- // listed filament. Plain string match — bundles store delta files keyed
- // by user preset name, no scoring needed since the user picks per-slot
- // afterwards if the default is wrong.
- useEffect(() => {
- if (!selectedBundle) {
- // Reset bundle picks when bundle is cleared so re-selection
- // re-defaults rather than carrying stale values.
- setBundleProcessName(null);
- setBundleFilamentNames([]);
- return;
- }
- setBundleProcessName((current) => {
- // Preserve a manual pick if it still exists in the bundle; otherwise
- // re-default. Same shape as the preset auto-pick effect above.
- if (current && selectedBundle.process.includes(current)) return current;
- return selectedBundle.process[0] ?? null;
- });
- setBundleFilamentNames((current) => {
- if (current.length === filamentSlots.length && current.every((n) => n != null)) {
- return current;
- }
- const fallback = selectedBundle.filament[0] ?? null;
- return filamentSlots.map((_, i) => current[i] ?? fallback);
- });
- }, [selectedBundle, filamentSlots]);
- const enqueueMutation = useMutation({
- mutationFn: async () => {
- let body: SliceRequest;
- if (isBundleMode) {
- // Bundle dispatch path. The selected bundle's first printer is the
- // implicit printer choice (every .bbscfg carries exactly one).
- if (
- !selectedBundle ||
- !bundleProcessName ||
- bundleFilamentNames.length === 0 ||
- bundleFilamentNames.some((n) => n == null)
- ) {
- throw new Error(t('slice.bundleAllRequired', 'Bundle process and every filament slot must be picked'));
- }
- const bundleSpec: SliceBundleSpec = {
- bundle_id: selectedBundle.id,
- printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
- process_name: bundleProcessName,
- filament_names: bundleFilamentNames as string[],
- };
- body = {
- bundle: bundleSpec,
- ...(selectedPlate != null ? { plate: selectedPlate } : {}),
- // Bed-type override (#1337) also flows through the bundle path —
- // the sidecar forwards `bedType` as --curr_bed_type to the CLI.
- ...(bedType != null ? { bed_type: bedType } : {}),
- };
- } else {
- if (
- !printerPreset ||
- !processPreset ||
- filamentPresets.length === 0 ||
- filamentPresets.some((r) => r == null)
- ) {
- throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
- }
- body = {
- printer_preset: printerPreset,
- process_preset: processPreset,
- // 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 } : {}),
- // Bed-type override (#1337).
- ...(bedType != null ? { bed_type: bedType } : {}),
- };
- }
- if (source.kind === 'libraryFile') {
- return api.sliceLibraryFile(source.id, body);
- }
- return api.sliceArchive(source.id, body);
- },
- onSuccess: (enqueue) => {
- trackJob(enqueue.job_id, source.kind, source.filename);
- onClose();
- },
- onError: (err: unknown) => {
- const msg = err instanceof Error ? err.message : String(err);
- setErrorMessage(msg);
- },
- });
- // Pre-slice compatibility check: the slicer CLI (both OrcaSlicer and
- // BambuStudio) cannot re-slice a 3MF for a printer different from the one
- // it was originally bound to — the cross-printer "convert project" flow
- // is desktop-Studio only. If we can match the source's printer model to a
- // SliceModal-known model and the user's chosen printer profile names a
- // different model, surface a warning before they click Slice.
- //
- // For bundle mode, the bundle's printer_preset_name plays the same role
- // as the picked PresetRef's resolved name in preset mode.
- const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
- const printerProfileName = isBundleMode
- ? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
- : printerPreset
- ? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
- : null;
- // Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
- // nozzle"). The CLI compat check uses the model prefix; substring match
- // catches both standard and locally-imported user-named profiles that
- // include the model in the name. Cloud presets with arbitrary names
- // (e.g. "My Custom X1C") fall through to no-warning, which is a
- // reasonable default — the user picked it knowingly.
- const printerMismatch =
- !!sourcePrinterModel &&
- !!printerProfileName &&
- !printerProfileName.toLowerCase().includes(sourcePrinterModel.toLowerCase());
- // Slice button stays disabled until *all* of these hold:
- // - the preview slice / embedded-metadata read has succeeded so we know
- // the per-plate filament slot list is final
- // (filamentReqsQuery.isSuccess). Without this gate the synthetic
- // single-slot fallback would auto-enable the button on opaque
- // defaults, before the slicer has even returned the real slot map.
- // - printer + process picked, every filament slot has a profile (the
- // auto-pick fills these once filamentSlots arrives)
- // - no printer-mismatch warning is up (clicking would silently fall
- // back to embedded settings and produce a wrong-printer file)
- const isReady = isBundleMode
- ? selectedBundle != null &&
- bundleProcessName != null &&
- filamentReqsQuery.isSuccess &&
- bundleFilamentNames.length > 0 &&
- bundleFilamentNames.every((n) => n != null) &&
- !printerMismatch
- : printerPreset != null &&
- processPreset != null &&
- filamentReqsQuery.isSuccess &&
- filamentPresets.length > 0 &&
- filamentPresets.every((r) => r != null) &&
- !printerMismatch;
- 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"
- onClick={() => {
- if (!isEnqueuing) onClose();
- }}
- >
- <div
- className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
- onClick={(e) => e.stopPropagation()}
- >
- {/* Header */}
- <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
- <div className="min-w-0">
- <h3 className="text-white font-medium flex items-center gap-2">
- <Cog className="w-4 h-4" />
- {t('slice.title', 'Slice model')}
- </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
- onClick={onClose}
- disabled={isEnqueuing}
- className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
- aria-label={t('common.close', 'Close')}
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Body */}
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
- {/* Preset listing loader — printer/process dropdowns can't render
- without it. Plate query reuses the same spinner since it's
- also blocking. */}
- {(platesQuery.isLoading || presetsQuery.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…')}
- </div>
- )}
- {presetsQuery.isError && (
- <div className="text-sm text-red-400" role="alert">
- {t(
- 'slice.presetsLoadFailed',
- 'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
- )}
- </div>
- )}
- {presetsQuery.data && (
- <>
- <CloudStatusBanner status={presetsQuery.data.cloud_status} />
- {/* Bundle picker — only renders when at least one .bbscfg has
- been imported via Settings → Slicer Bundles. Lets the user
- trade the cloud/local/standard tier for a single curated
- triplet from a previously-uploaded BambuStudio bundle. */}
- {bundlesQuery.data && bundlesQuery.data.length > 0 && (
- <BundlePicker
- bundles={bundlesQuery.data}
- selectedId={selectedBundleId}
- onChange={setSelectedBundleId}
- disabled={isEnqueuing}
- />
- )}
- {/* Preset triplet — hidden when a bundle is selected so the
- user only sees one tier at a time. The bundle's process +
- filament dropdowns render below in their stead. */}
- {!isBundleMode && (
- <>
- <PresetDropdown
- label={t('slice.printer', 'Printer profile')}
- slot="printer"
- data={presetsQuery.data}
- value={printerPreset}
- onChange={setPrinterPreset}
- disabled={isEnqueuing}
- />
- <PresetDropdown
- label={t('slice.process', 'Process profile')}
- slot="process"
- data={presetsQuery.data}
- value={processPreset}
- onChange={setProcessPreset}
- disabled={isEnqueuing}
- />
- </>
- )}
- {isBundleMode && selectedBundle && (
- <>
- {/* Bundle's printer is implicit (each .bbscfg has exactly
- one). Show it as a read-only label so the user can
- verify the printer they're slicing for. */}
- <div>
- <label className="block text-sm text-bambu-gray mb-1">
- {t('slice.printer', 'Printer profile')}
- </label>
- <div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
- {selectedBundle.printer_preset_name}
- </div>
- </div>
- <BundleStringDropdown
- label={t('slice.process', 'Process profile')}
- options={selectedBundle.process}
- value={bundleProcessName}
- onChange={setBundleProcessName}
- disabled={isEnqueuing}
- />
- </>
- )}
- {/* Bed-type override (#1337). Always visible, always enabled.
- In non-bundle mode the backend patches curr_bed_type on the
- resolved process JSON before forwarding to the sidecar; in
- bundle mode the same value rides through as a sidecar form
- field so the bundle's materialised process JSON gets the
- override applied there too. */}
- <BedTypeDropdown
- value={bedType}
- onChange={setBedType}
- disabled={isEnqueuing}
- />
- {/* Filament reqs may need a server-side preview-slice for
- unsliced project files (single-pass, then cached). Show a
- scoped spinner so the user sees the printer/process
- dropdowns instead of an opaque "Loading presets…" wait. */}
- {filamentReqsQuery.isLoading ? (
- <FilamentAnalysisSpinner
- requestId={previewRequestId}
- sourceName={source.filename}
- />
- ) : isBundleMode && selectedBundle ? (
- filamentSlots.map((slot, idx) => {
- 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 (
- <BundleStringDropdown
- key={`bundle-filament-${idx}`}
- label={label}
- options={selectedBundle.filament}
- value={bundleFilamentNames[idx] ?? null}
- onChange={(name) =>
- setBundleFilamentNames((current) => {
- const next = current.length === filamentSlots.length
- ? [...current]
- : filamentSlots.map((_, i) => current[i] ?? null);
- next[idx] = name;
- return next;
- })
- }
- disabled={isEnqueuing || !isUsed}
- 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}
- />
- );
- })
- )}
- </>
- )}
- {printerMismatch && (
- <div
- className="text-sm text-amber-200 bg-amber-900/20 border border-amber-700/40 rounded p-2"
- role="alert"
- >
- {t('slice.printerMismatch', {
- source: sourcePrinterModel,
- target: printerProfileName,
- defaultValue:
- '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.',
- })}
- </div>
- )}
- {errorMessage && (
- <div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
- {errorMessage}
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
- <button
- type="button"
- onClick={onClose}
- disabled={isEnqueuing}
- className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
- >
- {t('common.cancel', 'Cancel')}
- </button>
- <button
- type="button"
- onClick={() => {
- setErrorMessage(null);
- enqueueMutation.mutate();
- }}
- disabled={!isReady || isEnqueuing}
- className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
- >
- {isEnqueuing ? (
- <>
- <Loader2 className="w-4 h-4 animate-spin" />
- {t('slice.enqueuing', 'Submitting slice job…')}
- </>
- ) : (
- t('slice.action', 'Slice')
- )}
- </button>
- </div>
- </div>
- </div>
- );
- }
- function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
- const { t } = useTranslation();
- if (status === 'ok') return null;
- // Map each non-ok status to the appropriate icon + tone. None of these are
- // hard errors — the user can still slice using local + standard presets,
- // so we use info / warn styling rather than error red.
- const config: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud; key: string; fallback: string }> = {
- not_authenticated: {
- tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
- icon: Cloud,
- key: 'slice.cloud.notAuthenticated',
- fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
- },
- expired: {
- tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
- icon: CloudOff,
- key: 'slice.cloud.expired',
- fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
- },
- unreachable: {
- tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
- icon: CloudOff,
- key: 'slice.cloud.unreachable',
- fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
- },
- };
- const { tone, icon: Icon, key, fallback } = config[status];
- return (
- <div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
- <Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
- <span>{t(key, fallback)}</span>
- </div>
- );
- }
- // Build-plate options offered in the SliceModal (#1337). Values are the
- // canonical strings the slicer's StaticPrintConfig validator accepts as
- // `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
- // enum; OrcaSlicer accepts the same set with a Supertack alias that users
- // can target via the same dropdown if they re-import their presets.
- const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
- { value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
- {
- value: 'Cool Plate (SuperTack)',
- labelKey: 'slice.bedType.coolPlateSuperTack',
- fallback: 'Cool Plate SuperTack',
- },
- { value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
- { value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
- { value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
- { value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
- ];
- function BedTypeDropdown({
- value,
- onChange,
- disabled,
- }: {
- value: string | null;
- onChange: (value: string | null) => void;
- disabled?: boolean;
- }) {
- const { t } = useTranslation();
- return (
- <label className="block">
- <span className="block text-xs text-bambu-gray mb-1">
- {t('slice.bedType.label', 'Build plate')}
- </span>
- <select
- value={value ?? ''}
- onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
- disabled={disabled}
- className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
- >
- <option value="">{t('slice.bedType.auto', 'Auto (use process preset)')}</option>
- {BED_TYPE_OPTIONS.map((opt) => (
- <option key={opt.value} value={opt.value}>
- {t(opt.labelKey, opt.fallback)}
- </option>
- ))}
- </select>
- </label>
- );
- }
- interface PresetDropdownProps {
- label: string;
- slot: Slot;
- data: UnifiedPresetsResponse;
- 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, 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: '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
- .map(({ key, label: lk, fallback }) => ({
- tierLabel: t(lk, fallback),
- entries: (data[key] as UnifiedPresetsBySlot)[slot],
- }))
- .filter((s) => s.entries.length > 0);
- }, [data, slot, t]);
- const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0);
- return (
- <label className="block">
- <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))}
- disabled={disabled || totalEntries === 0}
- className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
- >
- <option value="">
- {totalEntries === 0
- ? t('slice.noPresetsForSlot', 'No presets available')
- : t('slice.selectPreset', '— Select a preset —')}
- </option>
- {sections.map((section) => (
- <optgroup key={section.tierLabel} label={section.tierLabel}>
- {section.entries.map((p) => (
- <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
- {p.name}
- </option>
- ))}
- </optgroup>
- ))}
- </select>
- </label>
- );
- }
- // Top-of-modal bundle picker. The "None" option leaves the user on the
- // cloud/local/standard tier path; selecting a bundle id flips the modal
- // into bundle dispatch mode (see SliceModal state above).
- interface BundlePickerProps {
- bundles: SlicerBundle[];
- selectedId: string | null;
- onChange: (id: string | null) => void;
- disabled?: boolean;
- }
- function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
- const { t } = useTranslation();
- return (
- <label className="block">
- <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
- <Package className="w-3.5 h-3.5" />
- {t('slice.bundle', 'Slicer bundle')}
- </span>
- <select
- value={selectedId ?? ''}
- onChange={(e) => onChange(e.target.value || null)}
- disabled={disabled}
- className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
- >
- <option value="">
- {t('slice.bundleNone', '— None (pick presets individually) —')}
- </option>
- {bundles.map((b) => (
- <option key={b.id} value={b.id}>
- {b.printer_preset_name}
- </option>
- ))}
- </select>
- </label>
- );
- }
- // Plain-string dropdown used for bundle-mode process / filament selectors.
- // Bundles store presets as a flat list of names within their printer-tied
- // directory, so a `<select>` of strings is enough — no source tier, no
- // optgroups. Same swatch / disabled affordances as the cloud/local/standard
- // PresetDropdown above so the visual rhythm of the form stays consistent.
- interface BundleStringDropdownProps {
- label: string;
- options: string[];
- value: string | null;
- onChange: (next: string | null) => void;
- disabled?: boolean;
- swatchColor?: string;
- }
- function BundleStringDropdown({
- label,
- options,
- value,
- onChange,
- disabled,
- swatchColor,
- }: BundleStringDropdownProps) {
- const { t } = useTranslation();
- return (
- <label className="block">
- <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
- {swatchColor && (
- <span
- className="inline-block w-3 h-3 rounded-sm border border-black/20"
- style={{ backgroundColor: swatchColor || 'transparent' }}
- aria-hidden
- />
- )}
- <span>{label}</span>
- </span>
- <select
- value={value ?? ''}
- onChange={(e) => onChange(e.target.value || null)}
- disabled={disabled || options.length === 0}
- className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
- >
- <option value="">
- {options.length === 0
- ? t('slice.noPresetsForSlot', 'No presets available')
- : t('slice.selectPreset', '— Select a preset —')}
- </option>
- {options.map((name) => (
- <option key={name} value={name}>
- {name}
- </option>
- ))}
- </select>
- </label>
- );
- }
|