slicePresetPicker.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. // - `pickFilamentForSlot` partitions candidates into compatible/unknown
  19. // vs mismatch buckets and only consults the mismatch bucket when
  20. // the compatible bucket is empty (#1851).
  21. import type {
  22. PresetRef,
  23. PresetSource,
  24. UnifiedPreset,
  25. UnifiedPresetsResponse,
  26. } from '../api/client';
  27. import { colorsAreSimilar, normalizeColorForCompare } from './amsHelpers';
  28. import {
  29. presetCompatibility,
  30. type PrinterCompatibilityIndex,
  31. } from './slicerPrinterMatch';
  32. export type Slot = 'printer' | 'process' | 'filament';
  33. export const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', 'cloud', 'standard'] as const;
  34. const TIER_BONUS: Record<PresetSource, number> = {
  35. local: 1.75,
  36. orca_cloud: 1.5,
  37. cloud: 1.0,
  38. standard: 0.5,
  39. };
  40. export function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
  41. for (const tier of SLICE_MODAL_TIER_ORDER) {
  42. const list = by[tier][slot];
  43. if (list.length > 0) {
  44. return { source: list[0].source, id: list[0].id };
  45. }
  46. }
  47. return null;
  48. }
  49. // Resolve a PresetRef back to its UnifiedPreset within the named slot, or
  50. // null if it no longer resolves (e.g. the preset was deleted between the
  51. // listing fetch and selection).
  52. export function findPreset(
  53. by: UnifiedPresetsResponse,
  54. ref: PresetRef | null,
  55. slot: Slot,
  56. ): UnifiedPreset | null {
  57. if (!ref) return null;
  58. return by[ref.source][slot].find((p) => p.id === ref.id) ?? null;
  59. }
  60. // Find a preset by exact name across tiers (local → cloud → standard). Used
  61. // to honour the printer / process preset names a 3MF was prepared with.
  62. export function findPresetByName(
  63. by: UnifiedPresetsResponse,
  64. slot: Slot,
  65. name: string | null | undefined,
  66. ): PresetRef | null {
  67. if (!name) return null;
  68. for (const tier of SLICE_MODAL_TIER_ORDER) {
  69. const p = by[tier][slot].find((x) => x.name === name);
  70. if (p) return { source: p.source, id: p.id };
  71. }
  72. return null;
  73. }
  74. // Process default: honour the process preset the 3MF was prepared with
  75. // (preferredName) when it's available and not incompatible with the selected
  76. // printer; otherwise the first preset compatible with the printer in tier
  77. // order, then the first whose compatibility is merely unknown, then plain
  78. // priority. Keeps the pre-pick honest with both the embedded config and the
  79. // printer filter instead of blindly taking list[0] (#1325).
  80. export function pickProcessDefault(
  81. by: UnifiedPresetsResponse,
  82. printerName: string | null,
  83. compatIndex: PrinterCompatibilityIndex,
  84. preferredName?: string | null,
  85. ): PresetRef | null {
  86. const preferred = findPresetByName(by, 'process', preferredName);
  87. if (preferred) {
  88. const p = findPreset(by, preferred, 'process');
  89. if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
  90. return preferred;
  91. }
  92. }
  93. for (const wanted of ['match', 'unknown'] as const) {
  94. for (const tier of SLICE_MODAL_TIER_ORDER) {
  95. for (const p of by[tier].process) {
  96. if (presetCompatibility(p, 'process', printerName, compatIndex) === wanted) {
  97. return { source: p.source, id: p.id };
  98. }
  99. }
  100. }
  101. }
  102. return pickDefault(by, 'process');
  103. }
  104. export function pickFilamentForSlot(
  105. by: UnifiedPresetsResponse,
  106. required: { type: string; color: string },
  107. printerName: string | null,
  108. compatIndex: PrinterCompatibilityIndex,
  109. ): PresetRef | null {
  110. // Score every filament preset against the plate slot's required (type,
  111. // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
  112. // print/schedule modal: type match dominates, exact-colour-match bumps over
  113. // similar-colour-match, and a small per-tier bonus breaks ties so cloud
  114. // user customisations win over standard bundled fallbacks of equal merit.
  115. //
  116. // Compatibility is a hard partition, not a soft penalty (#1851). The legacy
  117. // -100 demote let a printer-mismatched preset still win when the plate's
  118. // (type, colour) happened to match it better than the colour-default
  119. // standard preset on the right printer — e.g. an unused slot whose embedded
  120. // colour matched `Generic PLA @BBL H2C` but not the off-the-shelf
  121. // `Bambu PLA Basic @BBL A1`. The propagated slot-1 then poisoned every
  122. // unused slot via `substitute_unused_plate_filaments`, and the CLI rejected
  123. // the slice with "filament preset Generic PLA @BBL H2C (slot 1) is not
  124. // compatible with printer Bambu Lab A1 0.4 nozzle". Hard-skipping mismatches
  125. // while we still have any compatible/unknown candidate eliminates that
  126. // poisoning at the source; the mismatch tier is only consulted when no
  127. // printer-correct alternative exists, which preserves the graceful-degrade
  128. // behaviour for presets registries that genuinely have nothing for the
  129. // selected printer.
  130. const reqType = required.type.trim().toUpperCase();
  131. const reqColor = normalizeColorForCompare(required.color);
  132. let bestCompatible: { ref: PresetRef; score: number } | null = null;
  133. let bestMismatch: { ref: PresetRef; score: number } | null = null;
  134. for (const tier of SLICE_MODAL_TIER_ORDER) {
  135. for (const p of by[tier].filament) {
  136. let score = 0;
  137. const presetType = (p.filament_type ?? '').trim().toUpperCase();
  138. const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
  139. if (reqType && presetType && reqType === presetType) score += 10;
  140. if (reqColor && presetColor) {
  141. if (presetColor === reqColor) score += 5;
  142. else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
  143. }
  144. score += TIER_BONUS[tier];
  145. const ref = { source: p.source, id: p.id };
  146. if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
  147. if (bestMismatch == null || score > bestMismatch.score) {
  148. bestMismatch = { ref, score };
  149. }
  150. } else if (bestCompatible == null || score > bestCompatible.score) {
  151. bestCompatible = { ref, score };
  152. }
  153. }
  154. }
  155. if (bestCompatible != null) return bestCompatible.ref;
  156. if (bestMismatch != null) return bestMismatch.ref;
  157. // Final fallback when there are no filament presets at all (empty
  158. // registry) — pickDefault returns null in that case too, but keeping the
  159. // call mirrors the rest of the picker logic for shape consistency.
  160. return pickDefault(by, 'filament');
  161. }