فهرست منبع

fix(queue): extend Charcoal-style label fix to Specific-Printer panel (#1718 round 3)

  Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
  sub-brand, plus a material-disambiguated colour name from a new
  /inventory/colors/by-material endpoint. The printer-mode panel that
  renders the same 3MF (FilamentMapping) was reading the same raw
  fields — item.type for the required label, getColorName(item.color)
  for the swatch tooltip — and was not touched, so picking "Specific
  Printer" still showed "Required: PLA - Black" for a slice the
  "Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".

  Extract the three-query resolution machinery from FilamentOverride
  into a shared hook useFilamentLabels (returns positional
  {resolvedName, colorLabel} per slot). Both panels call it; both
  read the same labels. The hook also owns extractMaterialHint so the
  "strip leading brand token" rule has one source of truth.

  FilamentMapping required-side now reads {resolvedName} instead of
  {item.type}; swatch tooltip reads `Required: {resolvedName} -
  {colorLabel}` instead of `Required: {item.type} -
  getColorName(item.color)`.
maziggy 2 ماه پیش
والد
کامیت
6b477088a2

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 71 - 0
backend/app/api/routes/inventory.py

@@ -513,6 +513,10 @@ class ColorLookupResult(BaseModel):
     material: str | None = None
 
 
+class ColorByMaterialResult(BaseModel):
+    color_name: str | None = None
+
+
 # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
 
 
@@ -790,6 +794,73 @@ async def lookup_color(
     return ColorLookupResult(found=False)
 
 
+@router.get("/colors/by-material", response_model=ColorByMaterialResult)
+async def get_color_by_material(
+    hex: str,
+    material: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Disambiguated hex→name lookup that respects material context.
+
+    ``/colors/map`` collapses every catalog entry sharing a hex to a single
+    name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
+    "PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
+    This endpoint preserves the material context so the queue scheduler's
+    Filament Override label can show the actually-sliced sub-brand colour
+    instead of the generic bucket. #1718.
+
+    Returns ``color_name=None`` when the hex isn't in the catalog at all.
+    When the hex IS in the catalog but no entry matches the requested
+    material (or none was supplied), falls back to the same priority order
+    as ``/colors/map`` so callers without a material hint don't regress.
+
+    Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
+    every queue / archive view that renders a sliced filament colour needs
+    this, including read-only roles.
+    """
+    key = hex.lstrip("#").lower()[:6]
+    if len(key) != 6:
+        return ColorByMaterialResult(color_name=None)
+
+    material_norm = (material or "").strip().lower()
+
+    # Catalog rows are stored as ``#RRGGBB`` (verified at write time and
+    # against production); lookup uses lower-cased hex equality so mixed-case
+    # writes from older imports still match.
+    result = await db.execute(
+        select(
+            ColorCatalogEntry.color_name,
+            ColorCatalogEntry.manufacturer,
+            ColorCatalogEntry.material,
+            ColorCatalogEntry.is_default,
+        ).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
+    )
+    candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
+    if not candidates:
+        return ColorByMaterialResult(color_name=None)
+
+    if material_norm:
+        for name, _mfg, mat, _is_default in candidates:
+            if mat and mat.strip().lower() == material_norm:
+                return ColorByMaterialResult(color_name=name)
+
+    # Same priority order as ``/colors/map`` so a caller passing no (or an
+    # unrecognised) material gets the existing answer, not a degraded one.
+    best_name: str | None = None
+    best_priority = -1
+    for name, mfg, _mat, is_default in candidates:
+        priority = 0
+        if mfg and mfg.strip().lower() == "bambu lab":
+            priority += 2
+        if is_default:
+            priority += 1
+        if priority > best_priority:
+            best_name = name
+            best_priority = priority
+    return ColorByMaterialResult(color_name=best_name)
+
+
 @router.get("/colors/search", response_model=list[ColorEntryResponse])
 async def search_colors(
     manufacturer: str | None = None,

+ 127 - 0
backend/tests/integration/test_color_catalog_extras.py

@@ -157,3 +157,130 @@ async def test_create_spool_with_color_extras(async_client: AsyncClient):
     assert patch.status_code == 200
     assert patch.json()["extra_colors"] is None
     assert patch.json()["effect_type"] is None
+
+
+# ---- /colors/by-material — disambiguated lookup (#1718) -------------------
+
+
+async def _seed_black_collision(client: AsyncClient) -> None:
+    """Seed the #000000 ambiguity the endpoint was built to resolve.
+
+    PLA Matte → Charcoal, PLA Basic → Black, both at #000000 — same shape as
+    Bambu's production catalog.
+    """
+    for entry in (
+        {
+            "manufacturer": "Bambu Lab",
+            "color_name": "Charcoal",
+            "hex_color": "#000000",
+            "material": "PLA Matte",
+        },
+        {
+            "manufacturer": "Bambu Lab",
+            "color_name": "Black",
+            "hex_color": "#000000",
+            "material": "PLA Basic",
+        },
+    ):
+        response = await client.post("/api/v1/inventory/colors", json=entry)
+        assert response.status_code == 200, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_returns_material_specific_name(async_client: AsyncClient):
+    """Same hex + different material → returns the correctly-paired name."""
+    await _seed_black_collision(async_client)
+
+    matte = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Matte"}
+    )
+    assert matte.status_code == 200, matte.text
+    assert matte.json() == {"color_name": "Charcoal"}
+
+    basic = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Basic"}
+    )
+    assert basic.status_code == 200, basic.text
+    assert basic.json() == {"color_name": "Black"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_falls_back_to_first_when_material_unknown(async_client: AsyncClient):
+    """Unknown / unsupplied material → priority-order fallback, same as
+    ``/colors/map`` so existing flat-map callers don't regress."""
+    await _seed_black_collision(async_client)
+
+    # Unknown material → first Bambu Lab entry wins (matches /map's priority).
+    unknown = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA-Nope"}
+    )
+    assert unknown.status_code == 200, unknown.text
+    assert unknown.json()["color_name"] in {"Charcoal", "Black"}
+
+    # No material at all → same fallback.
+    nomat = await async_client.get("/api/v1/inventory/colors/by-material", params={"hex": "#000000"})
+    assert nomat.status_code == 200, nomat.text
+    assert nomat.json()["color_name"] in {"Charcoal", "Black"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_returns_null_when_hex_missing(async_client: AsyncClient):
+    """Hex not present in the catalog → color_name=None (do NOT 404)."""
+    response = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#abcdef", "material": "PLA Matte"},
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {"color_name": None}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_case_insensitive_on_both_inputs(async_client: AsyncClient):
+    """Lookup must tolerate mixed-case hex (legacy imports stored ``#B39B84``-
+    style upper-case) and material (frontend derives material from sub-brand
+    names whose casing isn't pinned). The endpoint uses ``func.lower`` on
+    ``hex_color`` and lower-cases ``material`` before equality, so both
+    directions of the case mismatch must round-trip."""
+    # Seed an upper-case stored hex to exercise the lower-cased comparison.
+    seed = await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Bambu Lab",
+            "color_name": "Iridium Gold Metallic",
+            "hex_color": "#B39B84",  # stored upper-case
+            "material": "PLA Metal",
+        },
+    )
+    assert seed.status_code == 200, seed.text
+
+    # Query the upper-case hex with lower-case input — must still match.
+    lower_query = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#b39b84", "material": "PLA Metal"},
+    )
+    assert lower_query.status_code == 200
+    assert lower_query.json() == {"color_name": "Iridium Gold Metallic"}
+
+    # Material is matched case-insensitively too.
+    await _seed_black_collision(async_client)
+    mixed_mat = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#000000", "material": "pla matte"},
+    )
+    assert mixed_mat.json() == {"color_name": "Charcoal"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_rejects_short_hex(async_client: AsyncClient):
+    """Invalid hex (< 6 chars after stripping '#') → color_name=None, no crash."""
+    response = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#abc", "material": "PLA Matte"},
+    )
+    assert response.status_code == 200
+    assert response.json() == {"color_name": None}

