// Runtime color-name catalog, populated once at app startup by ColorCatalogProvider // from /api/inventory/colors/map. The backend color_catalog table is the single // source of truth — no hardcoded hex→name tables live on the frontend anymore. // // Keyed by lowercase 6-char hex (no leading '#'). Lookups before the provider has // fetched the catalog fall through to hexToColorName (HSL-based bucketing). A // subscribe/getSnapshot pair lets React components re-render via // useSyncExternalStore when the catalog loads, so pages that mount before the // fetch resolves (InventoryPage, PrintersPage) update to the catalog name once it // arrives instead of staying stuck on the HSL fallback. let runtimeColorCatalog: Record = {}; // Names a plain hex lookup cannot reach, keyed "|". A hex is not // one colour in Bambu's range -- #FFFFFF is Jade White in PLA Basic and Ivory // White in PLA Matte -- so a caller that knows the material (an AMS slot knows // it as tray_sub_brands) gets the right one instead of whichever row the // backend's collapse happened to keep (#2875). let runtimeMaterialCatalog: Record = {}; let catalogVersion = 0; const catalogListeners = new Set<() => void>(); export function setColorCatalog(map: Record, byMaterial?: Record): void { // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already // does this, but the frontend contract is explicit so callers from tests or // future integrations can't accidentally break lookups. const normalized: Record = {}; for (const [key, value] of Object.entries(map)) { if (!key || !value) continue; const hex = key.replace('#', '').toLowerCase().slice(0, 6); if (hex.length === 6) normalized[hex] = value; } const normalizedByMaterial: Record = {}; for (const [key, value] of Object.entries(byMaterial || {})) { if (!key || !value) continue; // Split on the LAST separator: a material is free text (users edit the // catalog) and may itself contain a '|'. const cut = key.lastIndexOf('|'); if (cut <= 0) continue; const material = key.slice(0, cut).trim().toLowerCase(); const cleanHex = key.slice(cut + 1).replace('#', '').toLowerCase().slice(0, 6); if (material && cleanHex.length === 6) normalizedByMaterial[`${material}|${cleanHex}`] = value; } runtimeColorCatalog = normalized; runtimeMaterialCatalog = normalizedByMaterial; catalogVersion += 1; // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes. for (const listener of Array.from(catalogListeners)) { listener(); } } export function subscribeColorCatalog(listener: () => void): () => void { catalogListeners.add(listener); return () => { catalogListeners.delete(listener); }; } export function getColorCatalogVersion(): number { return catalogVersion; } /** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */ export function __resetColorCatalogForTests(): void { runtimeColorCatalog = {}; runtimeMaterialCatalog = {}; catalogVersion = 0; catalogListeners.clear(); } /** * Colour families, in the order the Inventory's Color column sorts them (#2729). * * Chromatic families run in rainbow order with Brown after them (it is a dark * orange by hue and would otherwise split the oranges in half), then the * neutrals light-to-dark, then Clear. */ export const COLOR_FAMILY_ORDER = [ 'Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Blue', 'Purple', 'Pink', 'Brown', 'White', 'Light Gray', 'Gray', 'Dark Gray', 'Black', 'Clear', ] as const; export type ColorFamily = (typeof COLOR_FAMILY_ORDER)[number]; /** Families whose hue carries no meaning — see ``colorSortKey``. */ const ACHROMATIC_FAMILIES = new Set([ 'White', 'Light Gray', 'Gray', 'Dark Gray', 'Black', 'Clear', ]); interface Hsl { h: number; s: number; l: number; } /** Parse 6/8-char hex (with or without '#') to HSL, or null if unparseable. */ function hexToHsl(hex: string | null | undefined): Hsl | null { if (!hex || hex.length < 6) return null; const cleanHex = hex.replace('#', ''); const r = parseInt(cleanHex.substring(0, 2), 16); const g = parseInt(cleanHex.substring(2, 4), 16); const b = parseInt(cleanHex.substring(4, 6), 16); if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; const max = Math.max(r, g, b) / 255; const min = Math.min(r, g, b) / 255; const l = (max + min) / 2; let h = 0; let s = 0; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255; if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6; else h = ((rNorm - gNorm) / d + 4) / 6; } return { h: h * 360, s, l }; } /** * Classify a hex colour into one of ``COLOR_FAMILY_ORDER``, or null if the * value can't be parsed. * * This is the single source of truth for both the fallback colour *name* shown * when a hex isn't in the catalog and the *order* the Color column sorts in, so * the two cannot drift into disagreeing about what counts as brown or grey. */ export function colorFamily(hex: string | null | undefined): ColorFamily | null { if (!hex || hex.length < 6) return null; const cleanHex = hex.replace('#', ''); // Alpha=00 → fully transparent. Classify as 'Clear' before looking at RGB, // otherwise #00000000 (Bambu's transparent code) would come out 'Black' (#1545). if (cleanHex.length === 8 && cleanHex.substring(6, 8).toLowerCase() === '00') { return 'Clear'; } const hsl = hexToHsl(cleanHex); if (!hsl) return null; const { h, s, l } = hsl; if (l < 0.15) return 'Black'; if (l > 0.85) return 'White'; if (s < 0.15) { if (l < 0.4) return 'Dark Gray'; if (l > 0.6) return 'Light Gray'; return 'Gray'; } // Brown is orange/yellow hue with lower lightness if (h >= 15 && h < 45 && l < 0.45) return 'Brown'; if (h >= 45 && h < 70 && l < 0.40) return 'Brown'; if (h < 15 || h >= 345) return 'Red'; if (h < 45) return 'Orange'; if (h < 70) return 'Yellow'; if (h < 150) return 'Green'; if (h < 200) return 'Cyan'; if (h < 260) return 'Blue'; if (h < 290) return 'Purple'; return 'Pink'; } /** * Convert hex color to basic color name using HSL analysis. * Used as fallback when hex is not in the runtime catalog. */ export function hexToColorName(hex: string | null | undefined): string { return colorFamily(hex) ?? 'Unknown'; } /** * Sort key placing a spool colour in rainbow order (#2729, reporter @macwhiz). * * Returns a fixed-width string so it drops into the Inventory table's existing * ``string | number`` comparison with no extra plumbing, ascending = Red first. * * The issue asked for a straight hue → saturation → lightness sort, which does * not survive contact with a real inventory: a near-neutral still has a hue and * it can be anything. Measured against a 30-spool inventory, Titan Gray * (5F6367, hue 210°, saturation 0.04) landed between Sky Blue and Purple, and * 8B8889 (hue 340°, saturation 0.01) landed between Purple and Burgundy Red. * Black, white and silver happen to clump correctly — zero saturation sorts * first within hue 0 — but anything a shade off neutral flies into the colours. * * So the family from ``colorFamily`` leads, and the continuous sort the issue * asked for runs inside each family. Within a neutral family hue is discarded * rather than sorted on, for the same reason it is not trusted to pick the * family: ordering greys by 210° vs 340° is ordering them by noise. Neutrals go * light-to-dark instead, matching the order of the families themselves. * * Unparseable or missing colours sort last in ascending order, so a spool with * no colour recorded never leads the list. */ export function colorSortKey(rgba: string | null | undefined): string { const family = colorFamily(rgba); if (!family) return '99|0000|0000|0000'; const rank = String(COLOR_FAMILY_ORDER.indexOf(family)).padStart(2, '0'); const hsl = hexToHsl(rgba) ?? { h: 0, s: 0, l: 0 }; const pad4 = (n: number) => String(Math.round(n)).padStart(4, '0'); if (ACHROMATIC_FAMILIES.has(family)) { // Lightness descending, so lighter shades lead within the family just as // White leads Black across families. Hue is deliberately zeroed. return `${rank}|0000|${pad4(1000 - hsl.l * 1000)}|${pad4(hsl.s * 1000)}`; } return `${rank}|${pad4(hsl.h * 10)}|${pad4(hsl.s * 1000)}|${pad4(hsl.l * 1000)}`; } /** * Get color name from hex color. * Looks up the runtime color catalog (backend-sourced), then falls back to HSL. * * Pass `material` whenever the caller knows which variant the colour belongs to * -- for an AMS slot that is the printer's own `tray_sub_brands` ("PLA Matte"). * Without it a white Matte spool reads "Jade White", the PLA Basic name that * shares its hex, because the flat map can only keep one name per hex (#2875). * An unknown material falls through to the flat lookup, so passing one can only * ever improve the answer. */ export function getColorName(hexColor: string, material?: string | null): string { if (!hexColor) return hexToColorName(hexColor); const clean = hexColor.replace('#', '').toLowerCase(); if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear'; const hex = clean.substring(0, 6); if (material) { const qualified = runtimeMaterialCatalog[`${material.trim().toLowerCase()}|${hex}`]; if (qualified) return qualified; } const mapped = runtimeColorCatalog[hex]; if (mapped) return mapped; return hexToColorName(hexColor); } /** * Label two colours so a reader can tell which is which. * * `getColorName` resolves a hex against the catalogue and falls back to a * coarse family bucket, so a slicer profile's near-pure `#0028FF` and Bambu's * navy `#0A2989` are both called "Blue". A mismatch warning between those two * then reads as a contradiction of the two identical names printed either side * of it, which is the whole of #2941: the comparison was right, the labels gave * the user no way to see what it was comparing. * * When the names collide the hex is appended to both, because that is what * actually differs. Distinct names are returned untouched -- once the words * separate them the hex is noise. A side with no name falls back to its hex, and * a pair with no usable hex at all keeps the bare names rather than growing an * empty "()". */ export function disambiguateColorNames( first: { name?: string | null; hex?: string | null }, second: { name?: string | null; hex?: string | null }, ): [string, string] { const hexLabel = (hex?: string | null): string => { const clean = (hex ?? '').replace('#', '').trim().slice(0, 6).toUpperCase(); return /^[0-9A-F]{6}$/.test(clean) ? `#${clean}` : ''; }; const firstName = (first.name ?? '').trim(); const secondName = (second.name ?? '').trim(); const firstHex = hexLabel(first.hex); const secondHex = hexLabel(second.hex); if (!firstName || !secondName) return [firstName || firstHex, secondName || secondHex]; if (firstName.toLowerCase() !== secondName.toLowerCase()) return [firstName, secondName]; if (!firstHex && !secondHex) return [firstName, secondName]; return [ firstHex ? `${firstName} (${firstHex})` : firstName, secondHex ? `${secondName} (${secondHex})` : secondName, ]; } /** * Resolve a spool's display color name. * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null. * Detects Bambu internal codes (e.g. "A06-D0") and ignores them in favor of hex lookup * because the same code is not globally unique across material families (#857). */ export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null { // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) { return colorName; } if (rgba && rgba.length >= 6) { const clean = rgba.replace('#', '').toLowerCase(); // Transparent rgba: don't fall through to RGB-based lookup that would // return 'Black' for #00000000 (#1545). if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear'; const hex = clean.substring(0, 6); const mapped = runtimeColorCatalog[hex]; if (mapped) return mapped; } // Return null (displayed as "-") — better than showing a code return null; } /** * Build a hex string suitable for SVG `fill=` / props that take a single * colour value. Preserves the alpha byte when alpha < FF so a transparent * spool renders translucent in SVG / CSS rather than collapsing to solid * black (#1545). Null / malformed input falls back to `#808080`. * * Prefer `getSwatchStyle` for `style` objects that paint a div background — * that helper paints a visible checkerboard under transparent fills. */ export function spoolColorString(rgba: string | null | undefined): string { if (!rgba) return '#808080'; const clean = rgba.replace(/^#/, ''); if (clean.length < 6) return '#808080'; if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') { return `#${clean.substring(0, 8)}`; } return `#${clean.substring(0, 6)}`; } /** * Build an inline-style object for a simple filament swatch (a div / button * background) given a spool's rgba. Opaque colours return a plain * `backgroundColor`; transparent (alpha=00) returns a small checkerboard * pattern so the user can see the swatch instead of an invisible element * (#1545). Null / unparseable input falls back to the neutral `#808080` used * elsewhere in the codebase. * * Use this anywhere a quick swatch was previously painted via * `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` — alpha-stripping * silently turned `Clear` spools into solid black. * * NOTE: `FilamentSwatch` already paints a richer checkerboard underlay * automatically for translucent colours; prefer that for new code and use * this helper only when retro-fitting an existing simple swatch site. */ export function getSwatchStyle(rgba: string | null | undefined): { backgroundColor?: string; backgroundImage?: string; backgroundSize?: string; } { if (!rgba) return { backgroundColor: '#808080' }; const clean = rgba.replace(/^#/, ''); if (clean.length < 6) return { backgroundColor: '#808080' }; if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() === '00') { return { backgroundImage: 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)', backgroundSize: '8px 8px', }; } return { backgroundColor: `#${clean.substring(0, 6)}` }; } /** * Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color. * Returns null for empty, all-zero, or fully transparent colors. */ export function parseFilamentColor(rgba: string): string | null { if (!rgba || rgba === '00000000' || rgba.length < 6) return null; const r = rgba.slice(0, 2); const g = rgba.slice(2, 4); const b = rgba.slice(4, 6); const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1; if (a === 0) return null; return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`; } /** * Check if a hex color is light (for choosing text contrast). * Uses luminance formula: 0.299*R + 0.587*G + 0.114*B. */ export function isLightColor(hex: string | null): boolean { if (!hex || hex.length < 6) return false; const cleanHex = hex.replace('#', ''); // Transparent swatches are painted over the light/mid-gray checkerboard // underlay, so treat them as light for text-contrast purposes (#1545). if (cleanHex.length === 8 && cleanHex.slice(6, 8).toLowerCase() === '00') return true; const r = parseInt(cleanHex.slice(0, 2), 16); const g = parseInt(cleanHex.slice(2, 4), 16); const b = parseInt(cleanHex.slice(4, 6), 16); return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6; }