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. */}
{/* 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