+ 62 - 0
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -227,4 +227,66 @@ describe('FilamentMapping — FTS routing', () => {
       expect(screen.queryByText(/Bambu PETG/)).not.toBeInTheDocument();
     });
   });
+
+  it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
+    // Same fix as FilamentOverride: required-side label was rendering the
+    // raw 3MF type ("PLA") and the generic getColorName bucket ("Black").
+    // After the shared useFilamentLabels hook it must now resolve
+    // tray_info_idx → "Bambu PLA Matte" and the material-disambiguated
+    // colour catalogue → "Charcoal" — the Specific-Printer panel matched
+    // the Any-Model panel that was already correct.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },
+            }),
+          ),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+        const url = new URL(request.url);
+        if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') {
+          return HttpResponse.json({ color_name: 'Charcoal' });
+        }
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const charcoalReqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
+      ],
+    };
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={charcoalReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Required-side type text picks up the resolved sub-brand.
+    await waitFor(() => {
+      expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument();
+    });
+    // The swatch tooltip carries the disambiguated "Charcoal" instead of
+    // the generic "Black" bucket; check the title attr on the colour
+    // circle's parent span.
+    await waitFor(() => {
+      const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/);
+      expect(swatch).toBeInTheDocument();
+    });
+  });
 });

+ 240 - 1
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -6,8 +6,10 @@
  */
 
 import { describe, it, expect, vi, afterEach } from 'vitest';
