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 { 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'; // Lookup priority: local → orca_cloud → cloud → standard. Local imports // outrank everything else because the user explicitly imported them for // this install; Orca Cloud comes next; Bambu Cloud after that; standard // (bundled) is the final fallback. The backend does NOT dedup tiers — // every group renders its full set so the user can pick a same-named // preset from a lower-priority source if they want to override the // auto-pick. const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', '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 = { local: 1.75, orca_cloud: 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(); // `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-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 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 ( ); }