Просмотр исходного кода

Fix multi-printer filament mapping ignoring nozzle on dual-nozzle printers (#624)

  The per-printer InlineMappingEditor showed filaments from both nozzles
  on H2D printers. The single-printer FilamentMapping dropdown was fixed
  in 29e9593 but the multi-printer code path was missed. Added nozzle_id
  filtering to both auto-match logic and dropdown options via shared
  autoMatchFilament() and filterFilamentsByNozzle() functions in
  amsHelpers.ts, with 10 regression tests.
maziggy 2 месяцев назад
Родитель
Сommit
1bc3da3fad

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
 - **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
 - **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
+- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
 
 ## [0.2.2b1] - 2026-03-03
 

+ 132 - 0
frontend/src/__tests__/components/PrinterSelector.test.ts

@@ -0,0 +1,132 @@
+/**
+ * Tests for InlineMappingEditor auto-match and nozzle filtering logic.
+ *
+ * Regression test for #624: multi-printer filament mapping showed filaments
+ * from both nozzles on dual-nozzle printers (H2D). The single-printer path
+ * (FilamentMapping.tsx) was fixed in commit 29e9593 but the multi-printer
+ * path (InlineMappingEditor in PrinterSelector.tsx) was missed.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  autoMatchFilament,
+  filterFilamentsByNozzle,
+} from '../../utils/amsHelpers';
+import type { LoadedFilament, FilamentRequirement } from '../../hooks/useFilamentMapping';
+
+// -- helpers -----------------------------------------------------------------
+
+function makeFilament(overrides: Partial<LoadedFilament> & { globalTrayId: number }): LoadedFilament {
+  return {
+    type: 'PLA',
+    color: '#FFFFFF',
+    colorName: 'White',
+    amsId: 0,
+    trayId: 0,
+    label: 'AMS1-T1',
+    trayInfoIdx: '',
+    extruderId: undefined,
+    ...overrides,
+  };
+}
+
+function makeReq(overrides: Partial<FilamentRequirement> = {}): FilamentRequirement {
+  return {
+    slot_id: 1,
+    type: 'PLA',
+    color: '#FFFFFF',
+    used_grams: 10,
+    ...overrides,
+  };
+}
+
+// Dual-nozzle H2D-like setup:
+// Left nozzle (extruderId=1): AMS0 with PLA Black (tray 0) and PETG White (tray 1)
+// Right nozzle (extruderId=0): AMS1 with PLA White (tray 4) and PLA Red (tray 5)
+const H2D_FILAMENTS: LoadedFilament[] = [
+  makeFilament({ globalTrayId: 0, type: 'PLA', color: '#000000', colorName: 'Black', amsId: 0, trayId: 0, label: 'AMS1-T1', extruderId: 1 }),
+  makeFilament({ globalTrayId: 1, type: 'PETG', color: '#FFFFFF', colorName: 'White', amsId: 0, trayId: 1, label: 'AMS1-T2', extruderId: 1 }),
+  makeFilament({ globalTrayId: 4, type: 'PLA', color: '#FFFFFF', colorName: 'White', amsId: 1, trayId: 0, label: 'AMS2-T1', extruderId: 0 }),
+  makeFilament({ globalTrayId: 5, type: 'PLA', color: '#FF0000', colorName: 'Red', amsId: 1, trayId: 1, label: 'AMS2-T2', extruderId: 0 }),
+];
+
+// -- filterFilamentsByNozzle -------------------------------------------------
+
+describe('filterFilamentsByNozzle', () => {
+  it('returns all filaments when nozzle_id is null', () => {
+    const result = filterFilamentsByNozzle(H2D_FILAMENTS, null);
+    expect(result).toHaveLength(4);
+  });
+
+  it('returns all filaments when nozzle_id is undefined', () => {
+    const result = filterFilamentsByNozzle(H2D_FILAMENTS, undefined);
+    expect(result).toHaveLength(4);
+  });
+
+  it('filters to left nozzle (extruderId=1)', () => {
+    const result = filterFilamentsByNozzle(H2D_FILAMENTS, 1);
+    expect(result).toHaveLength(2);
+    expect(result.every((f) => f.extruderId === 1)).toBe(true);
+  });
+
+  it('filters to right nozzle (extruderId=0)', () => {
+    const result = filterFilamentsByNozzle(H2D_FILAMENTS, 0);
+    expect(result).toHaveLength(2);
+    expect(result.every((f) => f.extruderId === 0)).toBe(true);
+  });
+});
+
+// -- autoMatchFilament -------------------------------------------------------
+
+describe('autoMatchFilament', () => {
+  it('matches exact type+color on correct nozzle', () => {
+    const req = makeReq({ type: 'PLA', color: '#FFFFFF', nozzle_id: 0 });
+    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(4); // AMS2-T1 on right nozzle
+  });
+
+  it('does NOT match filament on wrong nozzle — regression #624', () => {
+    // Require PLA Black on right nozzle (extruderId=0).
+    // PLA Black exists only on left nozzle (tray 0, extruderId=1).
+    const req = makeReq({ type: 'PLA', color: '#000000', nozzle_id: 0 });
+    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());
+    // Should NOT match tray 0 (wrong nozzle). May match tray 4 or 5 as type-only.
+    if (result) {
+      expect(result.extruderId).toBe(0);
+      expect(result.globalTrayId).not.toBe(0);
+    }
+  });
+
+  it('matches without nozzle constraint for single-nozzle printers', () => {
+    const req = makeReq({ type: 'PLA', color: '#000000' }); // no nozzle_id
+    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(0); // Exact match: PLA Black
+  });
+
+  it('falls back to type-only match on correct nozzle', () => {
+    // Require PETG Green on left nozzle — no exact color match, but PETG White exists
+    const req = makeReq({ type: 'PETG', color: '#00FF00', nozzle_id: 1 });
+    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(1); // PETG White on left nozzle
+    expect(result!.extruderId).toBe(1);
+  });
+
+  it('returns undefined when no filament matches on required nozzle', () => {
+    // Require PETG on right nozzle — PETG only exists on left nozzle
+    const req = makeReq({ type: 'PETG', color: '#FFFFFF', nozzle_id: 0 });
+    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());
+    expect(result).toBeUndefined();
+  });
+
+  it('skips already-used tray IDs', () => {
+    const req = makeReq({ type: 'PLA', color: '#FFFFFF', nozzle_id: 0 });
+    const used = new Set([4]); // AMS2-T1 already used
+    const result = autoMatchFilament(req, H2D_FILAMENTS, used);
+    // Should fall back to PLA Red (tray 5) as type-only match
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(5);
+  });
+});

+ 5 - 24
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -16,6 +16,8 @@ import { getColorName } from '../../utils/colors';
 import {
   normalizeColorForCompare,
   colorsAreSimilar,
+  autoMatchFilament,
+  filterFilamentsByNozzle,
 } from '../../utils/amsHelpers';
 import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
@@ -101,30 +103,8 @@ function InlineMappingEditor({
       loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
       isManual = true;
     } else {
-      // Auto-match logic
       const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
-
-      const exactMatch = printerResult.loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
-          f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = exactMatch
-        ? undefined
-        : printerResult.loadedFilaments.find(
-            (f) =>
-              !usedTrayIds.has(f.globalTrayId) &&
-              f.type?.toUpperCase() === req.type?.toUpperCase() &&
-              colorsAreSimilar(f.color, req.color)
-          );
-      const typeOnlyMatch =
-        exactMatch || similarMatch
-          ? undefined
-          : printerResult.loadedFilaments.find(
-              (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
-            );
-      loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+      loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds) as LoadedFilament | undefined;
     }
 
     // Determine status
@@ -188,7 +168,8 @@ function InlineMappingEditor({
             <option value="" className="bg-bambu-dark text-bambu-gray">
               -- Select slot --
             </option>
-            {printerResult.loadedFilaments.map((f) => (
+            {filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)
+              .map((f) => (
               <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                 {f.label}: {f.type} ({f.colorName})
               </option>

+ 47 - 0
frontend/src/utils/amsHelpers.ts

@@ -114,3 +114,50 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
   const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
   return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
 }
+
+/**
+ * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
+ * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
+ */
+export function autoMatchFilament(
+  req: { type?: string; color?: string; nozzle_id?: number | null },
+  loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number }[],
+  usedTrayIds: Set<number>,
+): typeof loadedFilaments[number] | undefined {
+  const nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
+
+  const exactMatch = nozzleFilaments.find(
+    (f) =>
+      !usedTrayIds.has(f.globalTrayId) &&
+      f.type?.toUpperCase() === req.type?.toUpperCase() &&
+      normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+  );
+  const similarMatch = exactMatch
+    ? undefined
+    : nozzleFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          colorsAreSimilar(f.color, req.color)
+      );
+  const typeOnlyMatch =
+    exactMatch || similarMatch
+      ? undefined
+      : nozzleFilaments.find(
+          (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        );
+  return exactMatch ?? similarMatch ?? typeOnlyMatch;
+}
+
+/**
+ * Filter loaded filaments to those valid for a given nozzle requirement.
+ * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
+ */
+export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
+  loadedFilaments: T[],
+  nozzleId: number | undefined | null,
+): T[] {
+  return loadedFilaments.filter(
+    (f) => nozzleId == null || f.extruderId === nozzleId
+  );
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-6ojoryAb.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CGBueEai.js"></script>
+    <script type="module" crossorigin src="/assets/index-6ojoryAb.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DOJtH8DG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов