import { Cloud, CloudOff, Cog, Loader2, Package, 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 SliceBundleSpec, type SliceJobProgress, type SliceRequest, type SlicerBundle, 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 { PlateFilament } from '../types/plates'; import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers'; import { presetCompatibility, buildCompatibilityIndex, EMPTY_COMPATIBILITY_INDEX, type PrinterCompatibilityIndex, } from '../utils/slicerPrinterMatch'; export type SliceSource = | { kind: 'libraryFile'; id: number; filename: string } | { kind: 'archive'; id: number; filename: string }; interface SliceModalProps { source: SliceSource; onClose: () => void; } type Slot = 'printer' | 'process' | 'filament'; // SliceModal-specific tier priority: orca_cloud → local → cloud → standard. // Imported (local) profiles are surfaced before Bambu Cloud because they're // metadata-tagged (Bambu Cloud isn't, by design — see // `_fetch_cloud_presets`'s rate-limit note). Orca Cloud comes first because // its sync_pull response inlines metadata too AND represents the user's // most-recently-curated source. Standard is the bundled fallback. This is // distinct from the listing endpoint's dedup order and only affects what // the SliceModal renders / pre-picks. const SLICE_MODAL_TIER_ORDER = ['orca_cloud', 'local', 'cloud', 'standard'] as const; function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null { for (const tier of SLICE_MODAL_TIER_ORDER) { const list = by[tier][slot]; if (list.length > 0) { return { source: list[0].source, id: list[0].id }; } } return null; } // Resolve a PresetRef back to its UnifiedPreset within the named slot, or // null if it no longer resolves (e.g. the preset was deleted between the // listing fetch and selection). function findPreset( by: UnifiedPresetsResponse, ref: PresetRef | null, slot: Slot, ): UnifiedPreset | null { if (!ref) return null; return by[ref.source][slot].find((p) => p.id === ref.id) ?? null; } // Find a preset by exact name across tiers (local → cloud → standard). Used // to honour the printer / process preset names a 3MF was prepared with. function findPresetByName( by: UnifiedPresetsResponse, slot: Slot, name: string | null | undefined, ): PresetRef | null { if (!name) return null; for (const tier of SLICE_MODAL_TIER_ORDER) { const p = by[tier][slot].find((x) => x.name === name); if (p) return { source: p.source, id: p.id }; } return null; } // Process default: honour the process preset the 3MF was prepared with // (preferredName) when it's available and not incompatible with the selected // printer; otherwise the first preset compatible with the printer in tier // order, then the first whose compatibility is merely unknown, then plain // priority. Keeps the pre-pick honest with both the embedded config and the // printer filter instead of blindly taking list[0] (#1325). function pickProcessDefault( by: UnifiedPresetsResponse, printerName: string | null, compatIndex: PrinterCompatibilityIndex, preferredName?: string | null, ): PresetRef | null { const preferred = findPresetByName(by, 'process', preferredName); if (preferred) { const p = findPreset(by, preferred, 'process'); if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') { return preferred; } } for (const wanted of ['match', 'unknown'] as const) { for (const tier of SLICE_MODAL_TIER_ORDER) { for (const p of by[tier].process) { if (presetCompatibility(p, 'process', printerName, compatIndex) === wanted) { return { source: p.source, id: p.id }; } } } } return pickDefault(by, 'process'); } const TIER_BONUS: Record = { orca_cloud: 1.75, local: 1.5, cloud: 1.0, standard: 0.5, }; function pickFilamentForSlot( by: UnifiedPresetsResponse, required: { type: string; color: string }, printerName: string | null, compatIndex: PrinterCompatibilityIndex, ): PresetRef | null { // Score every filament preset against the plate slot's required (type, // colour) and pick the highest. Mirrors the AMS slot-mapping match in the // print/schedule modal: type match dominates, exact-colour-match bumps over // similar-colour-match, and a small per-tier bonus breaks ties so cloud // user customisations win over standard bundled fallbacks of equal merit. const reqType = required.type.trim().toUpperCase(); const reqColor = normalizeColorForCompare(required.color); let best: { ref: PresetRef; score: number } | null = null; for (const tier of SLICE_MODAL_TIER_ORDER) { for (const p of by[tier].filament) { let score = 0; const presetType = (p.filament_type ?? '').trim().toUpperCase(); const presetColor = normalizeColorForCompare(p.filament_colour ?? ''); if (reqType && presetType && reqType === presetType) score += 10; if (reqColor && presetColor) { if (presetColor === reqColor) score += 5; else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2; } score += TIER_BONUS[tier]; // Demote printer-incompatible filaments (#1325): a penalty rather than a // hard skip so the pick still degrades gracefully if every filament // mismatches the selected printer. if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') { score -= 100; } if (best == null || score > best.score) { best = { ref: { source: p.source, id: p.id }, score }; } } } // Fall back to plain priority pick if every preset scored 0+tier (i.e. no // metadata matched). The fallback is exactly the single-color default — // first preset in the highest-priority non-empty tier. if (best == null) return pickDefault(by, 'filament'); return best.ref; } function toRefValue(ref: PresetRef | null): string { // The HTML ` setSliceAllPlates(e.target.checked)} disabled={isEnqueuing} className="cursor-pointer" /> {t('slice.allPlatesToggle', { count: totalPlateCount })} )} ); } function CloudStatusBanner({ status, cloudName = 'bambu', }: { status: SlicerCloudStatus; cloudName?: 'bambu' | 'orca'; }) { const { t } = useTranslation(); if (status === 'ok') 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' ? { not_authenticated: { key: 'slice.orcaCloud.notAuthenticated', fallback: 'Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets.', }, 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.', }, } : { not_authenticated: { key: 'slice.cloud.notAuthenticated', fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', }, 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, { tone: string; icon: typeof Cloud }> = { not_authenticated: { tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray', icon: Cloud, }, expired: { tone: 'border-amber-700/40 bg-amber-900/20 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 the uploaded // Slicer Bundles in 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: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' }, { key: 'local', label: 'slice.tier.local', fallback: 'Imported' }, { 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 ( ); } // Top-of-modal bundle picker. The "None" option leaves the user on the // cloud/local/standard tier path; selecting a bundle id flips the modal // into bundle dispatch mode (see SliceModal state above). interface BundlePickerProps { bundles: SlicerBundle[]; selectedId: string | null; onChange: (id: string | null) => void; disabled?: boolean; } function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) { const { t } = useTranslation(); return ( ); } // Plain-string dropdown used for bundle-mode process / filament selectors. // Bundles store presets as a flat list of names within their printer-tied // directory, so a ` onChange(e.target.value || null)} disabled={disabled || options.length === 0} className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50" > {options.map((name) => ( ))} ); }