SliceModal.tsx 45 KB

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