Files
bambuddy/frontend/src/components/SliceModal.tsx
T
maziggy db538e43f1 fix(slice): give the slice modal one filament row per project slot (#2712)
The filament list is positional from the modal down to the CLI's
filament_N.json parts, but for a source that already carries slice_info the
requirements endpoint returns only the slots the plate consumes. A
MakerWorld model declaring four filaments and painting with slot 4 alone
therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
slot 4 sliced with the profile baked into the source, and the print came out
PLA.

The endpoint now takes full_slots, which widens that answer to every
project slot with used_in_plate flags, and only the slice modal passes it.
Print-time AMS matching shares the endpoint and keeps the used-only list, so
it still asks for exactly the spools the job needs.
2026-07-30 17:34:26 +02:00

1244 lines
54 KiB
TypeScript

import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
api,
type PresetRef,
type PresetSource,
type SliceJobProgress,
type SliceRequest,
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 { DesignOverride, PlateFilament } from '../types/plates';
import {
presetCompatibility,
buildCompatibilityIndex,
EMPTY_COMPATIBILITY_INDEX,
type PrinterCompatibilityIndex,
} from '../utils/slicerPrinterMatch';
import {
findPreset,
findPresetByName,
pickDefault,
pickFilamentForSlot,
pickProcessDefault,
type Slot,
} from '../utils/slicePresetPicker';
export type SliceSource =
| { kind: 'libraryFile'; id: number; filename: string }
| { kind: 'archive'; id: number; filename: string };
interface SliceModalProps {
source: SliceSource;
onClose: () => void;
}
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 !== 'orca_cloud' && 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', {
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');
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`;
}
// Render a slicer parameter value for the design-settings list. Bambu's process
// schema stores everything as strings or arrays of strings, so this only has to
// flatten arrays and keep scalars readable — no unit or type interpretation,
// which would rot against every slicer release.
function formatDesignValue(value: unknown): string {
if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
if (value == null) return '';
return String(value);
}
export function SliceModal({ source, onClose }: SliceModalProps) {
const { t } = useTranslation();
const { trackJob } = useSliceJobTracker();
const queryClient = useQueryClient();
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)[]>([]);
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);
// "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
// ``--slice 0`` to the BS CLI, producing a single output 3MF whose
// ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
// one archive, one file, all plates. Distinct from the per-plate
// ``selectedPlate`` mode (which slices just that one plate). Filament
// selection in this mode covers every slot the project defines, not
// just the slots the currently-visible plate happens to use — see
// ``allProjectFilamentSlots`` below.
const [sliceAllPlates, setSliceAllPlates] = useState(false);
// 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);
// "Slice as designed" (#2611). When on, the backend honours the source
// 3MF's embedded project_settings.config (the designer's own wall count,
// infill, etc.) instead of the picked process/filament profiles. Only
// offered when the picked printer matches the design's target model —
// see canUseEmbedded below.
const [useEmbedded, setUseEmbedded] = useState(false);
// #2622: process settings the designer changed away from the stock preset,
// carried onto the picked process profile so a cross-printer re-slice keeps
// the model's intended wall count / infill / first layer instead of losing
// them to --load-settings. Keys the file flags as machine-coupled (speeds,
// accelerations, prime-tower geometry) are listed but start unticked — those
// were tuned for the designer's printer and can be plain wrong on another.
const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
const [designExpanded, setDesignExpanded] = useState(false);
// Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
// with one pick, or save the current selection as a new pipeline.
const pipelinesQuery = useQuery({
queryKey: ['slicer-pipelines'],
queryFn: () => api.listSlicerPipelines(),
staleTime: 60_000,
});
const [savePipelineOpen, setSavePipelineOpen] = useState(false);
const [pipelineDraftName, setPipelineDraftName] = useState('');
const { showToast } = useToast();
const createPipelineMutation = useMutation({
mutationFn: (body: {
name: string;
printer_preset: PresetRef;
process_preset: PresetRef;
filament_presets: PresetRef[];
bed_type: string | null;
}) => api.createSlicerPipeline(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
setSavePipelineOpen(false);
setPipelineDraftName('');
},
onError: (err: Error) => {
showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
},
});
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 () => {
// `fullSlots`: one row per project slot, not only the ones this plate
// prints with. The list below is positional all the way to the CLI's
// filament_N.json parts, so a source whose only used slot is 4 has to
// present four rows — otherwise the single pick binds to slot 1 and
// slot 4 slices with whatever the source had baked in (#2712). The
// unused rows stay disabled exactly as before.
if (source.kind === 'libraryFile') {
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
}
return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
},
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). In ``sliceAllPlates`` mode
// we keep the same slot list (the backend already returns every project
// slot via ``extract_project_filaments_from_3mf``'s fallback path when
// slice_info doesn't carry per-plate filaments) but override every
// slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
// drop the "— not used by this plate" suffix and the dropdowns become
// selectable. Across the whole project, every defined slot IS used by
// at least one plate, so this is correct in slice-all mode.
const filamentSlots = useMemo<PlateFilament[]>(() => {
const reqs = filamentReqsQuery.data?.filaments ?? [];
const base: PlateFilament[] =
reqs.length > 0
? (reqs as PlateFilament[])
: [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
if (sliceAllPlates) {
return base.map((slot) => ({ ...slot, used_in_plate: true }));
}
return base;
}, [sliceAllPlates, 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,
});
// Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
// bundled cache for one call so users who deleted a preset in Bambu
// Studio / Bambu Handy see the change immediately (#1581). The cache write
// inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
// fresh result so subsequent normal callers still get cached responses.
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefreshPresets = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
const fresh = await api.getSlicerPresets({ refresh: true });
queryClient.setQueryData(['slicerPresets'], fresh);
} catch {
// Fall through to invalidate so React Query retries via its normal
// path on the next render — surfacing the failure through the existing
// presetsQuery.isError banner instead of duplicating error UI here.
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
} finally {
setIsRefreshing(false);
}
};
// Canonical Bambu printer-model registry — drives the @BBL <code> name
// fallback in slicerPrinterMatch for cloud / standard presets (#1325).
// Long staleTime: the registry only changes across backend releases.
const printerModelsQuery = useQuery({
queryKey: ['slicerPrinterModels'],
queryFn: api.getSlicerPrinterModels,
staleTime: Infinity,
});
// Selected-printer context for the process / filament filter (#1325).
const selectedPrinterName = useMemo<string | null>(() => {
if (!presetsQuery.data || !printerPreset) return null;
return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
}, [presetsQuery.data, printerPreset]);
// Compatibility ground truth: the slicer's own `compatible_printers` list
// on local-imported presets, plus the @BBL <code> name fallback for cloud
// / standard presets via the backend Bambu printer-model registry.
const compatIndex = useMemo<PrinterCompatibilityIndex>(
() => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
[printerModelsQuery.data],
);
// Printer / process preset names the source 3MF was prepared with. The
// plates query resolves before the presets query (the latter is gated on
// it), so these are known by the time the pre-pick effects run.
const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
const designOverrides = useMemo<DesignOverride[]>(
() => platesQuery.data?.design_overrides ?? [],
[platesQuery.data],
);
const embeddedProcess = platesQuery.data?.embedded_process ?? null;
// "Slice as designed" is offered only when the source carries embedded
// settings (a real project 3MF, not an STL) AND the picked printer matches
// the design's target model. The match gate is load-bearing: honouring
// embedded settings for a different model would place the model on the
// wrong bed. Names come from the same preset namespace, so a normalised
// (strip "# " prefix, case-fold) equality is enough.
const canUseEmbedded = useMemo<boolean>(() => {
if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
return norm(selectedPrinterName) === norm(embeddedPrinter);
}, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
// Drop back to profile slicing whenever the toggle stops being offered
// (e.g. the user switches to a printer that doesn't match the design).
useEffect(() => {
if (!canUseEmbedded) setUseEmbedded(false);
}, [canUseEmbedded]);
// Pre-tick the printer-independent design settings once the source's list
// arrives. Machine-coupled keys stay off until the user opts in explicitly.
useEffect(() => {
setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
}, [designOverrides]);
// Printer pre-pick: defaults to the printer the 3MF was prepared for when
// that preset is available, else the first listed printer. Runs once when
// presets first arrive; later re-renders preserve any manual choice.
useEffect(() => {
const data = presetsQuery.data;
if (!data) return;
if (printerPreset == null) {
setPrinterPreset(
findPresetByName(data, 'printer', embeddedPrinter) ?? pickDefault(data, 'printer'),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetsQuery.data, embeddedPrinter]);
// Process pre-pick / re-pick (#1325): defaults to a process compatible with
// the selected printer, and re-defaults when a printer change leaves the
// current process incompatible. A compatible or unknown manual pick is kept.
useEffect(() => {
const data = presetsQuery.data;
if (!data) return;
setProcessPreset((current) => {
if (current) {
const p = findPreset(data, current, 'process');
if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
return current;
}
}
return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
});
}, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);
// Filament pre-pick: re-runs when the active filament-slot count changes
// (plate selection, single-plate metadata arriving) or the selected printer
// changes. Each slot scores every available filament preset against the
// slot's required (type, colour); an existing pick (incl. a user override)
// is kept as long as it's still compatible with the selected printer, while
// null slots and printer-incompatible picks are re-picked (#1325).
useEffect(() => {
const data = presetsQuery.data;
if (!data) return;
setFilamentPresets((current) => {
return filamentSlots.map((slot, i) => {
const cur = current[i] ?? null;
if (cur) {
const p = findPreset(data, cur, 'filament');
if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
return cur;
}
}
return pickFilamentForSlot(
data,
{ type: slot.type, color: slot.color },
selectedPrinterName,
compatIndex,
);
});
});
}, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
const enqueueMutation = useMutation({
mutationFn: async (plate: number | null) => {
const body = buildSliceBody(plate);
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);
},
});
// Body builder shared by the single-plate and slice-all paths. ``plate``
// is the 1-indexed plate number to slice, or ``null`` for STL / single-
// plate 3MF sources where the field is omitted entirely.
function buildSliceBody(plate: number | null): SliceRequest {
if (
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((r) => r == null)
) {
throw new Error(t('slice.allPresetsRequired'));
}
return {
printer_preset: printerPreset,
process_preset: processPreset,
filament_preset: filamentPresets[0] as PresetRef,
filament_presets: filamentPresets as PresetRef[],
...(plate != null ? { plate } : {}),
...(bedType != null ? { bed_type: bedType } : {}),
// The preset refs above are still sent (the backend validator requires
// them) but go unused when this flag is set — the slicer falls back on
// the file's embedded project_settings.config instead.
...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
// Carried design settings are patched onto the resolved process JSON,
// which the embedded-settings path never sends — so they are mutually
// exclusive by construction (#2622).
...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
};
}
// Slice button stays disabled until the preview slice / embedded-metadata
// read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
// has a picked profile.
const isReady =
printerPreset != null &&
processPreset != null &&
filamentReqsQuery.isSuccess &&
filamentPresets.length > 0 &&
filamentPresets.every((r) => r != null);
const isEnqueuing = enqueueMutation.isPending;
const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
// 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')}
</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')}
>
<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')}
</div>
)}
{presetsQuery.isError && (
<div className="text-sm text-red-700 dark: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 && (
<>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
<CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
</div>
<button
type="button"
onClick={handleRefreshPresets}
disabled={isRefreshing || isEnqueuing}
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={t('slice.refreshPresetsTitle')}
aria-label={t('slice.refreshPresets')}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
{t('slice.refreshPresets')}
</button>
</div>
{/* CloudStatusBanner above is hidden via flex-1 wrapper when
status === 'ok' (returns null in that case), but the Refresh
button stays visible regardless so users can pick up cloud /
bundled changes even when sign-in is healthy. */}
{/* Slicer Pipelines (#1425): apply a saved preset bundle to all
four slots, or save the current selection as a pipeline.
Pipelines are managed in Settings → Workflow → Pipelines. */}
<div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
<span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
<Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
</span>
<select
value=""
disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
if (Number.isNaN(id)) return;
const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
if (!picked) return;
// Apply slot state. The filament list is right-padded from
// current state so a pipeline with fewer entries than the
// current source's slot count keeps the existing tail.
setPrinterPreset(picked.printer_preset);
setProcessPreset(picked.process_preset);
setBedType(picked.bed_type);
setFilamentPresets((current) => {
const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
for (let i = 0; i < next.length; i++) {
if (i < picked.filament_presets.length) {
next[i] = picked.filament_presets[i];
}
}
return next;
});
showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
// Reset the dropdown so the user can re-apply the same
// pipeline if needed (selects don't fire onChange when
// value reselects the same option).
e.target.value = '';
}}
className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
>
<option value="">
{(pipelinesQuery.data?.pipelines.length ?? 0) === 0
? t('slice.pipelines.empty', 'No saved pipelines')
: t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
</option>
{pipelinesQuery.data?.pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{!savePipelineOpen ? (
<button
type="button"
onClick={() => {
setPipelineDraftName('');
setSavePipelineOpen(true);
}}
disabled={
isEnqueuing ||
!printerPreset ||
!processPreset ||
filamentPresets.length === 0 ||
filamentPresets.some((f) => f === null)
}
className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
>
{t('slice.pipelines.saveButton', 'Save as pipeline')}
</button>
) : (
<div className="flex items-center gap-1 flex-1 min-w-[16ch]">
<input
autoFocus
value={pipelineDraftName}
onChange={(e) => setPipelineDraftName(e.target.value)}
placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
/>
<button
type="button"
onClick={() => {
const trimmed = pipelineDraftName.trim();
if (!trimmed || !printerPreset || !processPreset) return;
const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
if (nonNull.length === 0) return;
createPipelineMutation.mutate({
name: trimmed,
printer_preset: printerPreset,
process_preset: processPreset,
filament_presets: nonNull,
bed_type: bedType,
});
}}
disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
>
{createPipelineMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.save', 'Save')
)}
</button>
<button
type="button"
onClick={() => {
setSavePipelineOpen(false);
setPipelineDraftName('');
}}
className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
)}
</div>
<PresetDropdown
label={t('slice.printer')}
slot="printer"
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
// Locked in embedded mode too: the picked printer is unused on
// the embedded-settings path, and changing it away from the
// design's target would drop canUseEmbedded and yank the toggle
// out from under the user (#2611).
disabled={isEnqueuing || useEmbedded}
/>
{/* "Slice as designed" (#2611): honour the file's embedded
settings instead of the picked process/filament. Offered
only when the picked printer matches the design's target. */}
{canUseEmbedded && (
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
<input
type="checkbox"
checked={useEmbedded}
onChange={(e) => setUseEmbedded(e.target.checked)}
disabled={isEnqueuing}
className="mt-0.5 cursor-pointer"
/>
<span>
{t('slice.useEmbedded')}
<span className="block text-xs text-bambu-gray/70">
{t('slice.useEmbeddedHint')}
</span>
</span>
</label>
)}
<PresetDropdown
label={t('slice.process')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing || useEmbedded}
selectedPrinterName={selectedPrinterName}
compatIndex={compatIndex}
/>
{/* Designer's process tweaks (#2622). BambuStudio records which
keys deviate from the stock preset in the 3MF itself, so a
re-slice for another printer can carry them instead of
flattening them under --load-settings. Hidden entirely when
the source lists none, and disabled in embedded mode where
the process JSON these patch is never sent. */}
{designOverrides.length > 0 && (
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
<button
type="button"
onClick={() => setDesignExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-sm text-white">
{t('slice.designSettings')}
<span className="block text-xs text-bambu-gray/70">
{t('slice.designSettingsHint', { count: designOverrides.length })}
</span>
</span>
<span className="shrink-0 text-xs text-bambu-gray">
{t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
</span>
</button>
{designExpanded && (
<div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
{designOverrides.map((o) => (
<label
key={o.key}
className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
>
<input
type="checkbox"
checked={designKeys.has(o.key)}
disabled={isEnqueuing || useEmbedded}
onChange={(e) => {
setDesignKeys((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(o.key);
else next.delete(o.key);
return next;
});
}}
className="mt-0.5 shrink-0 cursor-pointer"
/>
<span className="min-w-0 flex-1">
<span className="font-mono text-bambu-gray">{o.key}</span>
<span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
{o.printer_coupled && (
<span
className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
title={t('slice.designSettingsPrinterCoupledHint')}
>
{t('slice.designSettingsPrinterCoupled')}
</span>
)}
</span>
</label>
))}
</div>
)}
</div>
)}
{/* Bed-type override (#1337). Always visible, always enabled.
The backend patches curr_bed_type on the resolved process
JSON before forwarding to the sidecar. */}
{/* Bed-type patches curr_bed_type onto the resolved process
JSON, which the embedded-settings path never sends — so it
has no effect there and is disabled to avoid implying it
does. */}
<BedTypeDropdown
value={bedType}
onChange={setBedType}
disabled={isEnqueuing || useEmbedded}
/>
{/* 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}
/>
) : (
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,
})
: t('slice.filament');
const label = isUsed
? baseLabel
: `${baseLabel} ${t('slice.notUsedByPlate')}`;
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 || useEmbedded}
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
selectedPrinterName={selectedPrinterName}
compatIndex={compatIndex}
/>
);
})
)}
</>
)}
{errorMessage && (
<div className="text-sm text-red-700 dark: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')}
</button>
{canSliceAll && (
<label
className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
title={t('slice.actionAllTitle', { count: totalPlateCount })}
>
<input
type="checkbox"
checked={sliceAllPlates}
onChange={(e) => setSliceAllPlates(e.target.checked)}
disabled={isEnqueuing}
className="cursor-pointer"
/>
{t('slice.allPlatesToggle', { count: totalPlateCount })}
</label>
)}
<button
type="button"
onClick={() => {
setErrorMessage(null);
// ``plate=0`` is the sidecar's "all plates" sentinel — passes
// ``--slice 0`` to the BS CLI which produces a single 3MF
// with one ``Metadata/plate_N.gcode`` entry per plate.
const platePayload = sliceAllPlates ? 0 : selectedPlate;
enqueueMutation.mutate(platePayload);
}}
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')}
</>
) : sliceAllPlates ? (
t('slice.actionAll', { count: totalPlateCount })
) : (
t('slice.action')
)}
</button>
</div>
</div>
</div>
);
}
function CloudStatusBanner({
status,
cloudName = 'bambu',
}: {
status: SlicerCloudStatus;
cloudName?: 'bambu' | 'orca';
}) {
const { t } = useTranslation();
// `ok` is the happy path. `not_authenticated` is silenced too: a user who
// hasn't signed in (or has explicitly logged out — #1712) doesn't need a
// permanent nag at the top of the modal; sign-in lives on the Profiles
// page if they want it. Only `expired` and `unreachable` surface — those
// are real breakage states a previously-signed-in user needs to see.
if (status === 'ok' || status === 'not_authenticated') return null;
// Same status vocabulary for both Bambu and Orca Cloud — only the
// user-facing text varies. The fallbacks below name each cloud explicitly
// so the banner makes sense without translation when i18n hasn't been
// updated for a new locale.
const messages =
cloudName === 'orca'
? {
expired: {
key: 'slice.orcaCloud.expired',
fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
},
unreachable: {
key: 'slice.orcaCloud.unreachable',
fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
},
}
: {
expired: {
key: 'slice.cloud.expired',
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
},
unreachable: {
key: 'slice.cloud.unreachable',
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
};
const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
expired: {
tone: 'border-amber-300 dark:border-amber-700/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200',
icon: CloudOff,
},
unreachable: {
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
icon: CloudOff,
},
};
const { tone, icon: Icon } = tones[status];
const { key, fallback } = messages[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')}
</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')}</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;
// Selected printer context (#1325). When provided for a process / filament
// slot, presets that resolve to a different printer (per compatIndex) move
// into a trailing "Other printers" group instead of the main tier list.
selectedPrinterName?: string | null;
compatIndex?: PrinterCompatibilityIndex;
}
function PresetDropdown({
label,
slot,
data,
value,
onChange,
disabled,
swatchColor,
selectedPrinterName,
compatIndex,
}: PresetDropdownProps) {
const { t } = useTranslation();
// Tier sections (imported → cloud → standard), plus — for a process /
// filament slot with a selected printer — a trailing group of presets that
// resolve to a different printer (#1325). Compatibility-unknown presets
// stay in their tier, so a custom / untagged preset is never hidden, and
// empty sections collapse out.
const { sections, otherEntries } = useMemo(() => {
const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
{ key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
{ key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
{ key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
];
const filterByPrinter = slot !== 'printer';
const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
const other: UnifiedPreset[] = [];
for (const { key, label: lk, fallback } of tiers) {
const entries = (data[key] as UnifiedPresetsBySlot)[slot];
if (!filterByPrinter) {
if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
continue;
}
const compatible: UnifiedPreset[] = [];
for (const p of entries) {
if (
presetCompatibility(
p,
// filterByPrinter is true here, so slot is never 'printer'.
slot as 'process' | 'filament',
selectedPrinterName ?? null,
compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
) === 'mismatch'
) {
other.push(p);
} else {
compatible.push(p);
}
}
if (compatible.length > 0) {
compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
}
}
return { sections: compatSections, otherEntries: other };
}, [data, slot, t, selectedPrinterName, compatIndex]);
const totalEntries =
sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
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')
: t('slice.selectPreset')}
</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>
))}
{otherEntries.length > 0 && (
<optgroup label={t('slice.otherPrinters')}>
{otherEntries.map((p) => (
<option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
</label>
);
}