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
@@ -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),
};
});
}