Parcourir la source

Name an AMS slot's colour by its material, not its hex alone (issue #2875)

    A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
    Basic, Ivory White in PLA Matte and plain White in six other materials;
    popover resolved its title from the hex alone, against a map that keeps
    one name per hex, so an ivory Matte spool read "Jade White" while the
    profile line beside it correctly read Matte Ivory.

    /inventory/colors/map now carries the names collapsing loses, keyed
    "<material>|<hex>". An entry is emitted only when it recovers a name the
    same manufacturer's own range lost -- 11 of them against the 608 colours
    in the shipped catalog. Both halves matter: a name equal to the flat
    answer is weight, and a name from another brand is not a recovery, it
    would put Prusament's "Pristine White" on every generic white PLA slot.

    A slot with a spool assigned from Inventory is titled with that spool's
    own colour name: it is the roll the user said is in there. Bambu
    internal codes are still rejected as non-names (#857).
maziggy il y a 2 semaines
Parent
commit
5925e387f0

+ 39 - 5
backend/app/api/routes/inventory.py

@@ -744,6 +744,21 @@ async def get_color_name_map(
     Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
     share the same hex (different materials or manufacturers), Bambu Lab wins,
     then default entries, then the first encountered.
+
+    ``by_material`` carries the names that collapsing loses. A hex is not one
+    colour in Bambu's range: #FFFFFF is Jade White in PLA Basic, Ivory White in
+    PLA Matte and plain White in six more, and #000000 is Black except in PLA
+    Matte where it is Charcoal. A caller that knows the material — an AMS slot
+    knows it as ``tray_sub_brands`` — looks up ``"<material>|<hex>"`` there
+    first and falls back to ``colors`` (#2875).
+
+    An entry is included only when it recovers a name the *same manufacturer's*
+    own range lost. Two conditions, both load-bearing: a name equal to the
+    collapsed one is pure weight, and a name from a different manufacturer is
+    not a recovery at all — it would put Prusament's "Pristine White" on every
+    generic white PLA slot in place of Bambu's "Jade White", trading one
+    arbitrary answer for another. What survives is the handful of cases this
+    exists for.
     """
     result = await db.execute(
         select(
@@ -751,24 +766,43 @@ async def get_color_name_map(
             ColorCatalogEntry.color_name,
             ColorCatalogEntry.manufacturer,
             ColorCatalogEntry.is_default,
+            ColorCatalogEntry.material,
         )
     )
-    mapping: dict[str, tuple[str, int]] = {}  # hex → (name, priority); higher priority wins
-    for hex_color, color_name, manufacturer, is_default in result.all():
+    # hex → (name, priority, manufacturer); higher priority wins, first on a tie
+    mapping: dict[str, tuple[str, int, str]] = {}
+    by_material: dict[str, tuple[str, int, str]] = {}  # "material|hex" → same
+    for hex_color, color_name, manufacturer, is_default, material in result.all():
         if not hex_color or not color_name:
             continue
         key = hex_color.lstrip("#").lower()[:6]
         if len(key) != 6:
             continue
+        brand = (manufacturer or "").strip().lower()
         priority = 0
-        if manufacturer and manufacturer.strip().lower() == "bambu lab":
+        if brand == "bambu lab":
             priority += 2
         if is_default:
             priority += 1
         existing = mapping.get(key)
         if existing is None or priority > existing[1]:
-            mapping[key] = (color_name, priority)
-    return {"colors": {k: v[0] for k, v in mapping.items()}}
+            mapping[key] = (color_name, priority, brand)
+        material_key = (material or "").strip().lower()
+        if material_key:
+            # Split on the LAST separator when reading these back: a material is
+            # free text and may itself contain a '|'.
+            qualified = f"{material_key}|{key}"
+            existing = by_material.get(qualified)
+            if existing is None or priority > existing[1]:
+                by_material[qualified] = (color_name, priority, brand)
+
+    colors = {k: v[0] for k, v in mapping.items()}
+    qualified_colors = {}
+    for qualified, (name, _, brand) in by_material.items():
+        flat = mapping.get(qualified.rsplit("|", 1)[1])
+        if flat and flat[0] != name and flat[2] == brand:
+            qualified_colors[qualified] = name
+    return {"colors": colors, "by_material": qualified_colors}
 
 
 @router.post("/colors", response_model=ColorEntryResponse)

+ 249 - 1
backend/tests/integration/test_color_map_api.py

@@ -31,7 +31,7 @@ async def test_color_map_empty_catalog(async_client: AsyncClient):
     response = await async_client.get("/api/v1/inventory/colors/map")
     assert response.status_code == 200
     body = response.json()
-    assert body == {"colors": {}}
+    assert body == {"colors": {}, "by_material": {}}
 
 
 @pytest.mark.asyncio
@@ -163,3 +163,251 @@ async def test_color_map_skips_invalid_entries(async_client: AsyncClient, db_ses
     assert colors["f5b6cd"] == "Cherry Pink"
     # 3-char hex was dropped
     assert "fff" not in colors
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_keeps_the_name_collapsing_loses(async_client: AsyncClient, db_session):
+    """#2875: a hex is not one colour.
+
+    #FFFFFF is Jade White in PLA Basic and Ivory White in PLA Matte. Both are
+    Bambu Lab and both are seeded defaults, so the flat map's priority order
+    cannot separate them and falls back to insertion order -- which is why an
+    ivory spool showed as "Jade White" on the AMS slot popover. The material-
+    qualified map carries the name the flat one has to drop.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Jade White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Ivory White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["colors"]["ffffff"] == "Jade White"
+    assert body["by_material"]["pla matte|ffffff"] == "Ivory White"
+    # The row the flat map already answers correctly is not repeated.
+    assert "pla basic|ffffff" not in body["by_material"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_is_empty_when_nothing_is_ambiguous(async_client: AsyncClient, db_session):
+    """The qualified map costs only what the ambiguity costs.
+
+    It ships on every page load beside the full catalog, so an entry that says
+    the same thing as the flat map is pure weight.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Scarlet Red",
+                "hex_color": "#DE4343",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Cherry Pink",
+                "hex_color": "#F5B6CD",
+                "material": "PLA Translucent",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert len(body["colors"]) == 2
+    assert body["by_material"] == {}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_keys_are_normalized(async_client: AsyncClient, db_session):
+    """Same normalization as the flat map: lowercase, no '#'.
+
+    The frontend builds the lookup key from the printer's own
+    ``tray_sub_brands`` ("PLA Matte"), so both halves have to be case-folded
+    or the slot that needs this most never matches.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Black",
+                "hex_color": "#000000",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Charcoal",
+                "hex_color": "000000",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["by_material"] == {"pla matte|000000": "Charcoal"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_respects_the_same_priority_as_the_flat_map(async_client: AsyncClient, db_session):
+    """Two brands can share a hex *and* a material name. Bambu still wins.
+
+    The PLA Basic row comes first so the flat map keeps Jade White -- otherwise
+    the Matte answer would already be the flat one and the qualified entry
+    would be dropped as a duplicate, which proves nothing about the tie-break.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Jade White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Generic",
+                "color_name": "Off White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Matte",
+                "is_default": False,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Ivory White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["colors"]["ffffff"] == "Jade White"
+    assert body["by_material"]["pla matte|ffffff"] == "Ivory White"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rows_without_a_material_stay_out_of_the_qualified_map(async_client: AsyncClient, db_session):
+    """A row with no material cannot answer a material-qualified question."""
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Jade White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Generic",
+                "color_name": "Some White",
+                "hex_color": "#FFFFFF",
+                "material": None,
+                "is_default": False,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["by_material"] == {}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_does_not_hand_one_brands_name_to_another(async_client: AsyncClient, db_session):
+    """A qualified entry must recover a name, not substitute one.
+
+    The shipped catalog has Prusament "Pristine White" under material "PLA" on
+    the same #FFFFFF that Bambu's "Jade White" holds. A slot reporting plain
+    "PLA" — any third-party spool — would otherwise stop saying Jade White and
+    start saying Pristine White, trading one arbitrary answer for another for a
+    case nobody asked about. Only same-manufacturer recoveries are emitted.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Jade White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Prusament",
+                "color_name": "Pristine White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Ivory White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["colors"]["ffffff"] == "Jade White"
+    # The Bambu variant is recovered; the other brand's name is not offered.
+    assert body["by_material"] == {"pla matte|ffffff": "Ivory White"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_material_containing_the_separator_is_still_read_correctly(async_client: AsyncClient, db_session):
+    """Material is free text — users edit the catalog — so it can contain '|'.
+
+    The hex is everything after the LAST separator, never the first.
+    """
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Jade White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA Basic",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Ivory White",
+                "hex_color": "#FFFFFF",
+                "material": "PLA|Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    body = (await async_client.get("/api/v1/inventory/colors/map")).json()
+
+    assert body["by_material"] == {"pla|matte|ffffff": "Ivory White"}

+ 105 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -6,6 +6,7 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { render, screen, fireEvent, waitFor } from '../utils';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../../components/FilamentHoverCard';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 
 const baseFilamentData = {
   vendor: 'Bambu Lab' as const,
@@ -569,3 +570,107 @@ describe('EmptySlotHoverCard (#1133)', () => {
     });
   });
 });
+
+describe('FilamentHoverCard colour name (#2875)', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+    __resetColorCatalogForTests();
+  });
+
+  const whiteMatte = {
+    ...baseFilamentData,
+    profile: 'Bambu PLA Matte',
+    colorHex: 'FFFFFFFF',
+    // What PrintersPage now resolves with the slot's own tray_sub_brands.
+    colorName: 'Ivory White',
+  };
+
+  async function showCard(ui: React.ReactElement) {
+    renderWithHover(ui);
+    vi.advanceTimersByTime(100);
+  }
+
+  it('shows the resolved catalogue name for the slot', async () => {
+    await showCard(
+      <FilamentHoverCard data={whiteMatte}>
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+
+    await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
+    expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
+  });
+
+  it('prefers the assigned spool name, which is what the user put in the slot', async () => {
+    await showCard(
+      <FilamentHoverCard
+        data={{ ...whiteMatte, colorName: 'Jade White' }}
+        inventory={{
+          isAssigned: true,
+          assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: 'Matte Ivory White' },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+
+    await waitFor(() => expect(screen.getByText('Matte Ivory White')).toBeInTheDocument());
+    expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
+  });
+
+  it('ignores a Bambu internal colour code on the assigned spool', async () => {
+    // "A06-D0" is not a name, and is not unique across material families
+    // (#857) -- the catalogue answer stands.
+    await showCard(
+      <FilamentHoverCard
+        data={whiteMatte}
+        inventory={{
+          isAssigned: true,
+          assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: 'A06-D0' },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+
+    await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
+    expect(screen.queryByText('A06-D0')).not.toBeInTheDocument();
+  });
+
+  it.each([
+    ['an empty colour name', ''],
+    ['a whitespace-only colour name', '   '],
+  ])('keeps the catalogue answer for a spool with %s', async (_label, colorName) => {
+    await showCard(
+      <FilamentHoverCard
+        data={whiteMatte}
+        inventory={{
+          isAssigned: true,
+          assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: colorName },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+
+    await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
+  });
+
+  it('keeps the catalogue answer when a spool is assigned with no colour recorded', async () => {
+    setColorCatalog({ ffffff: 'Jade White' }, { 'pla matte|ffffff': 'Ivory White' });
+
+    await showCard(
+      <FilamentHoverCard
+        data={whiteMatte}
+        inventory={{
+          isAssigned: true,
+          assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: null },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+
+    await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
+  });
+});

+ 43 - 0
frontend/src/__tests__/contexts/ColorCatalogContext.test.tsx

@@ -81,6 +81,49 @@ describe('ColorCatalogProvider', () => {
     expect(getColorName('8344b0')).toBe('Purple');
   });
 
+  it('carries the material-qualified names through to getColorName (#2875)', async () => {
+    server.use(
+      http.get('/api/v1/inventory/colors/map', () =>
+        HttpResponse.json({
+          colors: { ffffff: 'Jade White' },
+          by_material: { 'pla matte|ffffff': 'Ivory White' },
+        })
+      )
+    );
+
+    const Wrapper = createWrapper();
+    render(
+      <Wrapper>
+        <div>ok</div>
+      </Wrapper>
+    );
+
+    await waitFor(() => {
+      expect(getColorName('ffffff', 'PLA Matte')).toBe('Ivory White');
+    });
+    expect(getColorName('ffffff')).toBe('Jade White');
+  });
+
+  it('accepts a response with no by_material at all', async () => {
+    // An older backend, or a catalog with nothing ambiguous in it.
+    server.use(
+      http.get('/api/v1/inventory/colors/map', () =>
+        HttpResponse.json({ colors: { ffffff: 'Jade White' } })
+      )
+    );
+
+    const Wrapper = createWrapper();
+    render(
+      <Wrapper>
+        <div>ok</div>
+      </Wrapper>
+    );
+
+    await waitFor(() => {
+      expect(getColorName('ffffff', 'PLA Matte')).toBe('Jade White');
+    });
+  });
+
   it('still renders children even when the catalog fetch fails', async () => {
     server.use(
       http.get('/api/v1/inventory/colors/map', () =>

+ 63 - 0
frontend/src/__tests__/utils/colors.test.ts

@@ -91,6 +91,69 @@ describe('getColorName', () => {
   });
 });
 
+describe('getColorName with a material (#2875)', () => {
+  beforeEach(() => {
+    __resetColorCatalogForTests();
+    // What the backend ships for a real Bambu catalog: the flat map can only
+    // keep one name for #FFFFFF, and the qualified map carries the rest.
+    setColorCatalog(
+      { ffffff: 'Jade White', '000000': 'Black' },
+      { 'pla matte|ffffff': 'Ivory White', 'pla matte|000000': 'Charcoal' },
+    );
+  });
+
+  it('returns the material-specific name when the caller knows the material', () => {
+    expect(getColorName('FFFFFFFF', 'PLA Matte')).toBe('Ivory White');
+    expect(getColorName('000000FF', 'PLA Matte')).toBe('Charcoal');
+  });
+
+  it('still returns the flat name for a material with no entry of its own', () => {
+    expect(getColorName('FFFFFFFF', 'PLA Basic')).toBe('Jade White');
+    expect(getColorName('FFFFFFFF', 'Some Third Party Filament')).toBe('Jade White');
+  });
+
+  it('ignores case and padding in the material, as tray_sub_brands is not normalized', () => {
+    expect(getColorName('FFFFFFFF', '  pla MATTE ')).toBe('Ivory White');
+  });
+
+  it('falls back to the flat name when no material is passed', () => {
+    expect(getColorName('FFFFFFFF')).toBe('Jade White');
+    expect(getColorName('FFFFFFFF', null)).toBe('Jade White');
+    expect(getColorName('FFFFFFFF', '')).toBe('Jade White');
+  });
+
+  it('keeps Clear ahead of any catalog lookup for a transparent spool', () => {
+    expect(getColorName('FFFFFF00', 'PLA Matte')).toBe('Clear');
+  });
+
+  it('falls back to HSL when neither map knows the hex', () => {
+    expect(getColorName('5F6367', 'PLA Matte')).toBe('Dark Gray');
+  });
+
+  it('survives a catalog with no qualified map at all', () => {
+    __resetColorCatalogForTests();
+    setColorCatalog({ ffffff: 'Jade White' });
+    expect(getColorName('FFFFFFFF', 'PLA Matte')).toBe('Jade White');
+  });
+
+  it('reads the hex from the last separator, so a material may contain one', () => {
+    // Material is free text -- users edit the colour catalog.
+    __resetColorCatalogForTests();
+    setColorCatalog({ ffffff: 'Jade White' }, { 'pla|matte|ffffff': 'Ivory White' });
+    expect(getColorName('FFFFFFFF', 'PLA|Matte')).toBe('Ivory White');
+  });
+
+  it('ignores malformed qualified keys instead of poisoning the map', () => {
+    __resetColorCatalogForTests();
+    setColorCatalog(
+      { ffffff: 'Jade White' },
+      { 'pla matte|nothex': 'Nope', '|ffffff': 'Nope', 'pla matte': 'Nope', 'pla matte|ffffff': 'Ivory White' },
+    );
+    expect(getColorName('FFFFFFFF', 'PLA Matte')).toBe('Ivory White');
+    expect(getColorName('FFFFFFFF')).toBe('Jade White');
+  });
+});
+
 describe('resolveSpoolColorName', () => {
   beforeEach(() => {
     __resetColorCatalogForTests();

+ 5 - 1
frontend/src/api/client.ts

@@ -6250,8 +6250,12 @@ export const api = {
     request<{ status: string }>(`/inventory/locations/${id}`, { method: 'DELETE' }),
   getColorCatalog: () =>
     request<ColorCatalogEntry[]>('/inventory/colors'),
+  /** Flat hex→name map, plus the names collapsing it loses. ``by_material`` is
+   *  keyed ``"<material>|<hex>"`` and only carries entries that differ from the
+   *  flat answer (e.g. ``"pla matte|ffffff" -> "Ivory White"`` where the flat
+   *  map says "Jade White"). #2875. */
   getColorNameMap: () =>
-    request<{ colors: Record<string, string> }>('/inventory/colors/map'),
+    request<{ colors: Record<string, string>; by_material?: Record<string, string> }>('/inventory/colors/map'),
   addColorEntry: (data: {
     manufacturer: string;
     color_name: string;

+ 17 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -3,11 +3,15 @@ import { createPortal } from 'react-dom';
 import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { Droplets, Copy, Check, Settings2, Package, Unlink } from 'lucide-react';
-import { isLightColor } from '../utils/colors';
+import { isLightColor, resolveSpoolColorName } from '../utils/colors';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
   profile: string;
+  /** Catalogue name for the loaded colour. Callers that know the material
+   *  should resolve it with ``getColorName(hex, tray_sub_brands)`` -- a white
+   *  Matte spool is Ivory White, not the Jade White that shares its hex
+   *  (#2875). An assigned spool overrides this; see ``displayColorName``. */
   colorName: string;
   colorHex: string | null;
   kFactor: string;
@@ -185,6 +189,17 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   };
 
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
+
+  // An assigned spool outranks any hex lookup: it is the roll the user put in
+  // this slot, named by whoever created it, and it is already shown two rows
+  // below under ASSIGNED. Passing a null rgba keeps the helper to its
+  // readable-name test -- a Bambu internal code like "A06-D0" is not a name
+  // (#857) and must not displace the catalogue answer, which the caller has
+  // already resolved with the slot's material (#2875).
+  // Trimmed, so a spool saved with a whitespace-only colour name leaves the
+  // swatch reading the catalogue answer instead of reading blank.
+  const assignedColorName = resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim();
+  const displayColorName = assignedColorName || data.colorName;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
   return (
@@ -238,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                 font-semibold text-sm tracking-wide
                 ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
               `}>
-                {data.colorName}
+                {displayColorName}
               </div>
 
               {/* Vendor badge - solid background for visibility on any color */}

+ 1 - 1
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -143,7 +143,7 @@ export function FilamentOverride({
                       value={`${f.type}|${f.color}`}
                       className="bg-bambu-dark text-white"
                     >
-                    {f.tray_sub_brands || f.type} ({getColorName(f.color)})
+                    {f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
                     </option>
                   ))}
                 </select>

+ 2 - 2
frontend/src/contexts/ColorCatalogContext.tsx

@@ -26,7 +26,7 @@ export function ColorCatalogProvider({ children }: { children: ReactNode }) {
     queryKey: ['color-catalog-map'],
     queryFn: async () => {
       const response = await api.getColorNameMap();
-      return response.colors;
+      return { colors: response.colors, byMaterial: response.by_material ?? {} };
     },
     // Catalog rarely changes during a session; no background refetch needed.
     staleTime: Infinity,
@@ -37,7 +37,7 @@ export function ColorCatalogProvider({ children }: { children: ReactNode }) {
 
   useEffect(() => {
     if (data) {
-      setColorCatalog(data);
+      setColorCatalog(data.colors, data.byMaterial);
     }
   }, [data]);
 

+ 2 - 2
frontend/src/hooks/useFilamentMapping.ts

@@ -44,7 +44,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         filaments.push({
           type: tray.tray_type,
           color,
-          colorName: getColorName(color),
+          colorName: getColorName(color, tray.tray_sub_brands),
           amsId: amsUnit.id,
           trayId: tray.id,
           isHt,
@@ -69,7 +69,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       filaments.push({
         type: extTray.tray_type,
         color,
-        colorName: getColorName(color),
+        colorName: getColorName(color, extTray.tray_sub_brands),
         amsId: -1,
         trayId: trayId - 254,
         isHt: false,

+ 3 - 3
frontend/src/pages/PrintersPage.tsx

@@ -5407,7 +5407,7 @@ function PrinterCard({
                                   // vendor-less form. Strip the "@<printer>..." suffix that
                                   // BambuStudio appends to user-preset names.
                                   profile: slotPreset?.preset_name || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
-                                  colorName: getColorName(tray.tray_color || ''),
+                                  colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: effectiveFill,
@@ -5695,7 +5695,7 @@ function PrinterCard({
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: slotPreset?.preset_name || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
-                          colorName: getColorName(tray.tray_color || ''),
+                          colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           fillLevel: htEffectiveFill,
@@ -6114,7 +6114,7 @@ function PrinterCard({
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 profile: extSlotPreset?.preset_name || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                                colorName: getColorName(extTray.tray_color || ''),
+                                colorName: getColorName(extTray.tray_color || '', extTray.tray_sub_brands),
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 fillLevel: extEffectiveFill,

+ 32 - 2
frontend/src/utils/colors.ts

@@ -10,10 +10,16 @@
 // arrives instead of staying stuck on the HSL fallback.
 
 let runtimeColorCatalog: Record<string, string> = {};
+// Names a plain hex lookup cannot reach, keyed "<material>|<hex>". A hex is not
+// one colour in Bambu's range -- #FFFFFF is Jade White in PLA Basic and Ivory
+// White in PLA Matte -- so a caller that knows the material (an AMS slot knows
+// it as tray_sub_brands) gets the right one instead of whichever row the
+// backend's collapse happened to keep (#2875).
+let runtimeMaterialCatalog: Record<string, string> = {};
 let catalogVersion = 0;
 const catalogListeners = new Set<() => void>();
 
-export function setColorCatalog(map: Record<string, string>): void {
+export function setColorCatalog(map: Record<string, string>, byMaterial?: Record<string, string>): void {
   // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already
   // does this, but the frontend contract is explicit so callers from tests or
   // future integrations can't accidentally break lookups.
@@ -23,7 +29,19 @@ export function setColorCatalog(map: Record<string, string>): void {
     const hex = key.replace('#', '').toLowerCase().slice(0, 6);
     if (hex.length === 6) normalized[hex] = value;
   }
+  const normalizedByMaterial: Record<string, string> = {};
+  for (const [key, value] of Object.entries(byMaterial || {})) {
+    if (!key || !value) continue;
+    // Split on the LAST separator: a material is free text (users edit the
+    // catalog) and may itself contain a '|'.
+    const cut = key.lastIndexOf('|');
+    if (cut <= 0) continue;
+    const material = key.slice(0, cut).trim().toLowerCase();
+    const cleanHex = key.slice(cut + 1).replace('#', '').toLowerCase().slice(0, 6);
+    if (material && cleanHex.length === 6) normalizedByMaterial[`${material}|${cleanHex}`] = value;
+  }
   runtimeColorCatalog = normalized;
+  runtimeMaterialCatalog = normalizedByMaterial;
   catalogVersion += 1;
   // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes.
   for (const listener of Array.from(catalogListeners)) {
@@ -45,6 +63,7 @@ export function getColorCatalogVersion(): number {
 /** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */
 export function __resetColorCatalogForTests(): void {
   runtimeColorCatalog = {};
+  runtimeMaterialCatalog = {};
   catalogVersion = 0;
   catalogListeners.clear();
 }
@@ -210,12 +229,23 @@ export function colorSortKey(rgba: string | null | undefined): string {
 /**
  * Get color name from hex color.
  * Looks up the runtime color catalog (backend-sourced), then falls back to HSL.
+ *
+ * Pass `material` whenever the caller knows which variant the colour belongs to
+ * -- for an AMS slot that is the printer's own `tray_sub_brands` ("PLA Matte").
+ * Without it a white Matte spool reads "Jade White", the PLA Basic name that
+ * shares its hex, because the flat map can only keep one name per hex (#2875).
+ * An unknown material falls through to the flat lookup, so passing one can only
+ * ever improve the answer.
  */
-export function getColorName(hexColor: string): string {
+export function getColorName(hexColor: string, material?: string | null): string {
   if (!hexColor) return hexToColorName(hexColor);
   const clean = hexColor.replace('#', '').toLowerCase();
   if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
   const hex = clean.substring(0, 6);
+  if (material) {
+    const qualified = runtimeMaterialCatalog[`${material.trim().toLowerCase()}|${hex}`];
+    if (qualified) return qualified;
+  }
   const mapped = runtimeColorCatalog[hex];
   if (mapped) return mapped;
   return hexToColorName(hexColor);

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-3zC9-lDQ.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-BFwS0pWA.js"></script>
+    <script type="module" crossorigin src="/assets/index-3zC9-lDQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff