nozzleRack.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import type { NozzleRackSlot } from '../api/client';
  2. import type { RackGroupInfo, RackPositionOption } from '../components/PrintModal/types';
  3. /**
  4. * H2C nozzle-rack position helpers (#1784).
  5. *
  6. * A rack position as everyone counts it — the printer card, BambuStudio, the
  7. * operator — is 1-based, and the physical nozzle ID the printer is sent is 15
  8. * higher. Measured 2026-08-14: the same plate dispatched with R1+R2 picked sent
  9. * `[16, 1, 17]`, and with R1+R3 picked sent `[16, 1, 18]`.
  10. *
  11. * The eligibility rule here mirrors `_rack_slot_is_eligible` in
  12. * `backend/app/services/bambu_mqtt.py`. It is duplicated rather than fetched
  13. * because the picker has to grey out an option as the user looks at it, but the
  14. * backend re-checks the same rule at dispatch and refuses the print if it no
  15. * longer holds — so this copy being briefly stale can only mislead, never
  16. * misprint.
  17. */
  18. /** Physical nozzle IDs of the six rack positions. */
  19. export const RACK_POSITION_BASE = 15;
  20. export const RACK_SIZE = 6;
  21. export const RACK_POSITIONS = Array.from({ length: RACK_SIZE }, (_, i) => i + 1);
  22. /** The two carriage entries the printer reports alongside the rack itself. */
  23. const RACK_CARRIAGE_NOZZLE_ID = 0;
  24. export function rackPositionToNozzleId(position: number): number | null {
  25. if (!Number.isInteger(position) || position < 1 || position > RACK_SIZE) return null;
  26. return RACK_POSITION_BASE + position;
  27. }
  28. /**
  29. * Live rack contents by 1-based position, including the mounted nozzle.
  30. *
  31. * The firmware omits a rack ID entirely while that nozzle is picked up onto the
  32. * carriage (#943) rather than sending an empty placeholder, so taking the gap at
  33. * face value would grey out the nozzle most likely to be wanted — the one the
  34. * last print left mounted. A single gap alongside a loaded carriage is that
  35. * carriage's nozzle; two or more gaps are genuinely ambiguous (four nozzles in
  36. * six positions looks identical) and stay absent.
  37. */
  38. export function rackByPosition(slots: NozzleRackSlot[] | undefined): Map<number, NozzleRackSlot> {
  39. const byPosition = new Map<number, NozzleRackSlot>();
  40. let carriage: NozzleRackSlot | undefined;
  41. for (const slot of slots ?? []) {
  42. if (slot.id === RACK_CARRIAGE_NOZZLE_ID) {
  43. carriage = slot;
  44. continue;
  45. }
  46. const position = slot.id - RACK_POSITION_BASE;
  47. if (position >= 1 && position <= RACK_SIZE) byPosition.set(position, slot);
  48. }
  49. const missing = RACK_POSITIONS.filter((p) => !byPosition.has(p));
  50. if (missing.length === 1 && carriage && (carriage.nozzle_diameter || carriage.nozzle_type)) {
  51. byPosition.set(missing[0], carriage);
  52. }
  53. return byPosition;
  54. }
  55. /** Whether a live rack slot can print a group wanting this nozzle. */
  56. export function isRackSlotEligible(
  57. slot: NozzleRackSlot | undefined,
  58. group: Pick<RackGroupInfo, 'nozzle_diameter' | 'volume_type'>,
  59. ): boolean {
  60. if (!slot) return false;
  61. if (!slot.nozzle_diameter && !slot.nozzle_type) return false;
  62. // "0.40" and "0.4" name the same nozzle — the 3MF pads, the printer does not.
  63. const slotDiameter = Number.parseFloat(slot.nozzle_diameter);
  64. const wantedDiameter = Number.parseFloat(group.nozzle_diameter);
  65. if (!Number.isFinite(slotDiameter) || !Number.isFinite(wantedDiameter)) return false;
  66. if (Math.abs(slotDiameter - wantedDiameter) > 0.005) return false;
  67. // Flow type: the printer reports a code ("HS", "HH01"), the slice a name
  68. // ("High Flow"). Compared only when both are stated, so a printer that omits
  69. // the code is not thereby ruled out.
  70. const wanted = (group.volume_type || '').trim().toLowerCase();
  71. if (wanted && slot.nozzle_type) {
  72. const isHighFlow = slot.nozzle_type.toUpperCase().startsWith('HH');
  73. if (wanted.startsWith('high flow') !== isHighFlow) return false;
  74. }
  75. return true;
  76. }
  77. /**
  78. * The six positions as pickable options for one group, each with the reason it
  79. * cannot be used when it cannot. Always returns all six: an operator looking for
  80. * position 4 should find it greyed out with an explanation, not missing.
  81. */
  82. export function rackOptionsForGroup(
  83. slots: NozzleRackSlot[] | undefined,
  84. group: RackGroupInfo,
  85. translate: (key: string, opts?: Record<string, unknown>) => string,
  86. ): RackPositionOption[] {
  87. const byPosition = rackByPosition(slots);
  88. return RACK_POSITIONS.map((position) => {
  89. const slot = byPosition.get(position);
  90. const eligible = isRackSlotEligible(slot, group);
  91. let reason: string | undefined;
  92. if (!eligible) {
  93. reason = slot
  94. ? translate('printModal.rackWrongNozzle', {
  95. has: `${slot.nozzle_diameter || '?'} ${slot.nozzle_type || ''}`.trim(),
  96. needs: `${group.nozzle_diameter} ${group.volume_type}`.trim(),
  97. })
  98. : translate('printModal.rackEmptyPosition');
  99. }
  100. return {
  101. position,
  102. diameter: slot?.nozzle_diameter ?? '',
  103. nozzleType: slot?.nozzle_type ?? '',
  104. filamentColor: slot?.filament_color ?? '',
  105. eligible,
  106. reason,
  107. };
  108. });
  109. }
  110. /**
  111. * A position for every rack-bound group, preferring one already loaded with the
  112. * group's own colour. Mirrors the dispatcher's assignment so the dialog shows
  113. * what will actually happen rather than leaving every picker blank.
  114. *
  115. * Returns null when some group cannot be placed — the caller then leaves the
  116. * pickers empty and lets the backend fall back, rather than showing a partial
  117. * assignment that reads as a decision.
  118. */
  119. export function autoAssignRackPositions(
  120. slots: NozzleRackSlot[] | undefined,
  121. groups: Map<number, RackGroupInfo>,
  122. pinned: Record<number, number> = {},
  123. ): Record<number, number> | null {
  124. const byPosition = rackByPosition(slots);
  125. const assigned: Record<number, number> = {};
  126. const taken = new Set<number>();
  127. const rackGroupIds = [...groups.keys()].filter((id) => groups.get(id)!.on_rack).sort((a, b) => a - b);
  128. // Explicit picks are placed first so an auto-assignment yields to them
  129. // instead of claiming a position the operator asked for.
  130. for (const groupId of rackGroupIds) {
  131. const position = pinned[groupId];
  132. if (position == null) continue;
  133. if (taken.has(position) || !isRackSlotEligible(byPosition.get(position), groups.get(groupId)!)) return null;
  134. assigned[groupId] = position;
  135. taken.add(position);
  136. }
  137. for (const groupId of rackGroupIds) {
  138. if (assigned[groupId] != null) continue;
  139. const group = groups.get(groupId)!;
  140. const eligible = RACK_POSITIONS.filter((p) => !taken.has(p) && isRackSlotEligible(byPosition.get(p), group));
  141. if (eligible.length === 0) return null;
  142. const wanted = normaliseColor(group.filament_color);
  143. const preferred = wanted
  144. ? eligible.find((p) => normaliseColor(byPosition.get(p)?.filament_color) === wanted)
  145. : undefined;
  146. const chosen = preferred ?? eligible[0];
  147. assigned[groupId] = chosen;
  148. taken.add(chosen);
  149. }
  150. return assigned;
  151. }
  152. /** Hex colours arrive as `#RRGGBB` from the 3MF and `RRGGBBAA` from the printer. */
  153. function normaliseColor(value: string | undefined): string {
  154. return (value ?? '').trim().replace(/^#/, '').slice(0, 6).toUpperCase();
  155. }