SliceModal.tsx 54 KB

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