-import { screen, fireEvent, cleanup } from '@testing-library/react';
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
+import { server } from '../mocks/server';
 import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
 import type { FilamentReqsData } from '../../components/PrintModal/types';
 
@@ -337,4 +339,241 @@ describe('FilamentOverride', () => {
       expect(mockOnChange).toHaveBeenCalledWith({});
     });
   });
+
+  describe('original-label SKU resolution (#1718)', () => {
+    it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
+      // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
+      // carries type=PLA + the GFA01 SKU. Without resolution the label
+      // collapses to "PLA (Black)" which was Sam's bug.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#1A1A1A', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // Wait for the queries to resolve and the resolved label to land in
+      // the dropdown's "original" placeholder option. The tooltip on the
+      // color swatch carries the same text, so we scope to the option to
+      // avoid the multi-match.
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
+      });
+    });
+
+    it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
+      // Cloud user-preset names are more specific than the builtin fallback —
+      // e.g. a user has renamed GFA00 to "My House PLA".
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () =>
+          HttpResponse.json({ GFA00: 'My House PLA' }),
+        ),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA00' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/My House PLA/);
+      });
+      // The builtin fallback must NOT bleed through anywhere — neither the
+      // placeholder option nor the tooltip.
+      expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
+    });
+
+    it('uses the material-disambiguated catalogue color name (PLA Matte Charcoal — #1718 round 2)', async () => {
+      // Sam's exact case: 3MF carries hex #000000 + tray_info_idx GFA01.
+      // Without material context, /colors/map collapses #000000 to "Black"
+      // (PLA Basic wins the priority race). The override panel must pass
+      // the derived material hint "PLA Matte" through to /colors/by-material
+      // so the user sees "Charcoal" — the actually-sliced color.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+          const url = new URL(request.url);
+          const hex = url.searchParams.get('hex');
+          const material = url.searchParams.get('material');
+          if (hex === '#000000' && material === 'PLA Matte') {
+            return HttpResponse.json({ color_name: 'Charcoal' });
+          }
+          return HttpResponse.json({ color_name: null });
+        }),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+      });
+    });
+
+    it('disambiguates per slot when two slots share a hex but differ in material', async () => {
+      // Regression guard: the per-slot useQueries dispatch must key on
+      // (hex, material) so a "PLA Matte Charcoal" slot does not adopt the
+      // "PLA Basic Black" slot's answer.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([
+            { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+            { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+          ]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+          const url = new URL(request.url);
+          const material = url.searchParams.get('material');
+          if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
+          if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
+          return HttpResponse.json({ color_name: null });
+        }),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+          { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3.2, tray_info_idx: 'GFA00' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const selects = screen.getAllByRole('combobox');
+        expect(selects).toHaveLength(2);
+        expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+        expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
+      });
+    });
+
+    it('falls back to getColorName(hex) when the by-material lookup returns null', async () => {
+      // Any time the catalogue has no entry for the hex (or the endpoint is
+      // unreachable), the placeholder must still render — the HSL-bucket
+      // fallback is strictly better than a blank.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        http.get('/api/v1/inventory/colors/by-material', () =>
+          HttpResponse.json({ color_name: null }),
+        ),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // Wait for the builtin lookup to land so we know the row mounted; the
+      // colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
+        expect(placeholder?.textContent).not.toMatch(/null/);
+      });
+    });
+
+    it('falls back to the raw type when the SKU is unknown to both maps', async () => {
+      // Unknown ids must not break rendering — the original "PLA" label is
+      // still better than a blank.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFXXX' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // (25g) is the easiest signal the row mounted at all; once it's there,
+      // assert the placeholder option carries the raw type.
+      await waitFor(() => {
+        expect(screen.getByText('(25g)')).toBeInTheDocument();
+      });
+      const select = screen.getByRole('combobox');
+      const placeholder = select.querySelector('option[value=""]');
+      expect(placeholder?.textContent).toMatch(/PLA \(/);
+    });
+  });
 });

