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

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

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

  FilamentMapping required-side now reads {resolvedName} instead of
  {item.type}; swatch tooltip reads `Required: {resolvedName} -
  {colorLabel}` instead of `Required: {item.type} -
  getColorName(item.color)`.
This commit is contained in:
maziggy
2026-06-11 14:49:52 +02:00
parent 3b1395e3c0
commit 6b477088a2
13 changed files with 1323 additions and 429 deletions
+2
View File
File diff suppressed because one or more lines are too long
+71
View File
@@ -513,6 +513,10 @@ class ColorLookupResult(BaseModel):
material: str | None = None
class ColorByMaterialResult(BaseModel):
color_name: str | None = None
# ── Spool Catalog CRUD ─────────────────────────────────────────────────────
@@ -790,6 +794,73 @@ async def lookup_color(
return ColorLookupResult(found=False)
@router.get("/colors/by-material", response_model=ColorByMaterialResult)
async def get_color_by_material(
hex: str,
material: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_auth_if_enabled),
):
"""Disambiguated hex→name lookup that respects material context.
``/colors/map`` collapses every catalog entry sharing a hex to a single
name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
"PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
This endpoint preserves the material context so the queue scheduler's
Filament Override label can show the actually-sliced sub-brand colour
instead of the generic bucket. #1718.
Returns ``color_name=None`` when the hex isn't in the catalog at all.
When the hex IS in the catalog but no entry matches the requested
material (or none was supplied), falls back to the same priority order
as ``/colors/map`` so callers without a material hint don't regress.
Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
every queue / archive view that renders a sliced filament colour needs
this, including read-only roles.
"""
key = hex.lstrip("#").lower()[:6]
if len(key) != 6:
return ColorByMaterialResult(color_name=None)
material_norm = (material or "").strip().lower()
# Catalog rows are stored as ``#RRGGBB`` (verified at write time and
# against production); lookup uses lower-cased hex equality so mixed-case
# writes from older imports still match.
result = await db.execute(
select(
ColorCatalogEntry.color_name,
ColorCatalogEntry.manufacturer,
ColorCatalogEntry.material,
ColorCatalogEntry.is_default,
).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
)
candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
if not candidates:
return ColorByMaterialResult(color_name=None)
if material_norm:
for name, _mfg, mat, _is_default in candidates:
if mat and mat.strip().lower() == material_norm:
return ColorByMaterialResult(color_name=name)
# Same priority order as ``/colors/map`` so a caller passing no (or an
# unrecognised) material gets the existing answer, not a degraded one.
best_name: str | None = None
best_priority = -1
for name, mfg, _mat, is_default in candidates:
priority = 0
if mfg and mfg.strip().lower() == "bambu lab":
priority += 2
if is_default:
priority += 1
if priority > best_priority:
best_name = name
best_priority = priority
return ColorByMaterialResult(color_name=best_name)
@router.get("/colors/search", response_model=list[ColorEntryResponse])
async def search_colors(
manufacturer: str | None = None,
@@ -157,3 +157,130 @@ async def test_create_spool_with_color_extras(async_client: AsyncClient):
assert patch.status_code == 200
assert patch.json()["extra_colors"] is None
assert patch.json()["effect_type"] is None
# ---- /colors/by-material — disambiguated lookup (#1718) -------------------
async def _seed_black_collision(client: AsyncClient) -> None:
"""Seed the #000000 ambiguity the endpoint was built to resolve.
PLA Matte → Charcoal, PLA Basic → Black, both at #000000 — same shape as
Bambu's production catalog.
"""
for entry in (
{
"manufacturer": "Bambu Lab",
"color_name": "Charcoal",
"hex_color": "#000000",
"material": "PLA Matte",
},
{
"manufacturer": "Bambu Lab",
"color_name": "Black",
"hex_color": "#000000",
"material": "PLA Basic",
},
):
response = await client.post("/api/v1/inventory/colors", json=entry)
assert response.status_code == 200, response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_by_material_returns_material_specific_name(async_client: AsyncClient):
"""Same hex + different material → returns the correctly-paired name."""
await _seed_black_collision(async_client)
matte = await async_client.get(
"/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Matte"}
)
assert matte.status_code == 200, matte.text
assert matte.json() == {"color_name": "Charcoal"}
basic = await async_client.get(
"/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Basic"}
)
assert basic.status_code == 200, basic.text
assert basic.json() == {"color_name": "Black"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_by_material_falls_back_to_first_when_material_unknown(async_client: AsyncClient):
"""Unknown / unsupplied material → priority-order fallback, same as
``/colors/map`` so existing flat-map callers don't regress."""
await _seed_black_collision(async_client)
# Unknown material → first Bambu Lab entry wins (matches /map's priority).
unknown = await async_client.get(
"/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA-Nope"}
)
assert unknown.status_code == 200, unknown.text
assert unknown.json()["color_name"] in {"Charcoal", "Black"}
# No material at all → same fallback.
nomat = await async_client.get("/api/v1/inventory/colors/by-material", params={"hex": "#000000"})
assert nomat.status_code == 200, nomat.text
assert nomat.json()["color_name"] in {"Charcoal", "Black"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_by_material_returns_null_when_hex_missing(async_client: AsyncClient):
"""Hex not present in the catalog → color_name=None (do NOT 404)."""
response = await async_client.get(
"/api/v1/inventory/colors/by-material",
params={"hex": "#abcdef", "material": "PLA Matte"},
)
assert response.status_code == 200, response.text
assert response.json() == {"color_name": None}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_by_material_case_insensitive_on_both_inputs(async_client: AsyncClient):
"""Lookup must tolerate mixed-case hex (legacy imports stored ``#B39B84``-
style upper-case) and material (frontend derives material from sub-brand
names whose casing isn't pinned). The endpoint uses ``func.lower`` on
``hex_color`` and lower-cases ``material`` before equality, so both
directions of the case mismatch must round-trip."""
# Seed an upper-case stored hex to exercise the lower-cased comparison.
seed = await async_client.post(
"/api/v1/inventory/colors",
json={
"manufacturer": "Bambu Lab",
"color_name": "Iridium Gold Metallic",
"hex_color": "#B39B84", # stored upper-case
"material": "PLA Metal",
},
)
assert seed.status_code == 200, seed.text
# Query the upper-case hex with lower-case input — must still match.
lower_query = await async_client.get(
"/api/v1/inventory/colors/by-material",
params={"hex": "#b39b84", "material": "PLA Metal"},
)
assert lower_query.status_code == 200
assert lower_query.json() == {"color_name": "Iridium Gold Metallic"}
# Material is matched case-insensitively too.
await _seed_black_collision(async_client)
mixed_mat = await async_client.get(
"/api/v1/inventory/colors/by-material",
params={"hex": "#000000", "material": "pla matte"},
)
assert mixed_mat.json() == {"color_name": "Charcoal"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_by_material_rejects_short_hex(async_client: AsyncClient):
"""Invalid hex (< 6 chars after stripping '#') → color_name=None, no crash."""
response = await async_client.get(
"/api/v1/inventory/colors/by-material",
params={"hex": "#abc", "material": "PLA Matte"},
)
assert response.status_code == 200
assert response.json() == {"color_name": None}
@@ -227,4 +227,66 @@ describe('FilamentMapping — FTS routing', () => {
expect(screen.queryByText(/Bambu PETG/)).not.toBeInTheDocument();
});
});
it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
// Same fix as FilamentOverride: required-side label was rendering the
// raw 3MF type ("PLA") and the generic getColorName bucket ("Black").
// After the shared useFilamentLabels hook it must now resolve
// tray_info_idx → "Bambu PLA Matte" and the material-disambiguated
// colour catalogue → "Charcoal" — the Specific-Printer panel matched
// the Any-Model panel that was already correct.
server.use(
http.get(
'/api/v1/printers/:id/status',
() =>
HttpResponse.json(
createStatus({
fila_switch: null,
ams_extruder_map: { '0': 1 },
}),
),
),
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') {
return HttpResponse.json({ color_name: 'Charcoal' });
}
return HttpResponse.json({ color_name: null });
}),
);
const charcoalReqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
],
};
render(
<FilamentMapping
printerId={1}
filamentReqs={charcoalReqs}
manualMappings={{}}
onManualMappingChange={() => {}}
currencySymbol="$"
defaultCostPerKg={0}
defaultExpanded
/>,
);
// Required-side type text picks up the resolved sub-brand.
await waitFor(() => {
expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument();
});
// The swatch tooltip carries the disambiguated "Charcoal" instead of
// the generic "Black" bucket; check the title attr on the colour
// circle's parent span.
await waitFor(() => {
const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/);
expect(swatch).toBeInTheDocument();
});
});
});
@@ -6,8 +6,10 @@
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { screen, fireEvent, cleanup } from '@testing-library/react';
import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
import type { FilamentReqsData } from '../../components/PrintModal/types';
@@ -337,4 +339,241 @@ describe('FilamentOverride', () => {
expect(mockOnChange).toHaveBeenCalledWith({});
});
});
describe('original-label SKU resolution (#1718)', () => {
it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
// Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
// carries type=PLA + the GFA01 SKU. Without resolution the label
// collapses to "PLA (Black)" which was Sam's bug.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#1A1A1A', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
// Wait for the queries to resolve and the resolved label to land in
// the dropdown's "original" placeholder option. The tooltip on the
// color swatch carries the same text, so we scope to the option to
// avoid the multi-match.
await waitFor(() => {
const select = screen.getByRole('combobox');
const placeholder = select.querySelector('option[value=""]');
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
});
});
it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
// Cloud user-preset names are more specific than the builtin fallback —
// e.g. a user has renamed GFA00 to "My House PLA".
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
),
http.get('/api/v1/cloud/filament-id-map', () =>
HttpResponse.json({ GFA00: 'My House PLA' }),
),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA00' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
await waitFor(() => {
const select = screen.getByRole('combobox');
const placeholder = select.querySelector('option[value=""]');
expect(placeholder?.textContent).toMatch(/My House PLA/);
});
// The builtin fallback must NOT bleed through anywhere — neither the
// placeholder option nor the tooltip.
expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
});
it('uses the material-disambiguated catalogue color name (PLA Matte Charcoal — #1718 round 2)', async () => {
// Sam's exact case: 3MF carries hex #000000 + tray_info_idx GFA01.
// Without material context, /colors/map collapses #000000 to "Black"
// (PLA Basic wins the priority race). The override panel must pass
// the derived material hint "PLA Matte" through to /colors/by-material
// so the user sees "Charcoal" — the actually-sliced color.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
const url = new URL(request.url);
const hex = url.searchParams.get('hex');
const material = url.searchParams.get('material');
if (hex === '#000000' && material === 'PLA Matte') {
return HttpResponse.json({ color_name: 'Charcoal' });
}
return HttpResponse.json({ color_name: null });
}),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
await waitFor(() => {
const select = screen.getByRole('combobox');
const placeholder = select.querySelector('option[value=""]');
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
});
});
it('disambiguates per slot when two slots share a hex but differ in material', async () => {
// Regression guard: the per-slot useQueries dispatch must key on
// (hex, material) so a "PLA Matte Charcoal" slot does not adopt the
// "PLA Basic Black" slot's answer.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([
{ filament_id: 'GFA00', name: 'Bambu PLA Basic' },
{ filament_id: 'GFA01', name: 'Bambu PLA Matte' },
]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
const url = new URL(request.url);
const material = url.searchParams.get('material');
if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
return HttpResponse.json({ color_name: null });
}),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
{ slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3.2, tray_info_idx: 'GFA00' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
await waitFor(() => {
const selects = screen.getAllByRole('combobox');
expect(selects).toHaveLength(2);
expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
});
});
it('falls back to getColorName(hex) when the by-material lookup returns null', async () => {
// Any time the catalogue has no entry for the hex (or the endpoint is
// unreachable), the placeholder must still render — the HSL-bucket
// fallback is strictly better than a blank.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', () =>
HttpResponse.json({ color_name: null }),
),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
// Wait for the builtin lookup to land so we know the row mounted; the
// colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
await waitFor(() => {
const select = screen.getByRole('combobox');
const placeholder = select.querySelector('option[value=""]');
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
expect(placeholder?.textContent).not.toMatch(/null/);
});
});
it('falls back to the raw type when the SKU is unknown to both maps', async () => {
// Unknown ids must not break rendering — the original "PLA" label is
// still better than a blank.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
);
const reqs: FilamentReqsData = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFXXX' },
],
};
render(
<FilamentOverride
filamentReqs={reqs}
availableFilaments={defaultAvailable}
overrides={{}}
onChange={mockOnChange}
/>,
);
// (25g) is the easiest signal the row mounted at all; once it's there,
// assert the placeholder option carries the raw type.
await waitFor(() => {
expect(screen.getByText('(25g)')).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
const placeholder = select.querySelector('option[value=""]');
expect(placeholder?.textContent).toMatch(/PLA \(/);
});
});
});
@@ -0,0 +1,230 @@
/**
* Tests for the shared filament-label resolution hook (#1718 round 3).
*
* Round 3 extracted the three-query resolution machinery out of
* ``FilamentOverride`` so the printer-mode ``FilamentMapping`` could share
* the same label logic without drift. Both panels are integration-tested
* already, but the hook deserves direct coverage so future edits don't break
* a subtle contract (positional output alignment, fallback chain, query
* dedup when the SKU is unknown).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { type ReactNode } from 'react';
import { server } from '../mocks/server';
import { extractMaterialHint, useFilamentLabels } from '../../components/PrintModal/useFilamentLabels';
function makeWrapper() {
// Fresh QueryClient per renderHook so cached data from one test doesn't
// bleed into the next — the hook keys queries on (hex, materialHint), so a
// stale "PLA Matte → Charcoal" cache entry would silently mask a misrouted
// request in a later test.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
afterEach(() => {
cleanup();
});
describe('extractMaterialHint', () => {
it('strips the leading brand token from multi-word names', () => {
expect(extractMaterialHint('Bambu PLA Matte')).toBe('PLA Matte');
expect(extractMaterialHint('PolyLite ABS')).toBe('ABS');
expect(extractMaterialHint('Bambu PLA-CF')).toBe('PLA-CF');
});
it('returns single-word names unchanged so "PLA" stays "PLA"', () => {
// The catalog's ``material`` column has plain "PLA" entries; passing the
// single token through means the by-material lookup can still match.
expect(extractMaterialHint('PLA')).toBe('PLA');
expect(extractMaterialHint('PETG-HF')).toBe('PETG-HF');
});
it('collapses interior whitespace and trims edges', () => {
expect(extractMaterialHint(' Bambu PLA Matte ')).toBe('PLA Matte');
});
it('returns "" when the input is blank', () => {
// Empty material is the same priority-fallback case as omitting the param.
expect(extractMaterialHint('')).toBe('');
expect(extractMaterialHint(' ')).toBe('');
});
});
describe('useFilamentLabels', () => {
it('returns [] for undefined or empty inputs', () => {
const { result, rerender } = renderHook(
({ reqs }: { reqs: undefined | Array<{ type: string; color: string }> }) =>
useFilamentLabels(reqs),
{ wrapper: makeWrapper(), initialProps: { reqs: undefined } },
);
expect(result.current).toEqual([]);
rerender({ reqs: [] });
expect(result.current).toEqual([]);
});
it('resolves tray_info_idx → sub-brand via the builtin map', async () => {
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', () =>
HttpResponse.json({ color_name: 'Charcoal' }),
),
);
const { result } = renderHook(
() => useFilamentLabels([{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
expect(result.current[0]?.colorLabel).toBe('Charcoal');
});
});
it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
// Round 2 contract: same id in both maps → cloud wins because the user-
// authored name is the more specific label.
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
),
http.get('/api/v1/cloud/filament-id-map', () =>
HttpResponse.json({ GFA00: 'My House PLA' }),
),
http.get('/api/v1/inventory/colors/by-material', () =>
HttpResponse.json({ color_name: null }),
),
);
const { result } = renderHook(
() => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00' }]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
expect(result.current[0]?.resolvedName).toBe('My House PLA');
});
});
it('falls back to req.type when the SKU is unknown to both maps', async () => {
server.use(
http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', () =>
HttpResponse.json({ color_name: null }),
),
);
const { result } = renderHook(
() => useFilamentLabels([{ type: 'PETG-HF', color: '#00FF00', tray_info_idx: 'GFXXX' }]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
expect(result.current[0]?.resolvedName).toBe('PETG-HF');
});
});
it('falls back colorLabel to getColorName(hex) when the by-material lookup returns null', async () => {
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', () =>
HttpResponse.json({ color_name: null }),
),
);
const { result } = renderHook(
() => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA01' }]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
// Anything non-empty from getColorName is fine; the critical contract
// is "never returns the empty string / null in the colorLabel".
expect(result.current[0]?.colorLabel).toBeTruthy();
expect(result.current[0]?.colorLabel).not.toBe('null');
});
});
it('keeps positional alignment across slots with different (hex, material) tuples', async () => {
// Regression guard for the position-indexed output contract: labels[i]
// MUST correspond to reqs[i]. If useQueries answered out-of-order or
// dedup'd same-hex slots, FilamentMapping would render a PLA Matte
// Charcoal slot as PLA Basic Black (and vice versa).
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([
{ filament_id: 'GFA00', name: 'Bambu PLA Basic' },
{ filament_id: 'GFA01', name: 'Bambu PLA Matte' },
]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
const material = new URL(request.url).searchParams.get('material');
if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
return HttpResponse.json({ color_name: null });
}),
);
const { result } = renderHook(
() =>
useFilamentLabels([
{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }, // PLA Matte
{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA00' }, // PLA Basic
]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
expect(result.current[0]?.colorLabel).toBe('Charcoal');
expect(result.current[1]?.resolvedName).toBe('Bambu PLA Basic');
expect(result.current[1]?.colorLabel).toBe('Black');
});
});
it('skips the by-material query when the slot has no hex (enabled: !!color)', async () => {
// Defensive: 3MFs occasionally leave the color attribute blank. The
// query is gated on truthy color so we don't fire a request that we
// know can't disambiguate anything.
let byMaterialCalls = 0;
server.use(
http.get('/api/v1/cloud/builtin-filaments', () =>
HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
),
http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
http.get('/api/v1/inventory/colors/by-material', () => {
byMaterialCalls += 1;
return HttpResponse.json({ color_name: null });
}),
);
const { result } = renderHook(
() => useFilamentLabels([{ type: 'PLA', color: '', tray_info_idx: 'GFA01' }]),
{ wrapper: makeWrapper() },
);
await waitFor(() => {
// The sub-brand half resolves from the builtin map even without a hex.
expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
});
expect(byMaterialCalls).toBe(0);
});
});
+14
View File
@@ -4529,6 +4529,20 @@ export const api = {
getFilamentIdMap: () =>
request<Record<string, string>>('/cloud/filament-id-map'),
/** Material-disambiguated hex→name lookup. Same hex can map to different
* catalog names depending on material (e.g. #000000 is "Charcoal" in PLA
* Matte but "Black" in PLA Basic). The flat ``/inventory/colors/map``
* collapses these to the first hit; this endpoint preserves the material
* context. Returns ``{color_name: null}`` when the hex isn't in the
* catalog at all. #1718. */
getColorByMaterial: (hex: string, material?: string) => {
const params = new URLSearchParams({ hex });
if (material) params.set('material', material);
return request<{ color_name: string | null }>(
`/inventory/colors/by-material?${params.toString()}`,
);
},
// MakerWorld URL-paste import flow.
getMakerworldStatus: () =>
request<MakerworldStatus>('/makerworld/status'),
@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId } from '../../utils/amsHelpers';
import { getColorName } from '../../utils/colors';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentMappingProps } from './types';
/**
@@ -44,6 +45,13 @@ export function FilamentMapping({
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
useFilamentMapping(filamentReqs, printerStatus, manualMappings);
// Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
// shared hook the model-mode FilamentOverride uses so both panels render
// the same sliced-3MF identity. Falls back to the raw type / generic
// colour bucket when the SKU is unknown or the by-material lookup hasn't
// resolved — never blanks out the required row.
const filamentLabels = useFilamentLabels(filamentReqs?.filaments);
const trayCostMap = useMemo(() => {
const map = new Map<number, number | null>();
for (const assignment of assignments || []) {
@@ -192,6 +200,11 @@ export function FilamentMapping({
// scheduler honors the flag in both modes; only the UI was missing.
const slotId = item.slot_id ?? 0;
const canForceMatch = slotId > 0 && onForceColorMatchChange != null;
// #1718: same sub-brand + colour resolution as FilamentOverride.
// Indexing is safe because ``useFilamentLabels`` mirrors the input
// array shape; defensive fallback covers the empty-reqs render
// path that shouldn't reach here anyway.
const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) };
return (
<div key={idx} className="space-y-1">
<div
@@ -199,7 +212,7 @@ export function FilamentMapping({
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
{/* Required color */}
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
<span title={`Required: ${resolvedName} - ${colorLabel}`}>
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
</span>
{/* Required type + grams + nozzle badge */}
@@ -212,7 +225,7 @@ export function FilamentMapping({
{item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
</span>
)}
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
{resolvedName} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Circle, RotateCcw, Palette } from 'lucide-react';
import { getColorName } from '../../utils/colors';
import { canonicalFilamentType } from '../../utils/amsHelpers';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentReqsData } from './types';
interface FilamentOverrideProps {
@@ -32,6 +33,11 @@ export function FilamentOverride({
}: FilamentOverrideProps) {
const { t } = useTranslation();
// Per-slot sub-brand + material-disambiguated colour labels (#1718). The
// shared hook fronts the three queries that power the resolution so this
// component and ``FilamentMapping`` cannot drift apart on label content.
const labels = useFilamentLabels(filamentReqs?.filaments);
// Index available filaments by canonical type for per-slot filtering.
// Types in the same equivalence group (e.g. PA-CF / PA12-CF / PAHT-CF) share one bucket.
const filamentsByType = useMemo(() => {
@@ -69,7 +75,7 @@ export function FilamentOverride({
</div>
<p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
<div className="bg-bambu-dark rounded-lg p-3 space-y-2">
{filaments.map((req) => {
{filaments.map((req, slotIdx) => {
const override = overrides[req.slot_id];
const isOverridden = !!override;
// Only show filaments of the same type AND compatible nozzle/extruder
@@ -81,6 +87,14 @@ export function FilamentOverride({
? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)
: sameType;
// #1718: sub-brand resolved from the 3MF's tray_info_idx via the
// builtin / cloud-id maps, plus the material-disambiguated catalogue
// colour for the hex. Both fall back gracefully (resolvedName →
// req.type when the SKU is unknown; colorLabel → getColorName(hex)
// when the by-material lookup hasn't resolved yet, returned null,
// or errored) so a slow query never blanks out the row.
const { resolvedName, colorLabel } = labels[slotIdx] ?? { resolvedName: req.type, colorLabel: getColorName(req.color) };
return (
<div key={req.slot_id} className="space-y-1">
<div
@@ -88,12 +102,12 @@ export function FilamentOverride({
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
>
{/* Original color swatch */}
<span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
<span title={`${t('printModal.originalFilament')}: ${resolvedName} - ${colorLabel}`}>
<Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
</span>
{/* Original type + grams */}
<span className="text-white truncate">
{req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
{resolvedName} <span className="text-bambu-gray">({req.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
@@ -109,7 +123,7 @@ export function FilamentOverride({
}`}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
{t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
{t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
</option>
{compatible.map((f, idx) => (
<option
@@ -183,6 +183,10 @@ export interface FilamentReqsData {
used_grams: number;
used_meters: number;
nozzle_id?: number;
/** Bambu SKU code from the 3MF (e.g. `GFA01` = Bambu PLA Matte, `P4d64437`
* = user custom). Used to resolve the "original" filament label in
* FilamentOverride against the builtin + cloud user-preset maps. #1718. */
tray_info_idx?: string;
}>;
}
@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import { getColorName } from '../../utils/colors';
/** Strip a leading brand token (the first whitespace-separated word) from a
* resolved filament name so what remains can be matched against the color
* catalog's ``material`` column. Examples:
* "Bambu PLA Matte" → "PLA Matte"
* "PolyLite ABS" → "ABS"
* "Bambu PLA-CF" → "PLA-CF"
* "PLA" → "PLA" (no brand to strip; pass through)
* "Devil Design PLA" → "Design PLA" (won't match catalog → falls back
* to priority-order answer, no regression)
* Never returns ``""`` — the empty-material case is the same priority
* fallback as omitting the param.
*/
export function extractMaterialHint(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length <= 1) return name.trim();
return parts.slice(1).join(' ');
}
export interface FilamentLabel {
/** Bambu sub-brand from the SKU lookup ("Bambu PLA Matte") falling back to
* the raw 3MF ``type`` ("PLA") when the SKU is unknown to both maps. */
resolvedName: string;
/** Material-disambiguated catalogue color ("Charcoal") falling back to
* ``getColorName(hex)`` when the by-material lookup hasn't resolved yet,
* returned null, or errored. Always non-empty. */
colorLabel: string;
}
interface FilamentReqLike {
type: string;
color: string;
tray_info_idx?: string;
}
/**
* Resolve per-slot human-readable labels for the schedule modal's filament
* panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
* mode ``FilamentMapping`` consume this so the two panels render the same
* sub-brand + disambiguated color for the same sliced 3MF. Extracted from
* the inline implementation in ``FilamentOverride`` so the two callers can't
* drift.
*
* Three queries back the resolution:
* - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
* "Bambu PLA Matte" etc.).
* - ``/cloud/filament-id-map`` → user custom cloud preset SKU → name
* map (P-prefix). Wins over the builtin entry for the same id.
* - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
* ``useQueries``, keyed on hex + material hint) → catalog color name
* disambiguated by material context.
*
* Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
* an empty array when ``reqs`` is undefined / empty so callers can safely
* index without a length check.
*/
export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
const { data: builtinFilaments } = useQuery({
queryKey: ['builtin-filaments'],
queryFn: () => api.getBuiltinFilaments(),
staleTime: 5 * 60 * 1000,
});
const { data: cloudFilamentIdMap } = useQuery({
queryKey: ['filament-id-map'],
queryFn: () => api.getFilamentIdMap(),
staleTime: 5 * 60 * 1000,
});
const filamentNameByIdx = useMemo(() => {
const map: Record<string, string> = {};
for (const f of builtinFilaments || []) {
if (f.filament_id) map[f.filament_id] = f.name;
}
// Cloud user-preset map wins when both have the same id — the user-
// authored name is the more specific label.
for (const [fid, name] of Object.entries(cloudFilamentIdMap || {})) {
if (fid && name) map[fid] = name;
}
return map;
}, [builtinFilaments, cloudFilamentIdMap]);
// Compute the per-slot (resolvedName, materialHint) pairs up-front so the
// ``useQueries`` call below has a stable shape and the render path below
// can reuse the same resolvedName without recomputing.
const perSlot = useMemo(() => {
return (reqs || []).map((req) => {
const resolvedName = (req.tray_info_idx && filamentNameByIdx[req.tray_info_idx]) || req.type;
return {
resolvedName,
materialHint: extractMaterialHint(resolvedName),
color: req.color,
};
});
}, [reqs, filamentNameByIdx]);
const colorQueries = useQueries({
queries: perSlot.map(({ color, materialHint }) => ({
queryKey: ['color-by-material', color, materialHint],
queryFn: () => api.getColorByMaterial(color, materialHint),
// Treat empty colour as "nothing to look up" so we don't spam the
// endpoint for entries the 3MF left blank.
enabled: !!color,
staleTime: 5 * 60 * 1000,
})),
});
return perSlot.map(({ resolvedName, color }, idx) => {
const disambiguated = colorQueries[idx]?.data?.color_name ?? null;
return {
resolvedName,
colorLabel: disambiguated || getColorName(color),
};
});
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BBKQG4M7.js"></script>
<script type="module" crossorigin src="/assets/index-Dtvr3nyH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
</head>
<body>