import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react'; import { useEffect, useId, useMemo, useRef, 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 { useIsWideLayout } from '../hooks/useIsWideLayout'; import { PlatePickerModal } from './PlatePickerModal'; import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel'; import type { DesignOverride, PlateFilament } from '../types/plates'; import type { SettingValue } from '../types/slicerSettings'; import { presetCompatibility, buildCompatibilityIndex, EMPTY_COMPATIBILITY_INDEX, type PrinterCompatibilityIndex, } from '../utils/slicerPrinterMatch'; import { findPreset, findPresetByName, pickDefault, pickFilamentForSlot, pickProcessDefault, statesDifferentMaterial, 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 `` accepts only `#RRGGBB`. Source colours reach us in // both that form and the 8-digit `#RRGGBBAA` the AMS reports, so the alpha byte // is trimmed for display only — an untouched slot still submits the original // string, alpha included. function colourInputValue(raw: string | null | undefined): string { const value = (raw || '').trim(); return /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value) ? value.slice(0, 7).toUpperCase() : SLICER_DEFAULT_COLOUR; } export function SliceModal({ source, onClose }: SliceModalProps) { const { t } = useTranslation(); const { trackJob } = useSliceJobTracker(); const queryClient = useQueryClient(); const [printerPreset, setPrinterPreset] = useState(null); const [processPreset, setProcessPreset] = useState(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)[]>([]); // Slots the user chose a filament for by hand, or by applying a pipeline. // The pre-pick below re-picks a slot whose preset states the wrong material, // and without this it would do that to a deliberate choice too — printing // PETG on a plate a designer labelled PLA is a thing people do on purpose. A // ref rather than state: this must not itself re-trigger the pre-pick. const explicitFilamentSlots = useRef>(new Set()); // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`. // `null` means "not overridden" rather than "no colour": the slot then falls // through to the source plate's own colour, and — when the source has none // either — to the backend's remaining fallbacks. Storing an explicit colour // for every slot up front would defeat that chain, because a sent colour // outranks the preset's own `default_filament_colour` (#2977). const [filamentColours, setFilamentColours] = useState<(string | null)[]>([]); const [errorMessage, setErrorMessage] = useState(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(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(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); // Auto-orient / auto-arrange (#2548) — the GUI's two layout buttons, // forwarded as the slicer's --orient / --arrange CLI actions. Per-slice // and off by default: both rewrite the object placement the file came // with, so they are something the user asks for, never a default. Kept // enabled in embedded mode, unlike the process-level options around // them — these act on the geometry, whichever config drives the slice. const [autoOrient, setAutoOrient] = useState(false); const [autoArrange, setAutoArrange] = 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>(new Set()); // Process settings the user edited by hand in the settings panel. Two shapes // are kept: the panel's editing values, and the same set serialised into the // string forms a process preset stores. The panel owns the option schema, so // it hands back both rather than making this component re-derive the second. const [processOverrides, setProcessOverrides] = useState>({}); const [serializedProcessOverrides, setSerializedProcessOverrides] = useState>({}); const [settingsExpanded, setSettingsExpanded] = useState(false); // Wide enough for the two-column layout, where the panel has a column to // itself and so is always open. The disclosure only exists for the narrow // single-stack layout, in which 348 unfolded options would bury the preset // pickers above them. const isWideLayout = useIsWideLayout(); const panelOpen = isWideLayout || settingsExpanded; // 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(() => { 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 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(() => { 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 name fallback for cloud // / standard presets via the backend Bambu printer-model registry. const compatIndex = useMemo( () => buildCompatibilityIndex(printerModelsQuery.data ?? {}), [printerModelsQuery.data], ); // The picked process preset's effective values, flattened by the sidecar. // Without this the settings panel shows OrcaSlicer's compiled-in defaults — // a preset with a 0.42mm line width would read 0, which is the C++ default // meaning "derive from the nozzle". Keyed on the preset so switching presets // re-baselines the panel. const presetValuesQuery = useQuery({ queryKey: ['slicer-preset-values', processPreset?.source, processPreset?.id], queryFn: () => api.getSlicerPresetValues(processPreset as PresetRef), enabled: processPreset != null, // Preset contents only change when the user edits them in the slicer, and // the modal is short-lived; no need to re-fetch while it is open. staleTime: 5 * 60_000, }); // A failed fetch is not an error the user must act on — the panel falls back // to schema defaults and says so — so treat "no data yet" as unresolved // rather than blocking the panel on it. const presetValues = presetValuesQuery.data?.values as Record | undefined; const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading; // A failed request (rather than a 'resolved: false' answer) means we // never reached the backend, which is the same situation as an // unreachable sidecar as far as the user is concerned. const presetValuesReason = presetValuesQuery.data?.reason ?? (presetValuesQuery.isError ? 'sidecar_unavailable' : undefined); // Slot list for the settings panel's filament pickers (support base and // interface, and the Multimaterial page's per-region options). Those store a // plain integer, so without this the user has to map slot numbers onto their // own AMS by hand. Falls back to the slot's material when a slot has no pick // yet, so the list is never a column of blanks. const filamentChoices = useMemo(() => { const data = presetsQuery.data; return filamentSlots.map((slot, idx) => { const ref = filamentPresets[idx] ?? null; const preset = data && ref ? findPreset(data, ref, 'filament') : null; return { index: idx + 1, label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'), color: slot.color || undefined, }; }); }, [filamentSlots, filamentPresets, presetsQuery.data, t]); // 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( () => 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(() => { 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]); // The per-key ticks follow "Use the file's built-in settings" (#2942). // Off, nothing comes out of the file unless the user asks for it by name; // on, the file drives the whole slice, so every setting it changed shows // ticked — a readout of what the toggle means rather than a control, since // the embedded path sends no design_overrides at all. // // They used to arrive pre-ticked whatever that toggle said, which is what // #2942 reported: a slice with it deliberately unticked still took sixteen // values from the file, several of them on rows the slicer had greyed out, // where the tick could not be cleared. One checkbox now answers the // question the whole dialog asks — where do this slice's settings come // from — and the per-key ticks refine that answer instead of contradicting // it. Reaching them in bulk is the panel's own "use the designer's // settings", which still leaves the machine-coupled and preset-defining // keys for an explicit per-key choice. useEffect(() => { setDesignKeys(useEmbedded ? new Set(designOverrides.map((o) => o.key)) : new Set()); }, [designOverrides, useEmbedded]); // 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'); // Compatible with the printer is not on its own a reason to keep it: // a preset that states a different material than the plate asks for // is the wrong preset however well it fits the machine, and holding // onto one is how a PETG profile survived on a PLA plate through // every re-pick (#2982). An explicit choice is exempt — the material // rule exists to correct the auto-pick, not to overrule the user. const wrongMaterial = p && !explicitFilamentSlots.current.has(i) && statesDifferentMaterial(p, slot.type); if ( p && !wrongMaterial && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch' ) { return cur; } } return pickFilamentForSlot( data, { type: slot.type, color: slot.color }, selectedPrinterName, compatIndex, ); }); }); }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]); // Drop colour overrides when the slot count changes. A plate switch renumbers // the slots, so keeping index-keyed overrides would paint slot 2's colour // onto whatever the new plate happens to call slot 2. Same slot count keeps // them: that is a re-pick of presets, not a different plate layout. useEffect(() => { setFilamentColours((current) => { if (current.length === filamentSlots.length) return current; // A renumbered slot list invalidates the record of which slots the user // chose for the same reason it invalidates the colours: slot 2 of the new // plate is not slot 2 of the old one. explicitFilamentSlots.current = new Set(); return filamentSlots.map(() => null); }); }, [filamentSlots]); 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[], // An untouched slot submits the source plate's own colour, and an empty // string when the source has none — which is what lets the backend fall // back to the preset's `default_filament_colour` for the Orca-imported // profiles that carry one. Sending a placeholder here instead would win // that comparison and silently discard the preset's real colour. filament_colours: filamentSlots.map( (slot, i) => filamentColours[i] ?? slot.color ?? '', ), ...(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). // // Sent whenever the file offered any, an empty list included: the // backend reads a missing list as a client that predates the per-key // ticks and keeps carrying the file's support settings unconditionally // (#1881), where an empty one says the user was shown them and took // none (#2942). ...(!useEmbedded && designOverrides.length > 0 ? { design_overrides: [...designKeys] } : {}), // The user's own edits from the settings panel. Like design_overrides // these patch the resolved process JSON, so the embedded-settings path // (which sends no process JSON at all) cannot carry them. ...(!useEmbedded && Object.keys(processOverrides).length > 0 ? { process_overrides: serializedProcessOverrides } : {}), // Sent only when on. The backend defaults both to false, so omitting // them keeps the request identical to what older clients send. ...(autoOrient ? { auto_orient: true } : {}), ...(autoArrange ? { auto_arrange: true } : {}), }; } // 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 ( 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 (
{ if (!isEnqueuing) onClose(); }} >
e.stopPropagation()} > {/* Header */}

