import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api, type PresetRef, type PresetSource, type SliceJobProgress, type SliceRequest, type SlicerCloudStatus, type UnifiedPreset, type UnifiedPresetsBySlot, type UnifiedPresetsResponse, } from '../api/client'; import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; import { useToast } from '../contexts/ToastContext'; import { PlatePickerModal } from './PlatePickerModal'; import type { DesignOverride, PlateFilament } from '../types/plates'; import { presetCompatibility, buildCompatibilityIndex, EMPTY_COMPATIBILITY_INDEX, type PrinterCompatibilityIndex, } from '../utils/slicerPrinterMatch'; import { findPreset, findPresetByName, pickDefault, pickFilamentForSlot, pickProcessDefault, type Slot, } from '../utils/slicePresetPicker'; export type SliceSource = | { kind: 'libraryFile'; id: number; filename: string } | { kind: 'archive'; id: number; filename: string }; interface SliceModalProps { source: SliceSource; onClose: () => void; } function toRefValue(ref: PresetRef | null): string { // The HTML ` { 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 && ( )} {/* Designer's process tweaks (#2622). BambuStudio records which keys deviate from the stock preset in the 3MF itself, so a re-slice for another printer can carry them instead of flattening them under --load-settings. Hidden entirely when the source lists none, and disabled in embedded mode where the process JSON these patch is never sent. */} {designOverrides.length > 0 && (
{designExpanded && (
{designOverrides.map((o) => ( ))}
)}
)} {/* 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. */} {/* 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} /> ); }) )} )} {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) move // into a trailing "Other printers" group instead of the main tier list. selectedPrinterName?: string | null; compatIndex?: PrinterCompatibilityIndex; } function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor, selectedPrinterName, compatIndex, }: PresetDropdownProps) { const { t } = useTranslation(); // Tier sections (imported → cloud → standard), plus — for a process / // filament slot with a selected printer — a trailing group of presets that // resolve to a different printer (#1325). Compatibility-unknown presets // stay in their tier, so a custom / untagged preset is never hidden, and // empty sections collapse out. const { sections, otherEntries } = useMemo(() => { const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [ { key: 'local', label: 'slice.tier.local', fallback: 'Imported' }, { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' }, { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' }, { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' }, ]; const filterByPrinter = slot !== 'printer'; const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = []; const other: UnifiedPreset[] = []; for (const { key, label: lk, fallback } of tiers) { const entries = (data[key] as UnifiedPresetsBySlot)[slot]; if (!filterByPrinter) { if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries }); continue; } const compatible: UnifiedPreset[] = []; for (const p of entries) { if ( presetCompatibility( p, // filterByPrinter is true here, so slot is never 'printer'. slot as 'process' | 'filament', selectedPrinterName ?? null, compatIndex ?? EMPTY_COMPATIBILITY_INDEX, ) === 'mismatch' ) { other.push(p); } else { compatible.push(p); } } if (compatible.length > 0) { compatSections.push({ tierLabel: t(lk, fallback), entries: compatible }); } } return { sections: compatSections, otherEntries: other }; }, [data, slot, t, selectedPrinterName, compatIndex]); const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length; return ( ); }