+ 230 - 0
frontend/src/__tests__/hooks/useFilamentLabels.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * Tests for the shared filament-label resolution hook (#1718 round 3).
+ *
+ * Round 3 extracted the three-query resolution machinery out of
+ * ``FilamentOverride`` so the printer-mode ``FilamentMapping`` could share
+ * the same label logic without drift. Both panels are integration-tested
+ * already, but the hook deserves direct coverage so future edits don't break
+ * a subtle contract (positional output alignment, fallback chain, query
+ * dedup when the SKU is unknown).
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { http, HttpResponse } from 'msw';
+import { type ReactNode } from 'react';
+import { server } from '../mocks/server';
+import { extractMaterialHint, useFilamentLabels } from '../../components/PrintModal/useFilamentLabels';
+
+function makeWrapper() {
+  // Fresh QueryClient per renderHook so cached data from one test doesn't
+  // bleed into the next — the hook keys queries on (hex, materialHint), so a
+  // stale "PLA Matte → Charcoal" cache entry would silently mask a misrouted
+  // request in a later test.
+  const client = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+  return function Wrapper({ children }: { children: ReactNode }) {
+    return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+  };
+}
+
+afterEach(() => {
+  cleanup();
+});
+
+describe('extractMaterialHint', () => {
+  it('strips the leading brand token from multi-word names', () => {
+    expect(extractMaterialHint('Bambu PLA Matte')).toBe('PLA Matte');
+    expect(extractMaterialHint('PolyLite ABS')).toBe('ABS');
+    expect(extractMaterialHint('Bambu PLA-CF')).toBe('PLA-CF');
+  });
+
+  it('returns single-word names unchanged so "PLA" stays "PLA"', () => {
+    // The catalog's ``material`` column has plain "PLA" entries; passing the
+    // single token through means the by-material lookup can still match.
+    expect(extractMaterialHint('PLA')).toBe('PLA');
+    expect(extractMaterialHint('PETG-HF')).toBe('PETG-HF');
+  });
+
+  it('collapses interior whitespace and trims edges', () => {
+    expect(extractMaterialHint('  Bambu   PLA   Matte  ')).toBe('PLA Matte');
+  });
+
+  it('returns "" when the input is blank', () => {
+    // Empty material is the same priority-fallback case as omitting the param.
+    expect(extractMaterialHint('')).toBe('');
+    expect(extractMaterialHint('   ')).toBe('');
+  });
+});
+
+describe('useFilamentLabels', () => {
+  it('returns [] for undefined or empty inputs', () => {
+    const { result, rerender } = renderHook(
+      ({ reqs }: { reqs: undefined | Array<{ type: string; color: string }> }) =>
+        useFilamentLabels(reqs),
+      { wrapper: makeWrapper(), initialProps: { reqs: undefined } },
+    );
+    expect(result.current).toEqual([]);
+
+    rerender({ reqs: [] });
+    expect(result.current).toEqual([]);
+  });
+
+  it('resolves tray_info_idx → sub-brand via the builtin map', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: 'Charcoal' }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+      expect(result.current[0]?.colorLabel).toBe('Charcoal');
+    });
+  });
+
+  it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
+    // Round 2 contract: same id in both maps → cloud wins because the user-
+    // authored name is the more specific label.
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () =>
+        HttpResponse.json({ GFA00: 'My House PLA' }),
+      ),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: null }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('My House PLA');
+    });
+  });
+
+  it('falls back to req.type when the SKU is unknown to both maps', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: null }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PETG-HF', color: '#00FF00', tray_info_idx: 'GFXXX' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('PETG-HF');
+    });
+  });
+
+  it('falls back colorLabel to getColorName(hex) when the by-material lookup returns null', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: null }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      // Anything non-empty from getColorName is fine; the critical contract
+      // is "never returns the empty string / null in the colorLabel".
+      expect(result.current[0]?.colorLabel).toBeTruthy();
+      expect(result.current[0]?.colorLabel).not.toBe('null');
+    });
+  });
+
+  it('keeps positional alignment across slots with different (hex, material) tuples', async () => {
+    // Regression guard for the position-indexed output contract: labels[i]
+    // MUST correspond to reqs[i]. If useQueries answered out-of-order or
+    // dedup'd same-hex slots, FilamentMapping would render a PLA Matte
+    // Charcoal slot as PLA Basic Black (and vice versa).
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([
+          { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+          { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+        ]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+        const material = new URL(request.url).searchParams.get('material');
+        if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
+        if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const { result } = renderHook(
+      () =>
+        useFilamentLabels([
+          { type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }, // PLA Matte
+          { type: 'PLA', color: '#000000', tray_info_idx: 'GFA00' }, // PLA Basic
+        ]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+      expect(result.current[0]?.colorLabel).toBe('Charcoal');
+      expect(result.current[1]?.resolvedName).toBe('Bambu PLA Basic');
+      expect(result.current[1]?.colorLabel).toBe('Black');
+    });
+  });
+
+  it('skips the by-material query when the slot has no hex (enabled: !!color)', async () => {
+    // Defensive: 3MFs occasionally leave the color attribute blank. The
+    // query is gated on truthy color so we don't fire a request that we
+    // know can't disambiguate anything.
+    let byMaterialCalls = 0;
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () => {
+        byMaterialCalls += 1;
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      // The sub-brand half resolves from the builtin map even without a hex.
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+    });
+    expect(byMaterialCalls).toBe(0);
+  });
+});

