mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user