SliceModal.tsx 64 KB

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