+ 14 - 0
frontend/src/api/client.ts

@@ -4529,6 +4529,20 @@ export const api = {
   getFilamentIdMap: () =>
     request<Record<string, string>>('/cloud/filament-id-map'),
 
+  /** Material-disambiguated hex→name lookup. Same hex can map to different
+   *  catalog names depending on material (e.g. #000000 is "Charcoal" in PLA
+   *  Matte but "Black" in PLA Basic). The flat ``/inventory/colors/map``
+   *  collapses these to the first hit; this endpoint preserves the material
+   *  context. Returns ``{color_name: null}`` when the hex isn't in the
+   *  catalog at all. #1718. */
+  getColorByMaterial: (hex: string, material?: string) => {
+    const params = new URLSearchParams({ hex });
+    if (material) params.set('material', material);
+    return request<{ color_name: string | null }>(
+      `/inventory/colors/by-material?${params.toString()}`,
+    );
+  },
+
   // MakerWorld URL-paste import flow.
   getMakerworldStatus: () =>
     request<MakerworldStatus>('/makerworld/status'),

+ 15 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId } from '../../utils/amsHelpers';
 import { getColorName } from '../../utils/colors';
+import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentMappingProps } from './types';
 
 /**
@@ -44,6 +45,13 @@ export function FilamentMapping({
   const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
     useFilamentMapping(filamentReqs, printerStatus, manualMappings);
 
+  // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
+  // shared hook the model-mode FilamentOverride uses so both panels render
+  // the same sliced-3MF identity. Falls back to the raw type / generic
+  // colour bucket when the SKU is unknown or the by-material lookup hasn't
+  // resolved — never blanks out the required row.
+  const filamentLabels = useFilamentLabels(filamentReqs?.filaments);
+
   const trayCostMap = useMemo(() => {
     const map = new Map<number, number | null>();
     for (const assignment of assignments || []) {
@@ -192,6 +200,11 @@ export function FilamentMapping({
             // scheduler honors the flag in both modes; only the UI was missing.
             const slotId = item.slot_id ?? 0;
             const canForceMatch = slotId > 0 && onForceColorMatchChange != null;
+            // #1718: same sub-brand + colour resolution as FilamentOverride.
+            // Indexing is safe because ``useFilamentLabels`` mirrors the input
+            // array shape; defensive fallback covers the empty-reqs render
+            // path that shouldn't reach here anyway.
+            const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) };
             return (
             <div key={idx} className="space-y-1">
               <div
@@ -199,7 +212,7 @@ export function FilamentMapping({
                 style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
               >
                 {/* Required color */}
