SliceModal.tsx 54 KB

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