mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user