-                <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                <span title={`Required: ${resolvedName} - ${colorLabel}`}>
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
                 {/* Required type + grams + nozzle badge */}
@@ -212,7 +225,7 @@ export function FilamentMapping({
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                     </span>
                   )}
-                  {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                  {resolvedName} <span className="text-bambu-gray">({item.used_grams}g)</span>
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>

+ 18 - 4
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Circle, RotateCcw, Palette } from 'lucide-react';
 import { getColorName } from '../../utils/colors';
 import { canonicalFilamentType } from '../../utils/amsHelpers';
+import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentReqsData } from './types';
 
 interface FilamentOverrideProps {
@@ -32,6 +33,11 @@ export function FilamentOverride({
 }: FilamentOverrideProps) {
   const { t } = useTranslation();
 
+  // Per-slot sub-brand + material-disambiguated colour labels (#1718). The
+  // shared hook fronts the three queries that power the resolution so this
+  // component and ``FilamentMapping`` cannot drift apart on label content.
+  const labels = useFilamentLabels(filamentReqs?.filaments);
+
   // Index available filaments by canonical type for per-slot filtering.
   // Types in the same equivalence group (e.g. PA-CF / PA12-CF / PAHT-CF) share one bucket.
   const filamentsByType = useMemo(() => {
@@ -69,7 +75,7 @@ export function FilamentOverride({
       </div>
       <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
       <div className="bg-bambu-dark rounded-lg p-3 space-y-2">
-        {filaments.map((req) => {
+        {filaments.map((req, slotIdx) => {
           const override = overrides[req.slot_id];
           const isOverridden = !!override;
           // Only show filaments of the same type AND compatible nozzle/extruder
@@ -81,6 +87,14 @@ export function FilamentOverride({
             ? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)
             : sameType;
 
+          // #1718: sub-brand resolved from the 3MF's tray_info_idx via the
+          // builtin / cloud-id maps, plus the material-disambiguated catalogue
+          // colour for the hex. Both fall back gracefully (resolvedName →
+          // req.type when the SKU is unknown; colorLabel → getColorName(hex)
+          // when the by-material lookup hasn't resolved yet, returned null,
+          // or errored) so a slow query never blanks out the row.
+          const { resolvedName, colorLabel } = labels[slotIdx] ?? { resolvedName: req.type, colorLabel: getColorName(req.color) };
+
           return (
             <div key={req.slot_id} className="space-y-1">
               <div
@@ -88,12 +102,12 @@ export function FilamentOverride({
                 style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
               >
                 {/* Original color swatch */}
-                <span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
+                <span title={`${t('printModal.originalFilament')}: ${resolvedName} - ${colorLabel}`}>
                   <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
                 </span>
                 {/* Original type + grams */}
                 <span className="text-white truncate">
-                  {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+                  {resolvedName} <span className="text-bambu-gray">({req.used_grams}g)</span>
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
@@ -109,7 +123,7 @@ export function FilamentOverride({
                   }`}
                 >
                   <option value="" className="bg-bambu-dark text-bambu-gray">
-                    {t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
+                    {t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
                   </option>
                   {compatible.map((f, idx) => (
                     <option

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -183,6 +183,10 @@ export interface FilamentReqsData {
     used_grams: number;
     used_meters: number;
     nozzle_id?: number;
+    /** Bambu SKU code from the 3MF (e.g. `GFA01` = Bambu PLA Matte, `P4d64437`
+     *  = user custom). Used to resolve the "original" filament label in
+     *  FilamentOverride against the builtin + cloud user-preset maps. #1718. */
+    tray_info_idx?: string;
   }>;
 }
 

+ 118 - 0
frontend/src/components/PrintModal/useFilamentLabels.ts

@@ -0,0 +1,118 @@
+import { useMemo } from 'react';
+import { useQueries, useQuery } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import { getColorName } from '../../utils/colors';
+
+/** Strip a leading brand token (the first whitespace-separated word) from a
+ *  resolved filament name so what remains can be matched against the color
+ *  catalog's ``material`` column. Examples:
+ *    "Bambu PLA Matte"   → "PLA Matte"
+ *    "PolyLite ABS"      → "ABS"
+ *    "Bambu PLA-CF"      → "PLA-CF"
+ *    "PLA"               → "PLA"        (no brand to strip; pass through)
+ *    "Devil Design PLA"  → "Design PLA" (won't match catalog → falls back
+ *                                        to priority-order answer, no regression)
+ *  Never returns ``""`` — the empty-material case is the same priority
+ *  fallback as omitting the param.
+ */
+export function extractMaterialHint(name: string): string {
+  const parts = name.trim().split(/\s+/);
+  if (parts.length <= 1) return name.trim();
+  return parts.slice(1).join(' ');
+}
+
+export interface FilamentLabel {
+  /** Bambu sub-brand from the SKU lookup ("Bambu PLA Matte") falling back to
+   *  the raw 3MF ``type`` ("PLA") when the SKU is unknown to both maps. */
+  resolvedName: string;
+  /** Material-disambiguated catalogue color ("Charcoal") falling back to
+   *  ``getColorName(hex)`` when the by-material lookup hasn't resolved yet,
+   *  returned null, or errored. Always non-empty. */
+  colorLabel: string;
+}
+
+interface FilamentReqLike {
+  type: string;
+  color: string;
+  tray_info_idx?: string;
+}
+
+/**
+ * Resolve per-slot human-readable labels for the schedule modal's filament
+ * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
+ * mode ``FilamentMapping`` consume this so the two panels render the same
+ * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
+ * the inline implementation in ``FilamentOverride`` so the two callers can't
+ * drift.
+ *
+ * Three queries back the resolution:
+ *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
+ *     "Bambu PLA Matte" etc.).
+ *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
+ *     map (P-prefix). Wins over the builtin entry for the same id.
+ *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
+ *     ``useQueries``, keyed on hex + material hint) → catalog color name
+ *     disambiguated by material context.
+ *
+ * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
+ * an empty array when ``reqs`` is undefined / empty so callers can safely
+ * index without a length check.
+ */
+export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+  const { data: builtinFilaments } = useQuery({
+    queryKey: ['builtin-filaments'],
+    queryFn: () => api.getBuiltinFilaments(),
+    staleTime: 5 * 60 * 1000,
+  });
+  const { data: cloudFilamentIdMap } = useQuery({
+    queryKey: ['filament-id-map'],
+    queryFn: () => api.getFilamentIdMap(),
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const filamentNameByIdx = useMemo(() => {
+    const map: Record<string, string> = {};
+    for (const f of builtinFilaments || []) {
+      if (f.filament_id) map[f.filament_id] = f.name;
+    }
+    // Cloud user-preset map wins when both have the same id — the user-
+    // authored name is the more specific label.
+    for (const [fid, name] of Object.entries(cloudFilamentIdMap || {})) {
+      if (fid && name) map[fid] = name;
+    }
+    return map;
+  }, [builtinFilaments, cloudFilamentIdMap]);
+
+  // Compute the per-slot (resolvedName, materialHint) pairs up-front so the
+  // ``useQueries`` call below has a stable shape and the render path below
+  // can reuse the same resolvedName without recomputing.
+  const perSlot = useMemo(() => {
+    return (reqs || []).map((req) => {
+      const resolvedName = (req.tray_info_idx && filamentNameByIdx[req.tray_info_idx]) || req.type;
+      return {
+        resolvedName,
+        materialHint: extractMaterialHint(resolvedName),
+        color: req.color,
+      };
+    });
+  }, [reqs, filamentNameByIdx]);
+
+  const colorQueries = useQueries({
+    queries: perSlot.map(({ color, materialHint }) => ({
+      queryKey: ['color-by-material', color, materialHint],
+      queryFn: () => api.getColorByMaterial(color, materialHint),
+      // Treat empty colour as "nothing to look up" so we don't spam the
+      // endpoint for entries the 3MF left blank.
+      enabled: !!color,
+      staleTime: 5 * 60 * 1000,
+    })),
+  });
+
+  return perSlot.map(({ resolvedName, color }, idx) => {
+    const disambiguated = colorQueries[idx]?.data?.color_name ?? null;
+    return {
+      resolvedName,
+      colorLabel: disambiguated || getColorName(color),
+    };
+  });
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Dtvr3nyH.js


+ 1 - 1
static/index.html

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است