{t('slice.title')}

{source.filename} {selectedPlate != null ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}` : ''}

{/* Body */}
{/* 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) && (
{t('slice.loadingPresets')}
)} {presetsQuery.isError && (
{t( 'slice.presetsLoadFailed', 'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.', )}
)} {presetsQuery.data && ( <>
{/* 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. */} {/* Two columns once there is room for them. The left keeps the "what am I slicing with" decisions together; the right gives the process-settings panel a column of its own, which is the only way 348 options are comfortable to work through. Below lg both collapse back into the original single stack. The left column grows with the modal: fixed at 20rem it cut preset names off and wrapped every filament label while the settings panel sat half empty. */}
{/* 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. */}
{t('slice.pipelines.label', 'Pipeline')} {!savePipelineOpen ? ( ) : (
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" />
)}
{/* "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 && ( )} {/* 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. */} {/* Layout passes (#2548) — the GUI's "Auto orient" / "Auto arrange". Not disabled in embedded mode: these are CLI actions on the geometry, so they work regardless of where the print config comes from. */}
{/* 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 ? ( ) : ( 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 ( { explicitFilamentSlots.current.add(idx); setFilamentPresets((current) => { const next = current.length === filamentSlots.length ? [...current] : filamentSlots.map((_, i) => current[i] ?? null); next[idx] = ref; return next; }); }} disabled={isEnqueuing || !isUsed || useEmbedded} // Shown for every filament slot now that it is editable, // not just multi-color ones: a single-slot STL is exactly // the case with no colour anywhere else to inherit. swatchColor={filamentColours[idx] ?? slot.color} swatchColorLabel={t('slice.filamentColour')} onSwatchColorChange={(colour) => setFilamentColours((current) => { const next = current.length === filamentSlots.length ? [...current] : filamentSlots.map((_, i) => current[i] ?? null); next[idx] = colour; return next; }) } selectedPrinterName={selectedPrinterName} compatIndex={compatIndex} /> ); }) )}
{/* Right column: the settings panel. It owns this column, so there is nothing to collapse it out of the way of — the disclosure below lg exists only because the single-column stack cannot afford 348 options unfolded. Kept on screen in embedded mode but disabled rather than removed: nothing here is sent on that path (the file's own settings drive the slice), and dropping the column outright made the dialog look like it had lost a feature whenever the toggle was flipped. */}
{panelOpen && (
{ setProcessOverrides(values); setSerializedProcessOverrides(serialized); }} disabled={isEnqueuing || useEmbedded} // The designer's own deviations (#2622) are shown // against the options they belong to rather than in // a list of their own. Only the tick state lives // here; the values still travel as design_overrides, // so the backend keeps reading them from the file // and keys outside the vendored schema stay faithful. filamentChoices={filamentChoices} presetValues={presetValues} presetValuesResolved={presetValuesResolved} presetValuesReason={presetValuesReason} sourceOverrides={designOverrides} sourceSelected={designKeys} onToggleSource={(key, on) => setDesignKeys((prev) => { const next = new Set(prev); if (on) next.add(key); else next.delete(key); return next; }) } />
)}
)} {errorMessage && (
{errorMessage}
)}
{/* Footer */}
{canSliceAll && ( )}
); } 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 (
{t(key, fallback)}
); } // 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 ( ); } 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; // When set, the swatch becomes an editable colour input for this slot // (#2977). Filament presets carry no colour of their own, so for an STL — // or any source whose plate was never given one — this is the only place the // colour recorded in the sliced file can come from. onSwatchColorChange?: (colour: string) => void; // Accessible name for that input. Required alongside the handler because the // visible label belongs to the preset it sits next to. const colourId = `${selectId}-colour`; // 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[] }[] = []; // The same sections with the printer filter never applied, kept for the // all-filtered-out case below. const unfiltered: { tierLabel: string; entries: UnifiedPreset[] }[] = []; const other: UnifiedPreset[] = []; for (const { key, label: lk, fallback } of tiers) { const entries = (data[key] as UnifiedPresetsBySlot)[slot]; if (entries.length > 0) unfiltered.push({ tierLabel: t(lk, fallback), entries }); 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 }); } } // Filtering that leaves nothing at all is a statement about our matching, // not about the presets: a P1S has ten usable process presets and read as // having none, because every one of them is named for an X1C and only says // "P1S" in a `compatible_printers` list an older sidecar doesn't report // (#2982). Hiding all of them left a dropdown holding one auto-picked entry // and a "Show all" link, with no hint that the list was the problem. So // when the filter empties the list, show it unfiltered — a visible preset // for the wrong printer is recoverable, an empty dropdown is not. if (compatSections.length === 0 && other.length > 0) { return { sections: unfiltered, otherEntries: [] }; } return { sections: compatSections, otherEntries: other }; }, [data, slot, t, selectedPrinterName, compatIndex]); // Other-printer presets are held back by default so the list shows what is // usable on the selected printer. Two things are never hidden: a preset whose // compatibility is merely *unknown* (it never reaches otherEntries), and the // one currently selected — a pipeline or an auto-pick can land on a // cross-printer preset, and dropping it from the options would blank the // select and silently discard the choice. const selectedRefValue = toRefValue(value); const visibleOther = useMemo(() => { if (showAll) return otherEntries; return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue); }, [showAll, otherEntries, selectedRefValue]); const hiddenCount = otherEntries.length - visibleOther.length; const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length; return ( // A plain wrapper rather than a