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
3901043238

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **An AMS slot could name the wrong white** — 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, and `#000000` is Charcoal in PLA Matte where it is Black everywhere else. The slot popover looked the colour up by hex alone, and the lookup table can only keep one name per hex — so an ivory Matte spool was titled "Jade White" even while the profile line beside it correctly read Matte Ivory. The colour map now also carries the names that collapsing loses, keyed by material, and every slot resolves its colour with the material the printer reports for it (`tray_sub_brands`). Slots with a spool assigned from Inventory are titled with that spool's own colour name, which is the roll the user actually put in. Only a name the same brand's own range lost is carried — one manufacturer's name must not displace another's — so the added map is 11 entries against the 608 in the shipped catalog.
 - **A queued job switched on printers that could never have printed it** — When no printer of the target model was available, the queue powered one on via its smart plug, but chose it on model alone: it walked the farm in printer-ID order, woke the first machine with an Auto On plug, and only then discovered the loaded filament was the wrong colour. A job for a colour loaded at the far end of the farm therefore woke every earlier printer in turn and left each one running until its own auto-power-off timer expired. The colours were known the whole time — a printer keeps its last reported AMS and external-spool trays after the power goes — so the wake step now asks the same three questions the matcher asks a live printer (required types, forced colours, preferred colours) and passes over a printer whose last known filament cannot satisfy the job. A printer Bambuddy has never heard from is still woken: no reading is not the same as no filament. Reconnecting a printer also no longer discards that reading, so a power-on attempt that times out stops erasing what the next attempt needs.
 - **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before.
 - **Slicing a file could ignore the process preset you picked** — Bambuddy carries a designer's own process deviations across a re-slice (#2622) and pre-ticked all of them except the machine-coupled ones. `layer_height` is one that MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 — the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now treated like the machine-coupled keys: still offered, never pre-selected, and their badge in the settings panel names the conflict and shows the preset's own value beside the file's, so ticking one is a deliberate choice.

+ 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