import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react'; import { useEffect, useId, 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 { 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, 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 ` { 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')} > {pipelinesQuery.data?.pipelines.map((p) => ( ))} {!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 ( 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} /> ); }) )} {/* 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; // Selected printer context (#1325). When provided for a process / filament // slot, presets that resolve to a different printer (per compatIndex) are // held back behind a "Show all" link instead of padding out the main list. selectedPrinterName?: string | null; compatIndex?: PrinterCompatibilityIndex; } function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor, selectedPrinterName, compatIndex, }: PresetDropdownProps) { const { t } = useTranslation(); // Reveals the other-printer group for this slot only. Per-dropdown rather // than modal-wide: wanting a filament from another printer's library says // nothing about wanting its process profiles too. const [showAll, setShowAll] = useState(false); // Binds the label to the select now that they are siblings rather than // nested. Filament slots render several of these, so the id must be unique // per instance rather than derived from the slot name. const selectId = useId(); // 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]); // 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