SliceModal.tsx 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243
  1. import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
  2. import { useEffect, useMemo, useState } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  5. import {
  6. api,
  7. type PresetRef,
  8. type PresetSource,
  9. type SliceJobProgress,
  10. type SliceRequest,
  11. type SlicerCloudStatus,
  12. type UnifiedPreset,
  13. type UnifiedPresetsBySlot,
  14. type UnifiedPresetsResponse,
  15. } from '../api/client';
  16. import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
  17. import { useToast } from '../contexts/ToastContext';
  18. import { PlatePickerModal } from './PlatePickerModal';
  19. import type { DesignOverride, PlateFilament } from '../types/plates';
  20. import {
  21. presetCompatibility,
  22. buildCompatibilityIndex,
  23. EMPTY_COMPATIBILITY_INDEX,
  24. type PrinterCompatibilityIndex,
  25. } from '../utils/slicerPrinterMatch';
  26. import {
  27. findPreset,
  28. findPresetByName,
  29. pickDefault,
  30. pickFilamentForSlot,
  31. pickProcessDefault,
  32. type Slot,
  33. } from '../utils/slicePresetPicker';
  34. export type SliceSource =
  35. | { kind: 'libraryFile'; id: number; filename: string }
  36. | { kind: 'archive'; id: number; filename: string };
  37. interface SliceModalProps {
  38. source: SliceSource;
  39. onClose: () => void;
  40. }
  41. function toRefValue(ref: PresetRef | null): string {
  42. // The HTML `<select>` value space is flat strings; encode source + id so
  43. // the same preset name can live in multiple tiers without collision.
  44. return ref ? `${ref.source}:${ref.id}` : '';
  45. }
  46. function fromRefValue(raw: string): PresetRef | null {
  47. if (!raw) return null;
  48. const idx = raw.indexOf(':');
  49. if (idx < 0) return null;
  50. const source = raw.slice(0, idx) as PresetSource;
  51. const id = raw.slice(idx + 1);
  52. if (source !== 'orca_cloud' && source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
  53. return { source, id };
  54. }
  55. // Inline spinner for the filament-requirements query. The backend runs a
  56. // preview slice on first open of an unsliced project file (cached after);
  57. // on a complex multi-color model that's a real slice — multi-second to
  58. // multi-minute. The static "Analyzing plate filaments…" string left
  59. // users wondering whether anything was happening, so the spinner now
  60. // shows elapsed seconds, polls the sidecar's --pipe progress (via the
  61. // /slicer/preview-progress proxy) for live stage + percent, and after ~5s
  62. // surfaces a "this is a one-time slice — repeat opens are instant"
  63. // note so users don't worry it'll be slow forever.
  64. //
  65. // requestId: a UUID generated by the modal when the filament-requirements
  66. // fetch starts. Forwarded to the sidecar via the API call AND used here
  67. // to poll the matching progress snapshot. Same id, two consumers.
  68. function FilamentAnalysisSpinner({
  69. requestId,
  70. sourceName,
  71. }: {
  72. requestId: string;
  73. sourceName: string;
  74. }) {
  75. const { t } = useTranslation();
  76. const { showPersistentToast, dismissToast } = useToast();
  77. const [elapsed, setElapsed] = useState(0);
  78. const [progress, setProgress] = useState<SliceJobProgress | null>(null);
  79. // Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
  80. let prettyName = sourceName;
  81. try {
  82. prettyName = decodeURIComponent(sourceName);
  83. } catch {
  84. /* keep raw on malformed encoding */
  85. }
  86. // Elapsed-time tick.
  87. useEffect(() => {
  88. const startedAt = Date.now();
  89. const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
  90. return () => clearInterval(id);
  91. }, []);
  92. // Progress polling — once per second while the spinner is mounted.
  93. // Mirrors the slice-job tracker's cadence. Sidecar 404s during the
  94. // race window between fetch start and progressStore.start() are
  95. // swallowed by the API method (returns null) so we keep polling.
  96. useEffect(() => {
  97. let cancelled = false;
  98. const id = setInterval(async () => {
  99. if (cancelled) return;
  100. const snap = await api.getPreviewSliceProgress(requestId);
  101. if (!cancelled && snap) setProgress(snap);
  102. }, 1000);
  103. return () => {
  104. cancelled = true;
  105. clearInterval(id);
  106. };
  107. }, [requestId]);
  108. // Mirror the spinner's contents into a persistent toast so the user
  109. // sees activity even when their cursor is elsewhere on the page.
  110. // Dismissed in the parent's effect when the requirements arrive.
  111. const toastId = `slice-preview-${requestId}`;
  112. useEffect(() => {
  113. const hasUseful = progress && progress.stage && progress.total_percent > 0;
  114. const elapsedStr = formatElapsed(elapsed);
  115. if (hasUseful) {
  116. showPersistentToast(
  117. toastId,
  118. t(
  119. 'slice.previewWithProgress',
  120. 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
  121. {
  122. name: prettyName,
  123. stage: progress!.stage,
  124. percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
  125. elapsed: elapsedStr,
  126. },
  127. ),
  128. 'loading',
  129. );
  130. } else {
  131. showPersistentToast(
  132. toastId,
  133. t('slice.previewToast', {
  134. name: prettyName,
  135. elapsed: elapsedStr,
  136. }),
  137. 'loading',
  138. );
  139. }
  140. return () => {
  141. dismissToast(toastId);
  142. };
  143. }, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
  144. const stage = progress?.stage;
  145. const percent = progress?.total_percent;
  146. const inlineLabel =
  147. stage && typeof percent === 'number' && percent > 0
  148. ? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
  149. : t('slice.analyzingPlateFilaments');
  150. return (
  151. <div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
  152. <div className="flex items-center gap-2">
  153. <Loader2 className="w-4 h-4 animate-spin" />
  154. {inlineLabel}
  155. <span className="text-xs tabular-nums">{elapsed}s</span>
  156. </div>
  157. {elapsed >= 5 && (
  158. <div className="text-xs text-bambu-gray/70 pl-6">
  159. {t(
  160. 'slice.analyzingPlateFilamentsHint',
  161. 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
  162. )}
  163. </div>
  164. )}
  165. </div>
  166. );
  167. }
  168. function formatElapsed(seconds: number): string {
  169. const s = Math.max(0, Math.floor(seconds));
  170. if (s < 60) return `${s}s`;
  171. const m = Math.floor(s / 60);
  172. const remS = s % 60;
  173. if (m < 60) return `${m}m ${remS}s`;
  174. const h = Math.floor(m / 60);
  175. const remM = m % 60;
  176. return `${h}h ${remM}m`;
  177. }
  178. // Render a slicer parameter value for the design-settings list. Bambu's process
  179. // schema stores everything as strings or arrays of strings, so this only has to
  180. // flatten arrays and keep scalars readable — no unit or type interpretation,
  181. // which would rot against every slicer release.
  182. function formatDesignValue(value: unknown): string {
  183. if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
  184. if (value == null) return '';
  185. return String(value);
  186. }
  187. export function SliceModal({ source, onClose }: SliceModalProps) {
  188. const { t } = useTranslation();
  189. const { trackJob } = useSliceJobTracker();
  190. const queryClient = useQueryClient();
  191. const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
  192. const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
  193. // One filament ref per plate slot, in plate order. For STL / single-plate /
  194. // single-color sources this is a one-element array; multi-color 3MFs get one
  195. // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
  196. // each slot from the source plate's required (type, colour).
  197. const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
  198. const [errorMessage, setErrorMessage] = useState<string | null>(null);
  199. // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
  200. // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
  201. // the user picks one (or implicitly for single-plate sources).
  202. const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
  203. // "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
  204. // ``--slice 0`` to the BS CLI, producing a single output 3MF whose
  205. // ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
  206. // one archive, one file, all plates. Distinct from the per-plate
  207. // ``selectedPlate`` mode (which slices just that one plate). Filament
  208. // selection in this mode covers every slot the project defines, not
  209. // just the slots the currently-visible plate happens to use — see
  210. // ``allProjectFilamentSlots`` below.
  211. const [sliceAllPlates, setSliceAllPlates] = useState(false);
  212. // Build-plate override (#1337). null = inherit from the process preset
  213. // (the default). Set to a canonical slicer enum value to patch
  214. // curr_bed_type into the resolved process JSON before slicing — needed
  215. // because the process preset's default plate (typically "Cool Plate") is
  216. // incompatible with high-temp filaments like ABS / ASA / PC, and the
  217. // user had no way to switch plates without cloning the preset.
  218. const [bedType, setBedType] = useState<string | null>(null);
  219. // "Slice as designed" (#2611). When on, the backend honours the source
  220. // 3MF's embedded project_settings.config (the designer's own wall count,
  221. // infill, etc.) instead of the picked process/filament profiles. Only
  222. // offered when the picked printer matches the design's target model —
  223. // see canUseEmbedded below.
  224. const [useEmbedded, setUseEmbedded] = useState(false);
  225. // #2622: process settings the designer changed away from the stock preset,
  226. // carried onto the picked process profile so a cross-printer re-slice keeps
  227. // the model's intended wall count / infill / first layer instead of losing
  228. // them to --load-settings. Keys the file flags as machine-coupled (speeds,
  229. // accelerations, prime-tower geometry) are listed but start unticked — those
  230. // were tuned for the designer's printer and can be plain wrong on another.
  231. const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
  232. const [designExpanded, setDesignExpanded] = useState(false);
  233. // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
  234. // with one pick, or save the current selection as a new pipeline.
  235. const pipelinesQuery = useQuery({
  236. queryKey: ['slicer-pipelines'],
  237. queryFn: () => api.listSlicerPipelines(),
  238. staleTime: 60_000,
  239. });
  240. const [savePipelineOpen, setSavePipelineOpen] = useState(false);
  241. const [pipelineDraftName, setPipelineDraftName] = useState('');
  242. const { showToast } = useToast();
  243. const createPipelineMutation = useMutation({
  244. mutationFn: (body: {
  245. name: string;
  246. printer_preset: PresetRef;
  247. process_preset: PresetRef;
  248. filament_presets: PresetRef[];
  249. bed_type: string | null;
  250. }) => api.createSlicerPipeline(body),
  251. onSuccess: () => {
  252. queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
  253. showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
  254. setSavePipelineOpen(false);
  255. setPipelineDraftName('');
  256. },
  257. onError: (err: Error) => {
  258. showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
  259. },
  260. });
  261. const platesQuery = useQuery({
  262. queryKey: ['slicePlates', source.kind, source.id],
  263. queryFn: async () => {
  264. if (source.kind === 'libraryFile') {
  265. return api.getLibraryFilePlates(source.id);
  266. }
  267. return api.getArchivePlates(source.id);
  268. },
  269. staleTime: 60_000,
  270. });
  271. const isMultiPlate =
  272. !!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
  273. // Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
  274. // at submit time so the backend's existing default behaviour is preserved.
  275. const needsPlatePicker = isMultiPlate && selectedPlate == null;
  276. // Per-plate filament requirements via the same endpoint the print/schedule
  277. // modal uses. Reusing it here keeps the SliceModal honest with whatever
  278. // logic that endpoint applies (slice_info parsing, future enhancements for
  279. // unsliced project files, dual-nozzle fields, etc.) instead of duplicating
  280. // extraction. plate_id is always sent: single-plate falls through to plate
  281. // 1 server-side; multi-plate uses the user's pick.
  282. const effectivePlateId = selectedPlate ?? 1;
  283. // Generate a request_id per (source, plate) pair so the backend's
  284. // preview-slice and the FilamentAnalysisSpinner's progress poll share
  285. // the same id. useMemo keeps it stable across renders within the same
  286. // pair; switching plates regenerates so a stale poll doesn't bleed
  287. // progress between plates.
  288. const previewRequestId = useMemo(() => {
  289. const random =
  290. typeof crypto !== 'undefined' && 'randomUUID' in crypto
  291. ? crypto.randomUUID()
  292. : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  293. // Tag the id with the (source, plate) so logs/Network panel show which
  294. // pair owns the poll. Also lets the lint rule see the deps in use.
  295. return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
  296. }, [source.kind, source.id, effectivePlateId]);
  297. const filamentReqsQuery = useQuery({
  298. queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
  299. queryFn: async () => {
  300. // `fullSlots`: one row per project slot, not only the ones this plate
  301. // prints with. The list below is positional all the way to the CLI's
  302. // filament_N.json parts, so a source whose only used slot is 4 has to
  303. // present four rows — otherwise the single pick binds to slot 1 and
  304. // slot 4 slices with whatever the source had baked in (#2712). The
  305. // unused rows stay disabled exactly as before.
  306. if (source.kind === 'libraryFile') {
  307. return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
  308. }
  309. return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
  310. },
  311. enabled: !needsPlatePicker,
  312. staleTime: 60_000,
  313. });
  314. // Filament slot list for the active plate. Falls back to one synthetic slot
  315. // for STL/STEP and any "no metadata available" case so the modal still
  316. // works (single dropdown, mono-color slice). In ``sliceAllPlates`` mode
  317. // we keep the same slot list (the backend already returns every project
  318. // slot via ``extract_project_filaments_from_3mf``'s fallback path when
  319. // slice_info doesn't carry per-plate filaments) but override every
  320. // slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
  321. // drop the "— not used by this plate" suffix and the dropdowns become
  322. // selectable. Across the whole project, every defined slot IS used by
  323. // at least one plate, so this is correct in slice-all mode.
  324. const filamentSlots = useMemo<PlateFilament[]>(() => {
  325. const reqs = filamentReqsQuery.data?.filaments ?? [];
  326. const base: PlateFilament[] =
  327. reqs.length > 0
  328. ? (reqs as PlateFilament[])
  329. : [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
  330. if (sliceAllPlates) {
  331. return base.map((slot) => ({ ...slot, used_in_plate: true }));
  332. }
  333. return base;
  334. }, [sliceAllPlates, filamentReqsQuery.data]);
  335. const presetsQuery = useQuery({
  336. queryKey: ['slicerPresets'],
  337. queryFn: () => api.getSlicerPresets(),
  338. staleTime: 60_000,
  339. // Don't fetch presets while the plate picker is on screen — saves a
  340. // round-trip if the user cancels out of the plate step.
  341. enabled: !platesQuery.isLoading && !needsPlatePicker,
  342. });
  343. // Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
  344. // bundled cache for one call so users who deleted a preset in Bambu
  345. // Studio / Bambu Handy see the change immediately (#1581). The cache write
  346. // inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
  347. // fresh result so subsequent normal callers still get cached responses.
  348. const [isRefreshing, setIsRefreshing] = useState(false);
  349. const handleRefreshPresets = async () => {
  350. if (isRefreshing) return;
  351. setIsRefreshing(true);
  352. try {
  353. const fresh = await api.getSlicerPresets({ refresh: true });
  354. queryClient.setQueryData(['slicerPresets'], fresh);
  355. } catch {
  356. // Fall through to invalidate so React Query retries via its normal
  357. // path on the next render — surfacing the failure through the existing
  358. // presetsQuery.isError banner instead of duplicating error UI here.
  359. queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
  360. } finally {
  361. setIsRefreshing(false);
  362. }
  363. };
  364. // Canonical Bambu printer-model registry — drives the @BBL <code> name
  365. // fallback in slicerPrinterMatch for cloud / standard presets (#1325).
  366. // Long staleTime: the registry only changes across backend releases.
  367. const printerModelsQuery = useQuery({
  368. queryKey: ['slicerPrinterModels'],
  369. queryFn: api.getSlicerPrinterModels,
  370. staleTime: Infinity,
  371. });
  372. // Selected-printer context for the process / filament filter (#1325).
  373. const selectedPrinterName = useMemo<string | null>(() => {
  374. if (!presetsQuery.data || !printerPreset) return null;
  375. return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
  376. }, [presetsQuery.data, printerPreset]);
  377. // Compatibility ground truth: the slicer's own `compatible_printers` list
  378. // on local-imported presets, plus the @BBL <code> name fallback for cloud
  379. // / standard presets via the backend Bambu printer-model registry.
  380. const compatIndex = useMemo<PrinterCompatibilityIndex>(
  381. () => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
  382. [printerModelsQuery.data],
  383. );
  384. // Printer / process preset names the source 3MF was prepared with. The
  385. // plates query resolves before the presets query (the latter is gated on
  386. // it), so these are known by the time the pre-pick effects run.
  387. const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
  388. const designOverrides = useMemo<DesignOverride[]>(
  389. () => platesQuery.data?.design_overrides ?? [],
  390. [platesQuery.data],
  391. );
  392. const embeddedProcess = platesQuery.data?.embedded_process ?? null;
  393. // "Slice as designed" is offered only when the source carries embedded
  394. // settings (a real project 3MF, not an STL) AND the picked printer matches
  395. // the design's target model. The match gate is load-bearing: honouring
  396. // embedded settings for a different model would place the model on the
  397. // wrong bed. Names come from the same preset namespace, so a normalised
  398. // (strip "# " prefix, case-fold) equality is enough.
  399. const canUseEmbedded = useMemo<boolean>(() => {
  400. if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
  401. const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
  402. return norm(selectedPrinterName) === norm(embeddedPrinter);
  403. }, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
  404. // Drop back to profile slicing whenever the toggle stops being offered
  405. // (e.g. the user switches to a printer that doesn't match the design).
  406. useEffect(() => {
  407. if (!canUseEmbedded) setUseEmbedded(false);
  408. }, [canUseEmbedded]);
  409. // Pre-tick the printer-independent design settings once the source's list
  410. // arrives. Machine-coupled keys stay off until the user opts in explicitly.
  411. useEffect(() => {
  412. setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
  413. }, [designOverrides]);
  414. // Printer pre-pick: defaults to the printer the 3MF was prepared for when
  415. // that preset is available, else the first listed printer. Runs once when
  416. // presets first arrive; later re-renders preserve any manual choice.
  417. useEffect(() => {
  418. const data = presetsQuery.data;
  419. if (!data) return;
  420. if (printerPreset == null) {
  421. setPrinterPreset(
  422. findPresetByName(data, 'printer', embeddedPrinter) ?? pickDefault(data, 'printer'),
  423. );
  424. }
  425. // eslint-disable-next-line react-hooks/exhaustive-deps
  426. }, [presetsQuery.data, embeddedPrinter]);
  427. // Process pre-pick / re-pick (#1325): defaults to a process compatible with
  428. // the selected printer, and re-defaults when a printer change leaves the
  429. // current process incompatible. A compatible or unknown manual pick is kept.
  430. useEffect(() => {
  431. const data = presetsQuery.data;
  432. if (!data) return;
  433. setProcessPreset((current) => {
  434. if (current) {
  435. const p = findPreset(data, current, 'process');
  436. if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
  437. return current;
  438. }
  439. }
  440. return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
  441. });
  442. }, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);
  443. // Filament pre-pick: re-runs when the active filament-slot count changes
  444. // (plate selection, single-plate metadata arriving) or the selected printer
  445. // changes. Each slot scores every available filament preset against the
  446. // slot's required (type, colour); an existing pick (incl. a user override)
  447. // is kept as long as it's still compatible with the selected printer, while
  448. // null slots and printer-incompatible picks are re-picked (#1325).
  449. useEffect(() => {
  450. const data = presetsQuery.data;
  451. if (!data) return;
  452. setFilamentPresets((current) => {
  453. return filamentSlots.map((slot, i) => {
  454. const cur = current[i] ?? null;
  455. if (cur) {
  456. const p = findPreset(data, cur, 'filament');
  457. if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
  458. return cur;
  459. }
  460. }
  461. return pickFilamentForSlot(
  462. data,
  463. { type: slot.type, color: slot.color },
  464. selectedPrinterName,
  465. compatIndex,
  466. );
  467. });
  468. });
  469. }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
  470. const enqueueMutation = useMutation({
  471. mutationFn: async (plate: number | null) => {
  472. const body = buildSliceBody(plate);
  473. if (source.kind === 'libraryFile') {
  474. return api.sliceLibraryFile(source.id, body);
  475. }
  476. return api.sliceArchive(source.id, body);
  477. },
  478. onSuccess: (enqueue) => {
  479. trackJob(enqueue.job_id, source.kind, source.filename);
  480. onClose();
  481. },
  482. onError: (err: unknown) => {
  483. const msg = err instanceof Error ? err.message : String(err);
  484. setErrorMessage(msg);
  485. },
  486. });
  487. // Body builder shared by the single-plate and slice-all paths. ``plate``
  488. // is the 1-indexed plate number to slice, or ``null`` for STL / single-
  489. // plate 3MF sources where the field is omitted entirely.
  490. function buildSliceBody(plate: number | null): SliceRequest {
  491. if (
  492. !printerPreset ||
  493. !processPreset ||
  494. filamentPresets.length === 0 ||
  495. filamentPresets.some((r) => r == null)
  496. ) {
  497. throw new Error(t('slice.allPresetsRequired'));
  498. }
  499. return {
  500. printer_preset: printerPreset,
  501. process_preset: processPreset,
  502. filament_preset: filamentPresets[0] as PresetRef,
  503. filament_presets: filamentPresets as PresetRef[],
  504. ...(plate != null ? { plate } : {}),
  505. ...(bedType != null ? { bed_type: bedType } : {}),
  506. // The preset refs above are still sent (the backend validator requires
  507. // them) but go unused when this flag is set — the slicer falls back on
  508. // the file's embedded project_settings.config instead.
  509. ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
  510. // Carried design settings are patched onto the resolved process JSON,
  511. // which the embedded-settings path never sends — so they are mutually
  512. // exclusive by construction (#2622).
  513. ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
  514. };
  515. }
  516. // Slice button stays disabled until the preview slice / embedded-metadata
  517. // read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
  518. // has a picked profile.
  519. const isReady =
  520. printerPreset != null &&
  521. processPreset != null &&
  522. filamentReqsQuery.isSuccess &&
  523. filamentPresets.length > 0 &&
  524. filamentPresets.every((r) => r != null);
  525. const isEnqueuing = enqueueMutation.isPending;
  526. const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
  527. const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
  528. // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
  529. // entire flow (matches the existing PlatePickerModal contract used by the
  530. // archive g-code-viewer entry point).
  531. if (needsPlatePicker && platesQuery.data) {
  532. return (
  533. <PlatePickerModal
  534. plates={platesQuery.data.plates}
  535. onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
  536. onClose={onClose}
  537. />
  538. );
  539. }
  540. // Step 2 (or only step for single-plate / non-3MF / load-failure): preset
  541. // picker. While the plates query is in-flight we still render the shell
  542. // because the presets query is gated on it; the loader covers both.
  543. return (
  544. <div
  545. className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
  546. onClick={() => {
  547. if (!isEnqueuing) onClose();
  548. }}
  549. >
  550. <div
  551. className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
  552. onClick={(e) => e.stopPropagation()}
  553. >
  554. {/* Header */}
  555. <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
  556. <div className="min-w-0">
  557. <h3 className="text-white font-medium flex items-center gap-2">
  558. <Cog className="w-4 h-4" />
  559. {t('slice.title')}
  560. </h3>
  561. <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
  562. {source.filename}
  563. {selectedPlate != null
  564. ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
  565. : ''}
  566. </p>
  567. </div>
  568. <button
  569. onClick={onClose}
  570. disabled={isEnqueuing}
  571. className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
  572. aria-label={t('common.close')}
  573. >
  574. <X className="w-5 h-5" />
  575. </button>
  576. </div>
  577. {/* Body */}
  578. <div className="flex-1 overflow-y-auto p-4 space-y-4">
  579. {/* Preset listing loader — printer/process dropdowns can't render
  580. without it. Plate query reuses the same spinner since it's
  581. also blocking. */}
  582. {(platesQuery.isLoading || presetsQuery.isLoading) && (
  583. <div className="flex items-center gap-2 text-bambu-gray text-sm">
  584. <Loader2 className="w-4 h-4 animate-spin" />
  585. {t('slice.loadingPresets')}
  586. </div>
  587. )}
  588. {presetsQuery.isError && (
  589. <div className="text-sm text-red-700 dark:text-red-400" role="alert">
  590. {t(
  591. 'slice.presetsLoadFailed',
  592. 'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
  593. )}
  594. </div>
  595. )}
  596. {presetsQuery.data && (
  597. <>
  598. <div className="flex items-start justify-between gap-2">
  599. <div className="flex-1 space-y-2">
  600. <CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
  601. <CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
  602. </div>
  603. <button
  604. type="button"
  605. onClick={handleRefreshPresets}
  606. disabled={isRefreshing || isEnqueuing}
  607. className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
  608. title={t('slice.refreshPresetsTitle')}
  609. aria-label={t('slice.refreshPresets')}
  610. >
  611. <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
  612. {t('slice.refreshPresets')}
  613. </button>
  614. </div>
  615. {/* CloudStatusBanner above is hidden via flex-1 wrapper when
  616. status === 'ok' (returns null in that case), but the Refresh
  617. button stays visible regardless so users can pick up cloud /
  618. bundled changes even when sign-in is healthy. */}
  619. {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
  620. four slots, or save the current selection as a pipeline.
  621. Pipelines are managed in Settings → Workflow → Pipelines. */}
  622. <div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
  623. <span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
  624. <Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
  625. </span>
  626. <select
  627. value=""
  628. disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
  629. onChange={(e) => {
  630. const id = parseInt(e.target.value, 10);
  631. if (Number.isNaN(id)) return;
  632. const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
  633. if (!picked) return;
  634. // Apply slot state. The filament list is right-padded from
  635. // current state so a pipeline with fewer entries than the
  636. // current source's slot count keeps the existing tail.
  637. setPrinterPreset(picked.printer_preset);
  638. setProcessPreset(picked.process_preset);
  639. setBedType(picked.bed_type);
  640. setFilamentPresets((current) => {
  641. const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
  642. for (let i = 0; i < next.length; i++) {
  643. if (i < picked.filament_presets.length) {
  644. next[i] = picked.filament_presets[i];
  645. }
  646. }
  647. return next;
  648. });
  649. showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
  650. // Reset the dropdown so the user can re-apply the same
  651. // pipeline if needed (selects don't fire onChange when
  652. // value reselects the same option).
  653. e.target.value = '';
  654. }}
  655. 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]"
  656. aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
  657. >
  658. <option value="">
  659. {(pipelinesQuery.data?.pipelines.length ?? 0) === 0
  660. ? t('slice.pipelines.empty', 'No saved pipelines')
  661. : t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
  662. </option>
  663. {pipelinesQuery.data?.pipelines.map((p) => (
  664. <option key={p.id} value={p.id}>
  665. {p.name}
  666. </option>
  667. ))}
  668. </select>
  669. {!savePipelineOpen ? (
  670. <button
  671. type="button"
  672. onClick={() => {
  673. setPipelineDraftName('');
  674. setSavePipelineOpen(true);
  675. }}
  676. disabled={
  677. isEnqueuing ||
  678. !printerPreset ||
  679. !processPreset ||
  680. filamentPresets.length === 0 ||
  681. filamentPresets.some((f) => f === null)
  682. }
  683. className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
  684. title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
  685. >
  686. {t('slice.pipelines.saveButton', 'Save as pipeline')}
  687. </button>
  688. ) : (
  689. <div className="flex items-center gap-1 flex-1 min-w-[16ch]">
  690. <input
  691. autoFocus
  692. value={pipelineDraftName}
  693. onChange={(e) => setPipelineDraftName(e.target.value)}
  694. placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
  695. aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
  696. className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  697. />
  698. <button
  699. type="button"
  700. onClick={() => {
  701. const trimmed = pipelineDraftName.trim();
  702. if (!trimmed || !printerPreset || !processPreset) return;
  703. const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
  704. if (nonNull.length === 0) return;
  705. createPipelineMutation.mutate({
  706. name: trimmed,
  707. printer_preset: printerPreset,
  708. process_preset: processPreset,
  709. filament_presets: nonNull,
  710. bed_type: bedType,
  711. });
  712. }}
  713. disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
  714. className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
  715. >
  716. {createPipelineMutation.isPending ? (
  717. <Loader2 className="w-3 h-3 animate-spin" />
  718. ) : (
  719. t('common.save', 'Save')
  720. )}
  721. </button>
  722. <button
  723. type="button"
  724. onClick={() => {
  725. setSavePipelineOpen(false);
  726. setPipelineDraftName('');
  727. }}
  728. className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
  729. >
  730. {t('common.cancel', 'Cancel')}
  731. </button>
  732. </div>
  733. )}
  734. </div>
  735. <PresetDropdown
  736. label={t('slice.printer')}
  737. slot="printer"
  738. data={presetsQuery.data}
  739. value={printerPreset}
  740. onChange={setPrinterPreset}
  741. // Locked in embedded mode too: the picked printer is unused on
  742. // the embedded-settings path, and changing it away from the
  743. // design's target would drop canUseEmbedded and yank the toggle
  744. // out from under the user (#2611).
  745. disabled={isEnqueuing || useEmbedded}
  746. />
  747. {/* "Slice as designed" (#2611): honour the file's embedded
  748. settings instead of the picked process/filament. Offered
  749. only when the picked printer matches the design's target. */}
  750. {canUseEmbedded && (
  751. <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
  752. <input
  753. type="checkbox"
  754. checked={useEmbedded}
  755. onChange={(e) => setUseEmbedded(e.target.checked)}
  756. disabled={isEnqueuing}
  757. className="mt-0.5 cursor-pointer"
  758. />
  759. <span>
  760. {t('slice.useEmbedded')}
  761. <span className="block text-xs text-bambu-gray/70">
  762. {t('slice.useEmbeddedHint')}
  763. </span>
  764. </span>
  765. </label>
  766. )}
  767. <PresetDropdown
  768. label={t('slice.process')}
  769. slot="process"
  770. data={presetsQuery.data}
  771. value={processPreset}
  772. onChange={setProcessPreset}
  773. disabled={isEnqueuing || useEmbedded}
  774. selectedPrinterName={selectedPrinterName}
  775. compatIndex={compatIndex}
  776. />
  777. {/* Designer's process tweaks (#2622). BambuStudio records which
  778. keys deviate from the stock preset in the 3MF itself, so a
  779. re-slice for another printer can carry them instead of
  780. flattening them under --load-settings. Hidden entirely when
  781. the source lists none, and disabled in embedded mode where
  782. the process JSON these patch is never sent. */}
  783. {designOverrides.length > 0 && (
  784. <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
  785. <button
  786. type="button"
  787. onClick={() => setDesignExpanded((v) => !v)}
  788. className="flex w-full items-center justify-between gap-2 text-left"
  789. >
  790. <span className="text-sm text-white">
  791. {t('slice.designSettings')}
  792. <span className="block text-xs text-bambu-gray/70">
  793. {t('slice.designSettingsHint', { count: designOverrides.length })}
  794. </span>
  795. </span>
  796. <span className="shrink-0 text-xs text-bambu-gray">
  797. {t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
  798. </span>
  799. </button>
  800. {designExpanded && (
  801. <div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
  802. {designOverrides.map((o) => (
  803. <label
  804. key={o.key}
  805. className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
  806. >
  807. <input
  808. type="checkbox"
  809. checked={designKeys.has(o.key)}
  810. disabled={isEnqueuing || useEmbedded}
  811. onChange={(e) => {
  812. setDesignKeys((prev) => {
  813. const next = new Set(prev);
  814. if (e.target.checked) next.add(o.key);
  815. else next.delete(o.key);
  816. return next;
  817. });
  818. }}
  819. className="mt-0.5 shrink-0 cursor-pointer"
  820. />
  821. <span className="min-w-0 flex-1">
  822. <span className="font-mono text-bambu-gray">{o.key}</span>
  823. <span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
  824. {o.printer_coupled && (
  825. <span
  826. className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
  827. title={t('slice.designSettingsPrinterCoupledHint')}
  828. >
  829. {t('slice.designSettingsPrinterCoupled')}
  830. </span>
  831. )}
  832. </span>
  833. </label>
  834. ))}
  835. </div>
  836. )}
  837. </div>
  838. )}
  839. {/* Bed-type override (#1337). Always visible, always enabled.
  840. The backend patches curr_bed_type on the resolved process
  841. JSON before forwarding to the sidecar. */}
  842. {/* Bed-type patches curr_bed_type onto the resolved process
  843. JSON, which the embedded-settings path never sends — so it
  844. has no effect there and is disabled to avoid implying it
  845. does. */}
  846. <BedTypeDropdown
  847. value={bedType}
  848. onChange={setBedType}
  849. disabled={isEnqueuing || useEmbedded}
  850. />
  851. {/* Filament reqs may need a server-side preview-slice for
  852. unsliced project files (single-pass, then cached). Show a
  853. scoped spinner so the user sees the printer/process
  854. dropdowns instead of an opaque "Loading presets…" wait. */}
  855. {filamentReqsQuery.isLoading ? (
  856. <FilamentAnalysisSpinner
  857. requestId={previewRequestId}
  858. sourceName={source.filename}
  859. />
  860. ) : (
  861. filamentSlots.map((slot, idx) => {
  862. // Slots flagged by the backend as not used by the
  863. // picked plate are auto-picked from project metadata
  864. // and disabled — the slicer CLI still needs a
  865. // profile per project slot, but the user shouldn't
  866. // have to think about slots their plate doesn't
  867. // paint with. used_in_plate defaults to true when
  868. // missing (sliced 3MFs and the no-flag legacy path).
  869. const isUsed = slot.used_in_plate !== false;
  870. const baseLabel =
  871. filamentSlots.length > 1
  872. ? t('slice.filamentSlot', {
  873. index: idx + 1,
  874. type: slot.type,
  875. })
  876. : t('slice.filament');
  877. const label = isUsed
  878. ? baseLabel
  879. : `${baseLabel} ${t('slice.notUsedByPlate')}`;
  880. return (
  881. <PresetDropdown
  882. key={`filament-${idx}`}
  883. label={label}
  884. slot="filament"
  885. data={presetsQuery.data}
  886. value={filamentPresets[idx] ?? null}
  887. onChange={(ref) =>
  888. setFilamentPresets((current) => {
  889. const next = current.length === filamentSlots.length
  890. ? [...current]
  891. : filamentSlots.map((_, i) => current[i] ?? null);
  892. next[idx] = ref;
  893. return next;
  894. })
  895. }
  896. disabled={isEnqueuing || !isUsed || useEmbedded}
  897. swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
  898. selectedPrinterName={selectedPrinterName}
  899. compatIndex={compatIndex}
  900. />
  901. );
  902. })
  903. )}
  904. </>
  905. )}
  906. {errorMessage && (
  907. <div className="text-sm text-red-700 dark:text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
  908. {errorMessage}
  909. </div>
  910. )}
  911. </div>
  912. {/* Footer */}
  913. <div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
  914. <button
  915. type="button"
  916. onClick={onClose}
  917. disabled={isEnqueuing}
  918. className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
  919. >
  920. {t('common.cancel')}
  921. </button>
  922. {canSliceAll && (
  923. <label
  924. className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
  925. title={t('slice.actionAllTitle', { count: totalPlateCount })}
  926. >
  927. <input
  928. type="checkbox"
  929. checked={sliceAllPlates}
  930. onChange={(e) => setSliceAllPlates(e.target.checked)}
  931. disabled={isEnqueuing}
  932. className="cursor-pointer"
  933. />
  934. {t('slice.allPlatesToggle', { count: totalPlateCount })}
  935. </label>
  936. )}
  937. <button
  938. type="button"
  939. onClick={() => {
  940. setErrorMessage(null);
  941. // ``plate=0`` is the sidecar's "all plates" sentinel — passes
  942. // ``--slice 0`` to the BS CLI which produces a single 3MF
  943. // with one ``Metadata/plate_N.gcode`` entry per plate.
  944. const platePayload = sliceAllPlates ? 0 : selectedPlate;
  945. enqueueMutation.mutate(platePayload);
  946. }}
  947. disabled={!isReady || isEnqueuing}
  948. className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
  949. >
  950. {isEnqueuing ? (
  951. <>
  952. <Loader2 className="w-4 h-4 animate-spin" />
  953. {t('slice.enqueuing')}
  954. </>
  955. ) : sliceAllPlates ? (
  956. t('slice.actionAll', { count: totalPlateCount })
  957. ) : (
  958. t('slice.action')
  959. )}
  960. </button>
  961. </div>
  962. </div>
  963. </div>
  964. );
  965. }
  966. function CloudStatusBanner({
  967. status,
  968. cloudName = 'bambu',
  969. }: {
  970. status: SlicerCloudStatus;
  971. cloudName?: 'bambu' | 'orca';
  972. }) {
  973. const { t } = useTranslation();
  974. // `ok` is the happy path. `not_authenticated` is silenced too: a user who
  975. // hasn't signed in (or has explicitly logged out — #1712) doesn't need a
  976. // permanent nag at the top of the modal; sign-in lives on the Profiles
  977. // page if they want it. Only `expired` and `unreachable` surface — those
  978. // are real breakage states a previously-signed-in user needs to see.
  979. if (status === 'ok' || status === 'not_authenticated') return null;
  980. // Same status vocabulary for both Bambu and Orca Cloud — only the
  981. // user-facing text varies. The fallbacks below name each cloud explicitly
  982. // so the banner makes sense without translation when i18n hasn't been
  983. // updated for a new locale.
  984. const messages =
  985. cloudName === 'orca'
  986. ? {
  987. expired: {
  988. key: 'slice.orcaCloud.expired',
  989. fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
  990. },
  991. unreachable: {
  992. key: 'slice.orcaCloud.unreachable',
  993. fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
  994. },
  995. }
  996. : {
  997. expired: {
  998. key: 'slice.cloud.expired',
  999. fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
  1000. },
  1001. unreachable: {
  1002. key: 'slice.cloud.unreachable',
  1003. fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
  1004. },
  1005. };
  1006. const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
  1007. expired: {
  1008. tone: 'border-amber-300 dark:border-amber-700/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200',
  1009. icon: CloudOff,
  1010. },
  1011. unreachable: {
  1012. tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
  1013. icon: CloudOff,
  1014. },
  1015. };
  1016. const { tone, icon: Icon } = tones[status];
  1017. const { key, fallback } = messages[status];
  1018. return (
  1019. <div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
  1020. <Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
  1021. <span>{t(key, fallback)}</span>
  1022. </div>
  1023. );
  1024. }
  1025. // Build-plate options offered in the SliceModal (#1337). Values are the
  1026. // canonical strings the slicer's StaticPrintConfig validator accepts as
  1027. // `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
  1028. // enum; OrcaSlicer accepts the same set with a Supertack alias that users
  1029. // can target via the same dropdown if they re-import their presets.
  1030. const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
  1031. { value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
  1032. {
  1033. value: 'Cool Plate (SuperTack)',
  1034. labelKey: 'slice.bedType.coolPlateSuperTack',
  1035. fallback: 'Cool Plate SuperTack',
  1036. },
  1037. { value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
  1038. { value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
  1039. { value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
  1040. { value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
  1041. ];
  1042. function BedTypeDropdown({
  1043. value,
  1044. onChange,
  1045. disabled,
  1046. }: {
  1047. value: string | null;
  1048. onChange: (value: string | null) => void;
  1049. disabled?: boolean;
  1050. }) {
  1051. const { t } = useTranslation();
  1052. return (
  1053. <label className="block">
  1054. <span className="block text-xs text-bambu-gray mb-1">
  1055. {t('slice.bedType.label')}
  1056. </span>
  1057. <select
  1058. value={value ?? ''}
  1059. onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
  1060. disabled={disabled}
  1061. 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"
  1062. >
  1063. <option value="">{t('slice.bedType.auto')}</option>
  1064. {BED_TYPE_OPTIONS.map((opt) => (
  1065. <option key={opt.value} value={opt.value}>
  1066. {t(opt.labelKey, opt.fallback)}
  1067. </option>
  1068. ))}
  1069. </select>
  1070. </label>
  1071. );
  1072. }
  1073. interface PresetDropdownProps {
  1074. label: string;
  1075. slot: Slot;
  1076. data: UnifiedPresetsResponse;
  1077. value: PresetRef | null;
  1078. onChange: (ref: PresetRef | null) => void;
  1079. disabled?: boolean;
  1080. // Optional colour swatch shown next to the label — used for multi-color
  1081. // filament slots so the user can see at a glance which slot they're
  1082. // configuring against the source 3MF's per-slot colour.
  1083. swatchColor?: string;
  1084. // Selected printer context (#1325). When provided for a process / filament
  1085. // slot, presets that resolve to a different printer (per compatIndex) move
  1086. // into a trailing "Other printers" group instead of the main tier list.
  1087. selectedPrinterName?: string | null;
  1088. compatIndex?: PrinterCompatibilityIndex;
  1089. }
  1090. function PresetDropdown({
  1091. label,
  1092. slot,
  1093. data,
  1094. value,
  1095. onChange,
  1096. disabled,
  1097. swatchColor,
  1098. selectedPrinterName,
  1099. compatIndex,
  1100. }: PresetDropdownProps) {
  1101. const { t } = useTranslation();
  1102. // Tier sections (imported → cloud → standard), plus — for a process /
  1103. // filament slot with a selected printer — a trailing group of presets that
  1104. // resolve to a different printer (#1325). Compatibility-unknown presets
  1105. // stay in their tier, so a custom / untagged preset is never hidden, and
  1106. // empty sections collapse out.
  1107. const { sections, otherEntries } = useMemo(() => {
  1108. const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
  1109. { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
  1110. { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
  1111. { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
  1112. { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
  1113. ];
  1114. const filterByPrinter = slot !== 'printer';
  1115. const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
  1116. const other: UnifiedPreset[] = [];
  1117. for (const { key, label: lk, fallback } of tiers) {
  1118. const entries = (data[key] as UnifiedPresetsBySlot)[slot];
  1119. if (!filterByPrinter) {
  1120. if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
  1121. continue;
  1122. }
  1123. const compatible: UnifiedPreset[] = [];
  1124. for (const p of entries) {
  1125. if (
  1126. presetCompatibility(
  1127. p,
  1128. // filterByPrinter is true here, so slot is never 'printer'.
  1129. slot as 'process' | 'filament',
  1130. selectedPrinterName ?? null,
  1131. compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
  1132. ) === 'mismatch'
  1133. ) {
  1134. other.push(p);
  1135. } else {
  1136. compatible.push(p);
  1137. }
  1138. }
  1139. if (compatible.length > 0) {
  1140. compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
  1141. }
  1142. }
  1143. return { sections: compatSections, otherEntries: other };
  1144. }, [data, slot, t, selectedPrinterName, compatIndex]);
  1145. const totalEntries =
  1146. sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
  1147. return (
  1148. <label className="block">
  1149. <span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
  1150. {swatchColor && (
  1151. <span
  1152. className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
  1153. style={{ backgroundColor: swatchColor || 'transparent' }}
  1154. aria-hidden
  1155. />
  1156. )}
  1157. <span>{label}</span>
  1158. </span>
  1159. <select
  1160. value={toRefValue(value)}
  1161. onChange={(e) => onChange(fromRefValue(e.target.value))}
  1162. disabled={disabled || totalEntries === 0}
  1163. 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"
  1164. >
  1165. <option value="">
  1166. {totalEntries === 0
  1167. ? t('slice.noPresetsForSlot')
  1168. : t('slice.selectPreset')}
  1169. </option>
  1170. {sections.map((section) => (
  1171. <optgroup key={section.tierLabel} label={section.tierLabel}>
  1172. {section.entries.map((p) => (
  1173. <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
  1174. {p.name}
  1175. </option>
  1176. ))}
  1177. </optgroup>
  1178. ))}
  1179. {otherEntries.length > 0 && (
  1180. <optgroup label={t('slice.otherPrinters')}>
  1181. {otherEntries.map((p) => (
  1182. <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
  1183. {p.name}
  1184. </option>
  1185. ))}
  1186. </optgroup>
  1187. )}
  1188. </select>
  1189. </label>
  1190. );
  1191. }