PrinterProfilesSection.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import { Fragment, useMemo } from 'react';
  2. import { Check, Loader2, Printer as PrinterIcon, Sparkles } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type {
  5. CalibrationProfile,
  6. FilamentOption,
  7. PrinterProfilesSectionProps,
  8. PrinterWithCalibrations,
  9. } from './types';
  10. import { hotendKey, isMatchingCalibration, presetKey } from './utils';
  11. import { STANDARD_NOZZLE_DIAMETERS } from './constants';
  12. import { PresetPicker } from './PresetPicker';
  13. import { extractPresetModel, matchesPrinterModelSuffix } from '../../utils/slicerPrinterMatch';
  14. import { flowLabel, normaliseFlow } from '../../utils/nozzleFlow';
  15. import type { NozzleFlow } from '../../utils/nozzleFlow';
  16. /**
  17. * The spool form's Printers tab: which filament preset this spool uses on each
  18. * printer MODEL, and which K profile it uses on each individual hotend.
  19. *
  20. * The two halves are keyed differently on purpose. A slicer preset is a
  21. * property of the model -- "@BBL X1C" is the same preset on every X1C the user
  22. * owns -- so asking per machine would make them pick the identical value once
  23. * per printer. A K value is measured on one individual hotend, so it stays per
  24. * printer, per extruder, per nozzle diameter, which is what both K tables have
  25. * always been keyed on.
  26. *
  27. * Layout is a model list plus a detail pane rather than a stack of cards: the
  28. * list is fixed height whatever the fleet size, and the detail pane is bounded
  29. * by the largest single model instead of by the total number of printers.
  30. *
  31. * Nothing here reads `status.nozzles[]` positionally. Which array index belongs
  32. * to which extruder is genuinely unsettled in the backend (the H2/X2 and legacy
  33. * MQTT parsers disagree), so every nozzle fact on this screen comes from data
  34. * that names its own extruder: a calibration profile carries both its
  35. * `extruder_id` and its `nozzle_diameter`, and the per-model diameter list is a
  36. * deduplicated SET, which no ordering can get wrong.
  37. */
  38. interface ModelGroup {
  39. /**
  40. * Identifies the row. Prefixed so the two kinds cannot collide: `m:` for a
  41. * real model, `p:` for a printer that has not reported one. Distinct from
  42. * `model` because a model-less printer has no model to be keyed by, and two
  43. * of them would otherwise be the same row.
  44. */
  45. id: string;
  46. /** Empty when the printer has not reported a model. */
  47. model: string;
  48. printers: PrinterWithCalibrations[];
  49. /** Distinct nozzle diameters across this model's machines. Order-independent. */
  50. diameters: string[];
  51. }
  52. /**
  53. * The flow type fitted to one hotend, or null when the printer does not say.
  54. *
  55. * Same array and the same indexing as the diameter. Legacy printers put the
  56. * nozzle MATERIAL in this field ("hardened_steel"), which normaliseFlow reads
  57. * as "unknown" -- correct, since those machines never report a flow.
  58. */
  59. function fittedFlow(entry: PrinterWithCalibrations, extruder: number): NozzleFlow | null {
  60. const nozzles = entry.nozzles ?? [];
  61. const isDual = (entry.printer.nozzle_count ?? 1) > 1;
  62. const index = isDual && extruder > 0 ? extruder : 0;
  63. return normaliseFlow(nozzles[index]?.nozzle_type) ?? normaliseFlow(nozzles[0]?.nozzle_type);
  64. }
  65. function distinctDiameters(entry: PrinterWithCalibrations): string[] {
  66. const seen = new Set<string>();
  67. for (const nozzle of entry.nozzles ?? []) {
  68. const raw = (nozzle?.nozzle_diameter ?? '').trim();
  69. if (raw && parseFloat(raw) > 0) seen.add(raw);
  70. }
  71. for (const cal of entry.calibrations) {
  72. const raw = (cal.nozzle_diameter ?? '').trim();
  73. if (raw && parseFloat(raw) > 0) seen.add(raw);
  74. }
  75. return Array.from(seen).sort((a, b) => parseFloat(a) - parseFloat(b));
  76. }
  77. /**
  78. * The hotend columns for one printer, in the order they sit on the machine.
  79. *
  80. * Extruder 0 is the RIGHT hotend and 1 is the left, so a left-to-right table
  81. * reads [1, 0]. A single-nozzle machine has one unnamed column -- there is no
  82. * side to name.
  83. */
  84. function columnsOf(
  85. entry: PrinterWithCalibrations,
  86. labels: { left: string; right: string; single: string },
  87. ): Array<{ extruder: number; label: string }> {
  88. if ((entry.printer.nozzle_count ?? 1) > 1) {
  89. return [
  90. { extruder: 1, label: labels.left },
  91. { extruder: 0, label: labels.right },
  92. ];
  93. }
  94. return [{ extruder: 0, label: labels.single }];
  95. }
  96. export function PrinterProfilesSection({
  97. formData,
  98. printersWithCalibrations,
  99. filamentOptions,
  100. modelPresets,
  101. setModelPresets,
  102. selectedProfiles,
  103. setSelectedProfiles,
  104. selectedGroupId,
  105. setSelectedGroupId,
  106. printerModels,
  107. isLoading = false,
  108. }: PrinterProfilesSectionProps) {
  109. const { t } = useTranslation();
  110. // Group the fleet by model. A printer whose model the backend has not
  111. // reported is grouped under its own name rather than dropped -- it still has
  112. // K profiles worth setting, and the preset row is disabled for it below.
  113. const groups = useMemo<ModelGroup[]>(() => {
  114. const byModel = new Map<string, PrinterWithCalibrations[]>();
  115. const modelless: PrinterWithCalibrations[] = [];
  116. for (const entry of printersWithCalibrations) {
  117. const model = (entry.printer.model || '').trim();
  118. if (!model) {
  119. modelless.push(entry);
  120. continue;
  121. }
  122. const list = byModel.get(model);
  123. if (list) list.push(entry);
  124. else byModel.set(model, [entry]);
  125. }
  126. const grouped = Array.from(byModel.entries())
  127. .map(([model, printers]) => ({
  128. id: `m:${model}`,
  129. model,
  130. printers,
  131. // Every standard size, plus anything unusual this model reports as
  132. // fitted. Not just the fitted ones: a spool is configured once and
  133. // nozzles get swapped, so the user has to be able to set the preset
  134. // for a size they are about to change to.
  135. diameters: Array.from(
  136. new Set([...STANDARD_NOZZLE_DIAMETERS, ...printers.flatMap(distinctDiameters)]),
  137. ).sort((a, b) => parseFloat(a) - parseFloat(b)),
  138. }))
  139. .sort((a, b) => a.model.localeCompare(b.model));
  140. // A printer that has not reported its model gets a row of its own, last:
  141. // it has K profiles worth setting but cannot share a preset with anything,
  142. // and it must not be folded in with other model-less printers.
  143. return [
  144. ...grouped,
  145. ...modelless.map(entry => ({
  146. id: `p:${entry.printer.id}`,
  147. model: '',
  148. printers: [entry],
  149. diameters: Array.from(
  150. new Set([...STANDARD_NOZZLE_DIAMETERS, ...distinctDiameters(entry)]),
  151. ).sort((a, b) => parseFloat(a) - parseFloat(b)),
  152. })),
  153. ];
  154. }, [printersWithCalibrations]);
  155. const active = groups.find(g => g.id === selectedGroupId) ?? groups[0];
  156. /**
  157. * The presets worth offering for one model.
  158. *
  159. * A preset name carries the model it belongs to ("@BBL H2C", "@Bambu Lab X1
  160. * Carbon", or just "X1C ..." at the front), and offering an X1C preset for an
  161. * H2C is offering something that machine has no profile for -- which is the
  162. * bug this whole tab exists to fix. Uses the same matcher the Configure AMS
  163. * Slot modal filters with, so the two lists agree.
  164. *
  165. * Two things are deliberately kept: a preset whose model cannot be read at
  166. * all (many user-authored and Orca presets name no model), because hiding
  167. * what we cannot classify would hide most third-party profiles; and whatever
  168. * is currently selected, so an override already saved never silently
  169. * disappears from the control that shows it.
  170. */
  171. const optionsForModel = useMemo(() => {
  172. const cache = new Map<string, FilamentOption[]>();
  173. return (model: string, selected: string | undefined): FilamentOption[] => {
  174. if (!model) return filamentOptions;
  175. let list = cache.get(model);
  176. if (!list) {
  177. list = filamentOptions.filter(option => {
  178. const presetModel = extractPresetModel(option.name, printerModels ?? {});
  179. return !presetModel || matchesPrinterModelSuffix(presetModel, model);
  180. });
  181. cache.set(model, list);
  182. }
  183. if (selected && !list.some(o => o.code === selected)) {
  184. const kept = filamentOptions.find(o => o.code === selected);
  185. if (kept) return [kept, ...list];
  186. }
  187. return list;
  188. };
  189. }, [filamentOptions, printerModels]);
  190. // "Bambu PLA Matte", from whichever of the three fields are filled in. Blank
  191. // parts are skipped rather than padded with "Any brand", which reads as a
  192. // filter setting rather than as what the spool is.
  193. const identity = [formData.brand, formData.material, formData.subtype]
  194. .map(part => part.trim())
  195. .filter(Boolean)
  196. .join(' ');
  197. // The spool's own colour. rgba is RRGGBBAA; the alpha is dropped because a
  198. // translucent swatch would show the panel behind it rather than the filament.
  199. const swatch = /^[0-9A-Fa-f]{6,8}$/.test(formData.rgba)
  200. ? `#${formData.rgba.slice(0, 6)}`
  201. : 'var(--bambu-gray, #808080)';
  202. const columns = (entry: PrinterWithCalibrations) =>
  203. columnsOf(entry, {
  204. left: t('inventory.leftNozzle'),
  205. right: t('inventory.rightNozzle'),
  206. single: t('inventory.nozzle'),
  207. });
  208. const matchingFor = (entry: PrinterWithCalibrations) =>
  209. entry.printer.connected
  210. ? entry.calibrations.filter(cal => isMatchingCalibration(cal, formData))
  211. : [];
  212. /**
  213. * Grid cells that could hold a K profile but do not -- the left rail's
  214. * "unset" badge. Only cells with something to choose are counted: a size the
  215. * printer has no calibration for is not an unfinished decision.
  216. */
  217. const unsetCount = (group: ModelGroup) => {
  218. let unset = 0;
  219. for (const entry of group.printers) {
  220. const matching = matchingFor(entry);
  221. for (const column of columns(entry)) {
  222. for (const diameter of group.diameters) {
  223. const hasCandidate = matching.some(
  224. cal =>
  225. (cal.extruder_id ?? 0) === column.extruder
  226. && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
  227. );
  228. if (!hasCandidate) continue;
  229. if (!selectedProfiles.get(hotendKey(entry.printer.id, column.extruder, diameter))) unset++;
  230. }
  231. }
  232. }
  233. return unset;
  234. };
  235. const setPreset = (model: string, diameter: string, option: FilamentOption | null) => {
  236. setModelPresets(prev => {
  237. const next = new Map(prev);
  238. const key = presetKey(model, diameter);
  239. // Removing the entry is what "inherited" means -- the backend cascade
  240. // falls through to the spool's own preset when no row exists. Storing a
  241. // row that repeats the spool's value would freeze it instead: later
  242. // edits to the spool preset would stop reaching this model.
  243. if (!option) next.delete(key);
  244. else next.set(key, { code: option.code, name: option.name });
  245. return next;
  246. });
  247. };
  248. const chooseProfile = (
  249. printerId: number,
  250. extruder: number,
  251. diameter: string,
  252. cal: CalibrationProfile | null,
  253. ) => {
  254. setSelectedProfiles(prev => {
  255. const next = new Map(prev);
  256. const key = hotendKey(printerId, extruder, diameter);
  257. if (!cal) next.delete(key);
  258. else next.set(key, cal);
  259. return next;
  260. });
  261. };
  262. /**
  263. * Fill each model's preset with the variant of the spool's own preset that
  264. * names that model. Preset names are mechanical ("Bambu PLA Basic @BBL X1C"),
  265. * so the match is a name comparison, not a guess about filament identity: a
  266. * model with no such variant is left inherited rather than given something
  267. * approximate.
  268. */
  269. const autoMatch = () => {
  270. const base = filamentOptions.find(o => o.code === formData.slicer_filament);
  271. if (!base) return;
  272. const stem = base.name.split('@')[0].trim().toLowerCase();
  273. if (!stem) return;
  274. setModelPresets(prev => {
  275. const next = new Map(prev);
  276. for (const group of groups) {
  277. if (!group.model) continue;
  278. // Only presets that name this model. An unclassifiable one stays in the
  279. // list to be picked by hand but is never assigned for the user.
  280. const candidates = optionsForModel(group.model, undefined).filter(
  281. option =>
  282. option.name.toLowerCase().startsWith(stem)
  283. && extractPresetModel(option.name, printerModels ?? {}) !== null,
  284. );
  285. if (candidates.length === 0) continue;
  286. for (const diameter of group.diameters) {
  287. // Bambu names the size in the preset ("@BBL X1C 0.4 nozzle"), so
  288. // prefer the variant for this size and fall back to one that names
  289. // the model without a size. A size with neither is left inherited --
  290. // an approximate preset is worse than the spool's own.
  291. const sized = candidates.find(option =>
  292. new RegExp(`\\b${diameter.replace('.', '\\.')}\\s*nozzle\\b`, 'i').test(option.name),
  293. );
  294. const unsized = candidates.find(option => !/\b[\d.]+\s*nozzle\b/i.test(option.name));
  295. const match = sized ?? unsized;
  296. if (match) next.set(presetKey(group.model, diameter), { code: match.code, name: match.name });
  297. }
  298. }
  299. return next;
  300. });
  301. };
  302. if (printersWithCalibrations.length === 0) {
  303. return (
  304. <div className="p-6 bg-bambu-dark rounded-lg text-center">
  305. {/* "No printers configured" is a claim about the user's setup and must
  306. not be made while the printers are still being asked -- reading each
  307. one's calibration table is several MQTT round trips. */}
  308. <p className="text-bambu-gray flex items-center justify-center gap-2">
  309. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  310. {isLoading ? t('common.loading') : t('inventory.noPrintersConfigured')}
  311. </p>
  312. </div>
  313. );
  314. }
  315. const renderPresetRow = (model: string, diameter: string, inheritLabel: string) => {
  316. const key = presetKey(model, diameter);
  317. const chosen = modelPresets.get(key);
  318. const options = optionsForModel(model, chosen?.code);
  319. return (
  320. <div className="flex items-center gap-2">
  321. <div className="flex-1 min-w-0">
  322. <PresetPicker
  323. ariaLabel={`${model} ${diameter}mm ${t('inventory.filamentPreset')}`}
  324. value={chosen?.code ?? ''}
  325. options={options}
  326. inheritLabel={inheritLabel}
  327. disabled={!model}
  328. onChange={option => setPreset(model, diameter, option)}
  329. />
  330. </div>
  331. <span
  332. className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full shrink-0 ${
  333. chosen ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
  334. }`}
  335. >
  336. {chosen ? t('inventory.presetOverride') : t('inventory.presetInherited')}
  337. </span>
  338. </div>
  339. );
  340. };
  341. return (
  342. <div className="space-y-3">
  343. {/* Which spool is being configured. Worth a line of its own here: this
  344. tab is the one place you read printer names rather than filament, and
  345. the K-profile lists below are filtered by exactly these fields --
  346. brand, material and subtype -- so an empty list is explained by what
  347. this line says. */}
  348. {(identity || formData.color_name) && (
  349. <div className="flex items-center gap-2.5 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
  350. <span
  351. className="w-5 h-5 rounded-full border border-white/15 shrink-0"
  352. style={{ background: swatch }}
  353. aria-hidden="true"
  354. />
  355. {identity && <span className="text-sm text-white truncate">{identity}</span>}
  356. {formData.color_name && (
  357. <span className="text-sm text-bambu-gray truncate">{formData.color_name}</span>
  358. )}
  359. </div>
  360. )}
  361. <div className="flex flex-col md:flex-row gap-4">
  362. {/* Model list. Sticky rather than its own scroll region: a second
  363. scrollbar inside the modal's own scrolling body means the user has to
  364. find which one moves the thing they are looking at. */}
  365. <div
  366. role="tablist"
  367. aria-label={t('inventory.printersTab')}
  368. aria-orientation="vertical"
  369. className="md:w-52 md:shrink-0 md:self-start md:sticky md:top-0 flex md:flex-col gap-1.5 overflow-x-auto md:overflow-x-visible"
  370. >
  371. {groups.map(group => {
  372. const isActive = group === active;
  373. const unset = unsetCount(group);
  374. return (
  375. <button
  376. key={group.id}
  377. type="button"
  378. role="tab"
  379. onClick={() => setSelectedGroupId(group.id)}
  380. aria-selected={isActive}
  381. className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-colors shrink-0 md:shrink ${
  382. isActive
  383. ? 'bg-bambu-green/10 border-bambu-green/40 text-white'
  384. : 'bg-transparent border-transparent text-bambu-gray hover:bg-bambu-dark hover:text-white'
  385. }`}
  386. >
  387. <div className="min-w-0 flex-1">
  388. <div className="text-sm font-semibold truncate">
  389. {group.model || t('inventory.unknownModel')}
  390. </div>
  391. <div className="text-[11px] text-bambu-gray">
  392. {group.printers.length === 1
  393. ? t('inventory.onePrinter')
  394. : t('inventory.nPrinters', { n: group.printers.length })}
  395. </div>
  396. </div>
  397. {unset > 0 ? (
  398. <span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray shrink-0">
  399. {unset}
  400. </span>
  401. ) : (
  402. <Check className="w-3.5 h-3.5 text-bambu-green shrink-0" />
  403. )}
  404. </button>
  405. );
  406. })}
  407. </div>
  408. {/* Detail */}
  409. <div className="flex-1 min-w-0">
  410. {active && (
  411. <div className="space-y-4">
  412. <div className="flex items-start justify-between gap-3">
  413. <div className="min-w-0">
  414. <h4 className="text-base font-semibold text-white truncate">
  415. {active.model || t('inventory.unknownModel')}
  416. </h4>
  417. <p className="text-xs text-bambu-gray">
  418. {active.printers.map(p => p.printer.name).join(', ')}
  419. </p>
  420. </div>
  421. {formData.slicer_filament && (
  422. <button
  423. type="button"
  424. onClick={autoMatch}
  425. title={t('inventory.autoMatchPresetsHint')}
  426. className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors shrink-0"
  427. >
  428. <Sparkles className="w-3.5 h-3.5" />
  429. {t('inventory.autoMatchPresets')}
  430. </button>
  431. )}
  432. </div>
  433. {/* Filament preset — model scoped */}
  434. <div className="space-y-2">
  435. <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
  436. {t('inventory.filamentPreset')}
  437. </p>
  438. {!active.model ? (
  439. <p className="text-sm text-bambu-gray italic">{t('inventory.presetNeedsModel')}</p>
  440. ) : (
  441. /* One row per nozzle size, and no model-wide row above them:
  442. the preset is written to an AMS slot, a slot feeds exactly
  443. one nozzle, and Bambu names its presets per size anyway
  444. ("@BBL X1C 0.4 nozzle"). A size left alone falls straight
  445. back to the spool's own preset. */
  446. <div className="space-y-2">
  447. {active.diameters.map(diameter => (
  448. <div key={diameter} className="flex items-center gap-3">
  449. <span className="text-xs font-mono text-bambu-gray w-14 shrink-0">
  450. {diameter}mm
  451. </span>
  452. <div className="flex-1 min-w-0">
  453. {renderPresetRow(
  454. active.model,
  455. diameter,
  456. t('inventory.presetUseSpoolDefault'),
  457. )}
  458. </div>
  459. </div>
  460. ))}
  461. </div>
  462. )}
  463. </div>
  464. {/* K profiles — machine scoped */}
  465. <div className="space-y-2">
  466. <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
  467. {t('inventory.kProfilesPerPrinter')}
  468. </p>
  469. {!formData.material ? (
  470. <p className="text-sm text-bambu-gray italic">{t('inventory.selectMaterialFirst')}</p>
  471. ) : (
  472. active.printers.map(entry => {
  473. const matching = matchingFor(entry);
  474. return (
  475. <div
  476. key={entry.printer.id}
  477. className="p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg"
  478. >
  479. <div className="flex items-center gap-2 mb-1">
  480. <PrinterIcon className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
  481. <span className="text-sm font-semibold text-white truncate">
  482. {entry.printer.name}
  483. </span>
  484. <span
  485. className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0 ${
  486. entry.printer.connected
  487. ? 'bg-green-500/20 text-green-500'
  488. : 'bg-bambu-dark-tertiary text-bambu-gray'
  489. }`}
  490. >
  491. {entry.printer.connected
  492. ? t('inventory.connected')
  493. : t('inventory.offline')}
  494. </span>
  495. </div>
  496. {!entry.printer.connected ? (
  497. <p className="text-sm text-bambu-gray italic py-1">
  498. {t('inventory.printerOffline')}
  499. </p>
  500. ) : matching.length === 0 ? (
  501. <p className="text-sm text-bambu-gray italic py-1">
  502. {t('inventory.noKProfilesMatch')}
  503. </p>
  504. ) : (
  505. /* A grid rather than a list of rows: nozzle size down
  506. the side, hotend across the top. A dual-nozzle
  507. machine has up to eight cells, and stacked rows made
  508. that a scroll where a table is a glance. Columns run
  509. left-then-right to match the machine, which is the
  510. reverse of the extruder ids behind them (extruder 0
  511. is the RIGHT hotend). */
  512. <div
  513. className="grid gap-x-3 gap-y-1.5 items-center"
  514. style={{
  515. gridTemplateColumns: `3.5rem repeat(${columns(entry).length}, minmax(0, 1fr))`,
  516. }}
  517. >
  518. <span />
  519. {columns(entry).map(column => (
  520. <span
  521. key={column.extruder}
  522. className="text-[11px] font-semibold uppercase tracking-wide text-bambu-gray"
  523. >
  524. {column.label}
  525. </span>
  526. ))}
  527. {active.diameters.map(diameter => (
  528. <Fragment key={diameter}>
  529. <span className="text-xs font-mono text-bambu-gray">{diameter}mm</span>
  530. {columns(entry).map(column => {
  531. const candidates = matching.filter(
  532. cal =>
  533. (cal.extruder_id ?? 0) === column.extruder
  534. && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
  535. );
  536. const key = hotendKey(entry.printer.id, column.extruder, diameter);
  537. const chosen = selectedProfiles.get(key);
  538. if (candidates.length === 0) {
  539. return (
  540. // The printer has no calibration for this
  541. // size on this hotend. Shown rather than
  542. // omitted so the size is visibly accounted
  543. // for instead of looking forgotten.
  544. <span
  545. key={key}
  546. className="text-xs text-bambu-gray/50 px-2 py-1.5"
  547. title={t('inventory.noKProfilesMatch')}
  548. >
  549. &mdash;
  550. </span>
  551. );
  552. }
  553. // A stored profile whose flow disagrees with
  554. // the nozzle now fitted is not applied at
  555. // assign time -- a K value measured on a
  556. // high-flow nozzle is not a fact about a
  557. // standard one. Say so here rather than let it
  558. // look configured and quietly do nothing.
  559. const fitted = fittedFlow(entry, column.extruder);
  560. const chosenFlow = normaliseFlow(chosen?.nozzle_id);
  561. const flowMismatch = !!(fitted && chosenFlow && fitted !== chosenFlow);
  562. return (
  563. <select
  564. key={key}
  565. title={
  566. flowMismatch
  567. ? t('inventory.kProfileFlowMismatch', {
  568. profile: flowLabel(chosenFlow),
  569. fitted: flowLabel(fitted),
  570. })
  571. : undefined
  572. }
  573. aria-label={`${entry.printer.name} ${column.label} ${diameter}mm`}
  574. value={chosen ? String(chosen.cali_idx) : ''}
  575. onChange={e => {
  576. const cal =
  577. candidates.find(c => String(c.cali_idx) === e.target.value)
  578. ?? null;
  579. chooseProfile(entry.printer.id, column.extruder, diameter, cal);
  580. }}
  581. className={`min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border rounded-lg text-sm text-white focus:outline-none focus:border-bambu-green ${
  582. flowMismatch ? 'border-amber-500/60' : 'border-bambu-dark-tertiary'
  583. }`}
  584. >
  585. <option value="">{t('inventory.kProfileNotSet')}</option>
  586. {candidates.map(cal => {
  587. // The flow the profile was measured on.
  588. // Shown because the same filament reads a
  589. // different K through a high-flow nozzle,
  590. // and a printer can hold both -- this H2D
  591. // has 102 high-flow entries and 6
  592. // standard. Omitted where the printer
  593. // declares none (an X1C declares none at
  594. // all), since there is nothing to say.
  595. const label = flowLabel(normaliseFlow(cal.nozzle_id));
  596. return (
  597. <option key={cal.cali_idx} value={cal.cali_idx}>
  598. {`${label ? `[${label}] ` : ''}${cal.name || cal.filament_id} K=${cal.k_value.toFixed(3)}`}
  599. </option>
  600. );
  601. })}
  602. </select>
  603. );
  604. })}
  605. </Fragment>
  606. ))}
  607. </div>
  608. )}
  609. </div>
  610. );
  611. })
  612. )}
  613. </div>
  614. </div>
  615. )}
  616. </div>
  617. </div>
  618. </div>
  619. );
  620. }