SliceModal.tsx 43 KB

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