SliceModal.tsx 74 KB

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