slicePresetPicker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. // Pure-function helpers for the SliceModal's per-slot preset selection.
  2. //
  3. // Extracted out of `SliceModal.tsx` so they can be unit-tested directly and
  4. // so the modal component file only exports React components (the
  5. // `react-refresh/only-export-components` lint rule requires this for HMR to
  6. // work correctly — exporting a non-component from a component file breaks
  7. // fast-refresh).
  8. //
  9. // Selection rules:
  10. // - Tier order is local → orca_cloud → cloud → standard. Local imports
  11. // outrank everything else because the user explicitly imported them
  12. // for this install; standard (bundled) is the final fallback.
  13. // - The backend does NOT dedup tiers, so each helper walks all four
  14. // and the caller relies on the order, not a single merged list.
  15. // - `pickProcessDefault` honours a 3MF's embedded process preset when
  16. // it exists and isn't printer-incompatible; otherwise prefers a
  17. // match-on-printer pick, then unknown-compat, then plain priority --
  18. // preferring a middle-of-the-road layer height within each of those
  19. // (#2982).
  20. // - `pickFilamentForSlot` partitions candidates into compatible/unknown
  21. // vs mismatch buckets and only consults the mismatch bucket when
  22. // the compatible bucket is empty (#1851). Material type partitions the
  23. // same way: a preset that states a different material than the plate
  24. // asks for is never the right answer (#2982).
  25. import type {
  26. PresetRef,
  27. PresetSource,
  28. UnifiedPreset,
  29. UnifiedPresetsResponse,
  30. } from '../api/client';
  31. import { colorsAreSimilar, normalizeColorForCompare } from './amsHelpers';
  32. import {
  33. presetCompatibility,
  34. type PrinterCompatibilityIndex,
  35. } from './slicerPrinterMatch';
  36. export type Slot = 'printer' | 'process' | 'filament';
  37. export const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', 'cloud', 'standard'] as const;
  38. const TIER_BONUS: Record<PresetSource, number> = {
  39. local: 1.75,
  40. orca_cloud: 1.5,
  41. cloud: 1.0,
  42. standard: 0.5,
  43. };
  44. export function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
  45. for (const tier of SLICE_MODAL_TIER_ORDER) {
  46. const list = by[tier][slot];
  47. if (list.length > 0) {
  48. return { source: list[0].source, id: list[0].id };
  49. }
  50. }
  51. return null;
  52. }
  53. // Resolve a PresetRef back to its UnifiedPreset within the named slot, or
  54. // null if it no longer resolves (e.g. the preset was deleted between the
  55. // listing fetch and selection).
  56. export function findPreset(
  57. by: UnifiedPresetsResponse,
  58. ref: PresetRef | null,
  59. slot: Slot,
  60. ): UnifiedPreset | null {
  61. if (!ref) return null;
  62. return by[ref.source][slot].find((p) => p.id === ref.id) ?? null;
  63. }
  64. // Find a preset by exact name across tiers (local → cloud → standard). Used
  65. // to honour the printer / process preset names a 3MF was prepared with.
  66. export function findPresetByName(
  67. by: UnifiedPresetsResponse,
  68. slot: Slot,
  69. name: string | null | undefined,
  70. ): PresetRef | null {
  71. if (!name) return null;
  72. for (const tier of SLICE_MODAL_TIER_ORDER) {
  73. const p = by[tier][slot].find((x) => x.name === name);
  74. if (p) return { source: p.source, id: p.id };
  75. }
  76. return null;
  77. }
  78. // Process default: honour the process preset the 3MF was prepared with
  79. // (preferredName) when it's available and not incompatible with the selected
  80. // printer; otherwise the first preset compatible with the printer in tier
  81. // order, then the first whose compatibility is merely unknown, then plain
  82. // priority. Keeps the pre-pick honest with both the embedded config and the
  83. // printer filter instead of blindly taking list[0] (#1325).
  84. export function pickProcessDefault(
  85. by: UnifiedPresetsResponse,
  86. printerName: string | null,
  87. compatIndex: PrinterCompatibilityIndex,
  88. preferredName?: string | null,
  89. ): PresetRef | null {
  90. const preferred = findPresetByName(by, 'process', preferredName);
  91. if (preferred) {
  92. const p = findPreset(by, preferred, 'process');
  93. if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
  94. return preferred;
  95. }
  96. }
  97. for (const wanted of ['match', 'unknown'] as const) {
  98. for (const tier of SLICE_MODAL_TIER_ORDER) {
  99. const candidates = by[tier].process.filter(
  100. (p) => presetCompatibility(p, 'process', printerName, compatIndex) === wanted,
  101. );
  102. const chosen = preferDefaultLayerHeight(candidates);
  103. if (chosen) return { source: chosen.source, id: chosen.id };
  104. }
  105. }
  106. return pickDefault(by, 'process');
  107. }
  108. // Bambu's default layer height, and the one its own presets are named
  109. // "Standard" at. Used only to order candidates that are equally valid.
  110. const DEFAULT_LAYER_HEIGHT_MM = 0.2;
  111. // Leading "<n>mm" on a process preset name — "0.20mm Standard @BBL X1C".
  112. const PROCESS_LAYER_HEIGHT = /^\s*([\d.]+)\s*mm\b/i;
  113. /**
  114. * Pick the best of a set of process presets that are all equally compatible.
  115. *
  116. * Tier order says which *source* to prefer, but within one tier the list is
  117. * alphabetical, and alphabetical order on Bambu's naming scheme puts the
  118. * finest layer height first. So the auto-pick landed on `0.08mm Extra Fine`
  119. * for an X1C and `0.06mm Fine` for an A1 mini — a correct preset, but the
  120. * slowest one the slicer ships, silently chosen for every slice that didn't
  121. * name its own process (#2982).
  122. *
  123. * Preferring the height nearest 0.2mm gives the same answer a person would:
  124. * `0.20mm Standard` where it exists, and the closest thing to it otherwise.
  125. * Ties break toward the coarser height, then toward the earlier name, so the
  126. * result stays deterministic. A name with no readable height sorts last but
  127. * is still eligible — an imported preset called "My Draft" must remain
  128. * pickable when it is the only candidate.
  129. */
  130. function preferDefaultLayerHeight(candidates: UnifiedPreset[]): UnifiedPreset | null {
  131. let best: UnifiedPreset | null = null;
  132. let bestDistance = Number.POSITIVE_INFINITY;
  133. let bestHeight = Number.NEGATIVE_INFINITY;
  134. for (const p of candidates) {
  135. const match = PROCESS_LAYER_HEIGHT.exec(p.name);
  136. const height = match ? Number.parseFloat(match[1]) : Number.NaN;
  137. const usable = Number.isFinite(height) && height > 0;
  138. const distance = usable
  139. ? Math.abs(height - DEFAULT_LAYER_HEIGHT_MM)
  140. : Number.POSITIVE_INFINITY;
  141. if (best == null || distance < bestDistance
  142. || (distance === bestDistance && usable && height > bestHeight)) {
  143. best = p;
  144. bestDistance = distance;
  145. bestHeight = usable ? height : Number.NEGATIVE_INFINITY;
  146. }
  147. }
  148. return best;
  149. }
  150. /**
  151. * True when ``preset`` states a material and it is not the one the plate slot
  152. * asks for.
  153. *
  154. * Deliberately three-valued in effect: a preset with no stated material is not
  155. * "different", it is unknown, and stays eligible. 32 profiles in the shipped
  156. * BBL bundle inherit from a parent the bundle doesn't contain, so their
  157. * material genuinely cannot be resolved — excluding those would leave slots
  158. * with nothing to pick.
  159. */
  160. export function statesDifferentMaterial(
  161. preset: Pick<UnifiedPreset, 'filament_type'>,
  162. requiredType: string,
  163. ): boolean {
  164. const required = requiredType.trim().toUpperCase();
  165. const stated = (preset.filament_type ?? '').trim().toUpperCase();
  166. return Boolean(required) && Boolean(stated) && required !== stated;
  167. }
  168. export function pickFilamentForSlot(
  169. by: UnifiedPresetsResponse,
  170. required: { type: string; color: string },
  171. printerName: string | null,
  172. compatIndex: PrinterCompatibilityIndex,
  173. ): PresetRef | null {
  174. // Score every filament preset against the plate slot's required (type,
  175. // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
  176. // print/schedule modal: type match dominates, exact-colour-match bumps over
  177. // similar-colour-match, and a small per-tier bonus breaks ties so cloud
  178. // user customisations win over standard bundled fallbacks of equal merit.
  179. //
  180. // Compatibility is a hard partition, not a soft penalty (#1851). The legacy
  181. // -100 demote let a printer-mismatched preset still win when the plate's
  182. // (type, colour) happened to match it better than the colour-default
  183. // standard preset on the right printer — e.g. an unused slot whose embedded
  184. // colour matched `Generic PLA @BBL H2C` but not the off-the-shelf
  185. // `Bambu PLA Basic @BBL A1`. The propagated slot-1 then poisoned every
  186. // unused slot via `substitute_unused_plate_filaments`, and the CLI rejected
  187. // the slice with "filament preset Generic PLA @BBL H2C (slot 1) is not
  188. // compatible with printer Bambu Lab A1 0.4 nozzle". Hard-skipping mismatches
  189. // while we still have any compatible/unknown candidate eliminates that
  190. // poisoning at the source; the mismatch tier is only consulted when no
  191. // printer-correct alternative exists, which preserves the graceful-degrade
  192. // behaviour for presets registries that genuinely have nothing for the
  193. // selected printer.
  194. //
  195. // Material is the second hard partition (#2982). A preset that states a
  196. // material the plate did not ask for is not a worse answer, it is the wrong
  197. // one: printing a PLA plate with a PETG profile means the wrong nozzle
  198. // temperature, the wrong bed temperature and the wrong flow. It used to be
  199. // only a missed +10 bonus, which a colour hit plus a tier bonus could
  200. // outweigh — and did, every time, once the standard tier's `filament_type`
  201. // turned out to be null for every preset it listed: an A1 mini offered
  202. // `Bambu PETG Basic` for a PLA plate, a P1S offered `Bambu PC`. Fixing the
  203. // sidecar to report the material restores the signal; skipping a stated
  204. // mismatch is what stops a wrong material from ever winning on colour again,
  205. // whatever a future registry reports.
  206. //
  207. // A preset that states NO material stays eligible — that is "don't know",
  208. // not "different", and 32 profiles in the shipped bundle inherit from a
  209. // parent it doesn't contain, so their material is genuinely unknown. The
  210. // same asymmetry `presetCompatibility` applies to printers.
  211. const reqType = required.type.trim().toUpperCase();
  212. const reqColor = normalizeColorForCompare(required.color);
  213. let bestCompatible: { ref: PresetRef; score: number } | null = null;
  214. let bestMismatch: { ref: PresetRef; score: number } | null = null;
  215. let bestWrongType: { ref: PresetRef; score: number } | null = null;
  216. for (const tier of SLICE_MODAL_TIER_ORDER) {
  217. for (const p of by[tier].filament) {
  218. let score = 0;
  219. const presetType = (p.filament_type ?? '').trim().toUpperCase();
  220. const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
  221. if (reqType && presetType && reqType === presetType) score += 10;
  222. if (reqColor && presetColor) {
  223. if (presetColor === reqColor) score += 5;
  224. else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
  225. }
  226. score += TIER_BONUS[tier];
  227. const ref = { source: p.source, id: p.id };
  228. if (statesDifferentMaterial(p, reqType)) {
  229. if (bestWrongType == null || score > bestWrongType.score) {
  230. bestWrongType = { ref, score };
  231. }
  232. } else if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
  233. if (bestMismatch == null || score > bestMismatch.score) {
  234. bestMismatch = { ref, score };
  235. }
  236. } else if (bestCompatible == null || score > bestCompatible.score) {
  237. bestCompatible = { ref, score };
  238. }
  239. }
  240. }
  241. if (bestCompatible != null) return bestCompatible.ref;
  242. if (bestMismatch != null) return bestMismatch.ref;
  243. // Nothing of the right material anywhere. Better a wrong-material preset the
  244. // user can see and change in the dropdown than a null the modal renders as
  245. // an empty slot, which is what shipped before the partition existed.
  246. if (bestWrongType != null) return bestWrongType.ref;
  247. // Final fallback when there are no filament presets at all (empty
  248. // registry) — pickDefault returns null in that case too, but keeping the
  249. // call mirrors the rest of the picker logic for shape consistency.
  250. return pickDefault(by, 'filament');
  251. }