Forráskód Böngészése

Say which blue a colour mismatch is about (issue #2941)

A print was refused a colour match between two filaments the dialog itself
labelled "Blue". The comparison was right: the slicer profile asked for a
near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
blue channel alone and a CIEDE2000 distance of 15, where 1 is a
just-noticeable difference. Nothing on screen said so. A hex that misses the
colour catalogue is named by a coarse family bucket, so both sides resolved
to the same word, and the warning sat between two identical labels with
nothing to reconcile it against. Read as a broken matcher, which is what the
report said, and a fair reading of what was shown.

Where both sides of a mismatch carry the same name they are now qualified by
their hex, and the tooltip names them together: "Same type, different color:
needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
left alone -- the hex is noise once the words separate them -- and a side with
no name falls back to its hex rather than growing an empty bracket.

The comparison is untouched. It was correct, and its tolerance is not
something to widen on one report: a difference that size would start matching
navy to cyan, and eligibility is the same rule the queue scheduler dispatches
on, so loosening it would change which spool gets printed rather than only
which warning gets shown. This issue was about being able to see why the
warning fired, and a test pins that it still fires.

The strings around it were hardcoded English: the panel's status line, the
required-filament tooltip, the auto-matched marker, the slot placeholder, the
mismatch detail and the type-not-found message. All nine now go through
translation, in all thirteen locales. Two of them -- "Same type, different
color" and "Filament type not loaded" -- turned out to have had translations
sitting in every locale file the whole time, unused, while the component
rendered the English literal beside them. The parity gate could not have
caught any of this: keys that were never added have nothing to compare
against.
maziggy 1 hete
szülő
commit
cbbdab86f9

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 ### Fixed
+- **A colour mismatch was reported between two filaments the app itself called "Blue" (#2941)** — The print dialog compared a slicer profile's near-pure `#0028FF` against the Bambu navy `#0A2989` loaded in A4 and correctly said they differ: 118 apart in the blue channel alone, a CIEDE2000 distance of 15 where 1 is a just-noticeable difference. Nothing in the dialog said so. A hex that misses the colour catalogue is named by a coarse family bucket, so both sides resolved to the name "Blue", and the warning sat between two identical labels with no way to tell what it was objecting to — the reporter read it, reasonably, as the matcher being broken. Where both sides of a mismatch carry the same name they are now qualified by their hex, and the tooltip names them together: "Same type, different color: needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are left alone, since the hex is noise once the words separate them. The comparison itself is untouched — it was right, and its tolerance is not the kind of thing to widen on a single report: admitting a difference that large would start matching navy to cyan, and the eligibility rule is shared with the queue scheduler, which would then dispatch on it. The panel's own status line, the required-filament tooltip, the auto-matched/manual marker, the slot placeholder and the type-not-found message were all hardcoded English; they are translated now, in all thirteen locales, and two of them turned out to have had translations sitting unused in every locale file the whole time.
 - **A print that failed on an `hms[]` fault recorded an unlookupable error code** — The queue's failure reason is built by formatting the fault's module and error into `MMMM_EEEE`, and that one derivation never masked the error to 16 bits. A fault arriving from the printer's `hms[]` array carries its alert level in the code's high half, so the label came out as e.g. `0500_3000A` — five digits in a group that has four. It is not a code the user can look up on Bambu's HMS index, and because it matches no catalogue key the sentence explaining the failure was dropped along with it, leaving the bracketed number alone. The nozzle-size mismatch behind #1111 is exactly such a fault: it reads as `[0500_4038] The nozzle diameter in sliced file is not consistent...` when the printer reports it one way and read as a bare `[0500_24038]` when it reported it the other. There was already a helper that gets this right and is used by the archive's own failure-reason lookup; the queue's now calls it instead of keeping a fourth copy of the derivation.
 - **AMS slots were offered as places to store a spool** — The Storage Location dropdown in the spool editor listed entries like "H2D-1 - AMS A1" alongside real locations, and they could not be removed. They are not locations at all: Bambuddy used to record which slot a spool was loaded into by writing that string into Spoolman's `location` field, and although that writer went away when Storage Location became something the user picks, the strings stayed on people's Spoolman spools — where the location sync, which imports every distinct one it finds, has been reading them back ever since. A printer slot is where a spool is loaded, not where it is put away, and Bambuddy already tracks the first through slot assignments. Deleting one by hand did not work either, which is what made this a dead end rather than an annoyance: the delete route refuses a location that has spools, and in Spoolman mode it counts them by matching that same string, so every marker still sitting on a loaded spool answered 409 — and the two that were empty came back on the next sync a minute later. The import now skips them, and the ones already in the catalogue are removed on upgrade. The filter is deliberately narrow, matching only the shape Bambuddy itself wrote — an optional printer-name prefix followed by `AMS A1`, `AMS-HT A1` or `External Spool` — so "AMS Drybox" and "Spare AMS trays" are left alone; anything it swallowed would be a place the user could no longer file a spool under. A row is only removed when no spool in Bambuddy's own database points at it, by id or by legacy free-text name, so an internal-mode user who has deliberately filed spools under such a name keeps it. Spools in Spoolman are not touched: their location strings are the user's data on the user's server, and one that still reads "H2D-1 - AMS A1" in the inventory list is telling the truth about what Spoolman holds — it simply stops being offered as a destination.
 - **A wood, silk or gradient roll the AMS added for you was drawn as a flat disc** — A spool's swatch is composed from `effect_type` and `extra_colors`, and the RFID auto-add set neither. It reads the colour catalogue to name the colour and took the name alone, even though the row it had in hand also carries those two columns — the spool form's own colour picker hands both to a spool a user adds by hand, so the same roll rendered one way when you typed it in and another when the printer identified it for you. Both columns now travel with the name. That alone would have changed nothing on a stock install, because the shipped catalogue carries an effect on none of its 600-odd rows, so the subtype is read where the catalogue has none: it is already derived from what the printer reports, and the two vocabularies line up — Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow, Translucent, Matte, and the Gradient, Dual Color and Tri Color that the M*/T* colour codes upgrade a subtype to. "Silk+" is read as Silk, since the plus is on the product name rather than the finish. A subtype that names no effect — Basic, Tough, CF — leaves the column empty rather than inventing an overlay, and a value already set is never overwritten, so the column stays what it is documented to be: a rendering hint the user can override without touching Bambu's categorical label.

+ 117 - 0
frontend/src/__tests__/components/FilamentMappingColorMismatch.test.tsx

@@ -0,0 +1,117 @@
+/**
+ * Tests for how a colour mismatch is explained in the Filament Mapping panel
+ * (#2941).
+ *
+ * The report: "the color is Blue PLA, and it reports the selected filament does
+ * not match when it's blue PLA as well." The comparison was right -- the slicer
+ * profile asked for a near-pure `#0028FF` and the slot held Bambu's navy Blue
+ * `#0A2989`, which differ by 118 in the blue channel alone -- but both hexes
+ * resolve to the name "Blue", so the panel printed that name on either side of a
+ * mismatch warning and gave the user nothing to reconcile it with.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, cleanup } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// The reporter's slicer profile: a pure-ish blue no pigment reaches.
+const SLICER_BLUE = '#0028FF';
+// Bambu PLA Basic "Blue" -- a dark navy, and what the AMS actually held.
+const BAMBU_BLUE = '0A2989';
+
+const blueReqs = {
+  filaments: [{ slot_id: 1, type: 'PLA', color: SLICER_BLUE, used_grams: 18, used_meters: 6 }],
+};
+
+function statusWithTray(trayColor: string): PrinterStatus {
+  return {
+    id: 1,
+    name: 'P1S-1',
+    connected: true,
+    state: 'IDLE',
+    ams: [
+      {
+        id: 0,
+        tray: [{ id: 3, tray_type: 'PLA', tray_color: trayColor, tray_info_idx: 'GFA00' }],
+      },
+    ],
+    vt_tray: [],
+    ams_extruder_map: {},
+  } as PrinterStatus;
+}
+
+function renderMapping() {
+  render(
+    <FilamentMapping
+      printerId={1}
+      filamentReqs={blueReqs}
+      manualMappings={{}}
+      onManualMappingChange={() => {}}
+      currencySymbol="$"
+      defaultCostPerKg={0}
+      defaultExpanded
+    />,
+  );
+}
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping — explaining a colour mismatch', () => {
+  it('names both colours by hex when they share a name', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusWithTray(BAMBU_BLUE))),
+    );
+
+    renderMapping();
+
+    // Without the hex the tooltip read "Same type, different color" against two
+    // labels both saying "Blue", which is the complaint in the report. Both
+    // sides are now qualified, in one string, so the difference is readable
+    // without the user having to compare two swatches by eye.
+    const warning = await screen.findByTitle(/different color/i);
+    const title = warning.getAttribute('title') ?? '';
+    expect(title).toMatch(/Blue \(#0028FF\)/);
+    expect(title).toMatch(/Blue \(#0A2989\)/);
+
+    // The required-filament swatch carries the same qualified label, so the
+    // two tooltips in the row cannot disagree about what was asked for.
+    expect(screen.getByTitle(/^Required:/).getAttribute('title')).toMatch(/Blue \(#0028FF\)/);
+  });
+
+  it('still flags the mismatch in the panel header', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusWithTray(BAMBU_BLUE))),
+    );
+
+    renderMapping();
+
+    // The warning itself is correct and stays -- this issue was about being
+    // able to see why, not about silencing it.
+    await waitFor(() => {
+      expect(screen.getByText(/Color mismatch/)).toBeInTheDocument();
+    });
+  });
+
+  it('reports a ready mapping when the slot really does hold that colour', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusWithTray('0028FF'))),
+    );
+
+    renderMapping();
+
+    await waitFor(() => {
+      expect(screen.getByText(/Ready/)).toBeInTheDocument();
+    });
+    expect(screen.queryByText(/Color mismatch/)).not.toBeInTheDocument();
+  });
+});

+ 60 - 0
frontend/src/__tests__/utils/colors.test.ts

@@ -1,5 +1,6 @@
 import { describe, it, expect, beforeEach } from 'vitest';
 import {
+  disambiguateColorNames,
   colorFamily,
   colorSortKey,
   hexToColorName,
@@ -283,3 +284,62 @@ describe('colorFamily / hexToColorName agreement', () => {
     }
   });
 });
+
+describe('disambiguateColorNames', () => {
+  // #2941: a slicer profile asked for a near-pure blue while the AMS slot held
+  // Bambu's navy Blue. Both resolve to the name "Blue", so the mismatch warning
+  // sat between two identical labels and read as a contradiction.
+  const SLICER_BLUE = '#0028FF';
+  const BAMBU_BLUE = '#0A2989';
+
+  it('qualifies both sides with hex when the names collide', () => {
+    expect(
+      disambiguateColorNames({ name: 'Blue', hex: SLICER_BLUE }, { name: 'Blue', hex: BAMBU_BLUE }),
+    ).toEqual(['Blue (#0028FF)', 'Blue (#0A2989)']);
+  });
+
+  it('treats names as colliding regardless of case', () => {
+    expect(disambiguateColorNames({ name: 'blue', hex: SLICER_BLUE }, { name: 'Blue', hex: BAMBU_BLUE })).toEqual([
+      'blue (#0028FF)',
+      'Blue (#0A2989)',
+    ]);
+  });
+
+  it('leaves distinct names alone', () => {
+    // Once the words separate them the hex is noise, not information.
+    expect(disambiguateColorNames({ name: 'Blue', hex: SLICER_BLUE }, { name: 'Navy', hex: BAMBU_BLUE })).toEqual([
+      'Blue',
+      'Navy',
+    ]);
+  });
+
+  it('falls back to the hex for a side with no name', () => {
+    expect(disambiguateColorNames({ hex: SLICER_BLUE }, { name: 'Blue', hex: BAMBU_BLUE })).toEqual([
+      '#0028FF',
+      'Blue',
+    ]);
+  });
+
+  it('keeps the bare names when neither side has a usable hex', () => {
+    // Better a repeated name than "Blue ()" twice.
+    expect(disambiguateColorNames({ name: 'Blue', hex: 'nonsense' }, { name: 'Blue' })).toEqual(['Blue', 'Blue']);
+  });
+
+  it('qualifies only the side that has a hex', () => {
+    expect(disambiguateColorNames({ name: 'Blue', hex: SLICER_BLUE }, { name: 'Blue' })).toEqual([
+      'Blue (#0028FF)',
+      'Blue',
+    ]);
+  });
+
+  it('normalizes hex form, so an 8-char rgba and a bare hex read alike', () => {
+    expect(disambiguateColorNames({ name: 'Blue', hex: '0028ffff' }, { name: 'Blue', hex: '#0a2989' })).toEqual([
+      'Blue (#0028FF)',
+      'Blue (#0A2989)',
+    ]);
+  });
+
+  it('returns empty labels when there is nothing to name', () => {
+    expect(disambiguateColorNames({}, {})).toEqual(['', '']);
+  });
+});

+ 29 - 10
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -5,7 +5,7 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
 import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
-import { getColorName } from '../../utils/colors';
+import { disambiguateColorNames, getColorName } from '../../utils/colors';
 import { useFilamentLabels } from './useFilamentLabels';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
 import type { FilamentMappingProps, RackGroupInfo } from './types';
@@ -324,11 +324,11 @@ export function FilamentMapping({
         <Circle className="w-4 h-4" fill={statusColor} stroke="none" />
         <span>{plateLabel ? `${t('printModal.filamentMapping')} — ${plateLabel}` : t('printModal.filamentMapping')}</span>
         {hasTypeMismatch ? (
-          <span className="text-xs text-orange-700 dark:text-orange-400">(Type not found)</span>
+          <span className="text-xs text-orange-700 dark:text-orange-400">({t('printModal.statusTypeNotFound')})</span>
         ) : hasColorMismatch ? (
-          <span className="text-xs text-yellow-700 dark:text-yellow-400">(Color mismatch)</span>
+          <span className="text-xs text-yellow-700 dark:text-yellow-400">({t('printModal.statusColorMismatch')})</span>
         ) : (
-          <span className="text-xs text-bambu-green">(Ready)</span>
+          <span className="text-xs text-bambu-green">({t('printModal.statusReady')})</span>
         )}
         {isExpanded ? (
           <ChevronUp className="w-4 h-4 ml-auto" />
@@ -385,6 +385,14 @@ export function FilamentMapping({
             // 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) };
+            // Both sides of a colour mismatch routinely resolve to the same
+            // name -- a slicer's pure blue and a spool's navy are both "Blue" --
+            // which made the warning look like it was contradicting itself
+            // (#2941). Qualify them with their hex when the names collide.
+            const [requiredColorLabel, loadedColorLabel] = disambiguateColorNames(
+              { name: colorLabel, hex: item.color },
+              { name: item.loaded?.colorName, hex: item.loaded?.color },
+            );
             return (
             <div key={idx} className="space-y-1">
               <div
@@ -402,7 +410,7 @@ export function FilamentMapping({
                 }}
               >
                 {/* Required color */}
-                <span title={`Required: ${resolvedName} - ${colorLabel}`}>
+                <span title={t('printModal.requiredTooltip', { name: resolvedName, color: requiredColorLabel })}>
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
                 {/* Required type + grams + nozzle badge. Only the name
@@ -463,10 +471,10 @@ export function FilamentMapping({
                       ? 'border-yellow-500 dark:border-yellow-400/50 text-yellow-700 dark:text-yellow-400'
                       : 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
                   } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                  title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                  title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
                 >
                   <option value="" className="bg-bambu-dark text-bambu-gray">
-                    -- Select slot --
+                    -- {t('printModal.selectSlot')} --
                   </option>
                   {/*
                     #1722: every loaded slot is offered for every filament row,
@@ -509,11 +517,20 @@ export function FilamentMapping({
                 {item.status === 'match' ? (
                   <Check className="w-3 h-3 text-bambu-green" />
                 ) : item.status === 'type_only' ? (
-                  <span title="Same type, different color">
+                  <span
+                    title={
+                      requiredColorLabel && loadedColorLabel
+                        ? t('printModal.sameTypeDifferentColorDetail', {
+                            required: requiredColorLabel,
+                            loaded: loadedColorLabel,
+                          })
+                        : t('printModal.sameTypeDifferentColor')
+                    }
+                  >
                     <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
                   </span>
                 ) : (
-                  <span title="Filament type not loaded">
+                  <span title={t('printModal.filamentTypeNotLoaded')}>
                     <AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400" />
                   </span>
                 )}
@@ -553,7 +570,9 @@ export function FilamentMapping({
             </p>
           )}
           {hasTypeMismatch && (
-            <p className="text-xs text-orange-700 dark:text-orange-400 mt-2">Required filament type not found in printer.</p>
+            <p className="text-xs text-orange-700 dark:text-orange-400 mt-2">
+              {t('printModal.requiredTypeNotInPrinter')}
+            </p>
           )}
         </div>
       )}

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -4961,6 +4961,15 @@ export default {
     printerOffline: 'Drucker ist offline',
     sameTypeDifferentColor: 'Gleicher Typ, andere Farbe',
     filamentTypeNotLoaded: 'Filamenttyp nicht geladen',
+    statusReady: 'Bereit',
+    statusColorMismatch: 'Farbe weicht ab',
+    statusTypeNotFound: 'Typ nicht gefunden',
+    requiredTooltip: 'Benötigt: {{name}} - {{color}}',
+    manuallySelected: 'Manuell ausgewählt',
+    autoMatched: 'Automatisch zugeordnet',
+    selectSlot: 'Slot wählen',
+    sameTypeDifferentColorDetail: 'Gleicher Typ, andere Farbe: benötigt {{required}}, im Slot ist {{loaded}}',
+    requiredTypeNotInPrinter: 'Benötigter Filamenttyp im Drucker nicht gefunden.',
     whenToPrint: 'Wann drucken',
     asap: 'Sofort',
     queue: 'Warteschlange',

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -5005,6 +5005,15 @@ export default {
     printerOffline: 'Printer is offline',
     sameTypeDifferentColor: 'Same type, different color',
     filamentTypeNotLoaded: 'Filament type not loaded',
+    statusReady: 'Ready',
+    statusColorMismatch: 'Color mismatch',
+    statusTypeNotFound: 'Type not found',
+    requiredTooltip: 'Required: {{name}} - {{color}}',
+    manuallySelected: 'Manually selected',
+    autoMatched: 'Auto-matched',
+    selectSlot: 'Select slot',
+    sameTypeDifferentColorDetail: 'Same type, different color: needs {{required}}, slot has {{loaded}}',
+    requiredTypeNotInPrinter: 'Required filament type not found in printer.',
     whenToPrint: 'When to print',
     asap: 'ASAP',
     queue: 'Queue',

+ 9 - 0
frontend/src/i18n/locales/es.ts

@@ -4968,6 +4968,15 @@ export default {
     printerOffline: 'La impresora está desconectada',
     sameTypeDifferentColor: 'Mismo tipo, color distinto',
     filamentTypeNotLoaded: 'Tipo de filamento no cargado',
+    statusReady: 'Listo',
+    statusColorMismatch: 'Color distinto',
+    statusTypeNotFound: 'Tipo no encontrado',
+    requiredTooltip: 'Requerido: {{name}} - {{color}}',
+    manuallySelected: 'Seleccionado manualmente',
+    autoMatched: 'Emparejado automáticamente',
+    selectSlot: 'Elegir ranura',
+    sameTypeDifferentColorDetail: 'Mismo tipo, color distinto: necesita {{required}}, la ranura tiene {{loaded}}',
+    requiredTypeNotInPrinter: 'El tipo de filamento requerido no se encuentra en la impresora.',
     whenToPrint: 'Cuándo imprimir',
     asap: 'Lo antes posible',
     queue: 'Cola',

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -4950,6 +4950,15 @@ export default {
     printerOffline: 'L\'imprimante est hors ligne',
     sameTypeDifferentColor: 'Même type, couleur différente',
     filamentTypeNotLoaded: 'Type de filament non chargé',
+    statusReady: 'Prêt',
+    statusColorMismatch: 'Couleur différente',
+    statusTypeNotFound: 'Type introuvable',
+    requiredTooltip: 'Requis : {{name}} - {{color}}',
+    manuallySelected: 'Sélectionné manuellement',
+    autoMatched: 'Associé automatiquement',
+    selectSlot: 'Choisir un emplacement',
+    sameTypeDifferentColorDetail: 'Même type, couleur différente : requiert {{required}}, l’emplacement contient {{loaded}}',
+    requiredTypeNotInPrinter: 'Type de filament requis introuvable dans l’imprimante.',
     whenToPrint: 'Quand imprimer',
     asap: 'Dès que possible',
     queue: 'File',

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -4949,6 +4949,15 @@ export default {
     printerOffline: 'Stampante offline',
     sameTypeDifferentColor: 'Stesso tipo, colore diverso',
     filamentTypeNotLoaded: 'Tipo di filamento non caricato',
+    statusReady: 'Pronto',
+    statusColorMismatch: 'Colore diverso',
+    statusTypeNotFound: 'Tipo non trovato',
+    requiredTooltip: 'Richiesto: {{name}} - {{color}}',
+    manuallySelected: 'Selezionato manualmente',
+    autoMatched: 'Abbinato automaticamente',
+    selectSlot: 'Scegli slot',
+    sameTypeDifferentColorDetail: 'Stesso tipo, colore diverso: richiede {{required}}, lo slot ha {{loaded}}',
+    requiredTypeNotInPrinter: 'Tipo di filamento richiesto non trovato nella stampante.',
     whenToPrint: 'Quando stampare',
     asap: 'Subito',
     queue: 'Coda',

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -4961,6 +4961,15 @@ export default {
     printerOffline: 'プリンターはオフラインです',
     sameTypeDifferentColor: '同じ種類、異なる色',
     filamentTypeNotLoaded: 'フィラメントタイプが未読み込み',
+    statusReady: '準備完了',
+    statusColorMismatch: '色が不一致',
+    statusTypeNotFound: 'タイプが見つかりません',
+    requiredTooltip: '必要: {{name}} - {{color}}',
+    manuallySelected: '手動で選択',
+    autoMatched: '自動で一致',
+    selectSlot: 'スロットを選択',
+    sameTypeDifferentColorDetail: '同じ種類、異なる色: 必要な色は {{required}}、スロットは {{loaded}}',
+    requiredTypeNotInPrinter: '必要なフィラメントタイプがプリンターに見つかりません。',
     whenToPrint: '印刷タイミング',
     asap: '即時',
     queue: 'キュー',

+ 9 - 0
frontend/src/i18n/locales/ko.ts

@@ -4731,6 +4731,15 @@ export default {
     printerOffline: '프린터가 오프라인입니다',
     sameTypeDifferentColor: '같은 유형, 다른 색상',
     filamentTypeNotLoaded: '필라멘트 유형이 장착되지 않음',
+    statusReady: '준비됨',
+    statusColorMismatch: '색상 불일치',
+    statusTypeNotFound: '유형을 찾을 수 없음',
+    requiredTooltip: '필요: {{name}} - {{color}}',
+    manuallySelected: '수동으로 선택됨',
+    autoMatched: '자동으로 일치됨',
+    selectSlot: '슬롯 선택',
+    sameTypeDifferentColorDetail: '같은 유형, 다른 색상: {{required}} 필요, 슬롯에는 {{loaded}}',
+    requiredTypeNotInPrinter: '필요한 필라멘트 유형을 프린터에서 찾을 수 없습니다.',
     whenToPrint: '인쇄 시점',
     asap: '최대한 빨리',
     queue: '대기열',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4949,6 +4949,15 @@ export default {
     printerOffline: 'Impressora offline',
     sameTypeDifferentColor: 'Mesmo tipo, cor diferente',
     filamentTypeNotLoaded: 'Tipo de filamento não carregado',
+    statusReady: 'Pronto',
+    statusColorMismatch: 'Cor diferente',
+    statusTypeNotFound: 'Tipo não encontrado',
+    requiredTooltip: 'Necessário: {{name}} - {{color}}',
+    manuallySelected: 'Selecionado manualmente',
+    autoMatched: 'Correspondido automaticamente',
+    selectSlot: 'Escolher slot',
+    sameTypeDifferentColorDetail: 'Mesmo tipo, cor diferente: precisa de {{required}}, o slot tem {{loaded}}',
+    requiredTypeNotInPrinter: 'Tipo de filamento necessário não encontrado na impressora.',
     whenToPrint: 'Quando imprimir',
     asap: 'O quanto antes',
     queue: 'Fila',

+ 9 - 0
frontend/src/i18n/locales/ru.ts

@@ -4720,6 +4720,15 @@ export default {
     printerOffline: "Принтер не в сети",
     sameTypeDifferentColor: "Тот же тип, другой цвет",
     filamentTypeNotLoaded: "Филамент этого типа не загружен",
+    statusReady: "Готово",
+    statusColorMismatch: "Цвет не совпадает",
+    statusTypeNotFound: "Тип не найден",
+    requiredTooltip: "Требуется: {{name}} - {{color}}",
+    manuallySelected: "Выбрано вручную",
+    autoMatched: "Подобрано автоматически",
+    selectSlot: "Выбрать слот",
+    sameTypeDifferentColorDetail: "Тот же тип, другой цвет: нужен {{required}}, в слоте {{loaded}}",
+    requiredTypeNotInPrinter: "Требуемый тип филамента не найден в принтере.",
     whenToPrint: "Когда печатать",
     asap: "Как можно скорее",
     queue: "В очередь",

+ 9 - 0
frontend/src/i18n/locales/tr.ts

@@ -4938,6 +4938,15 @@ export default {
     printerOffline: 'Yazıcı çevrimdışı',
     sameTypeDifferentColor: 'Aynı tür, farklı renk',
     filamentTypeNotLoaded: 'Filament türü yüklenmedi',
+    statusReady: 'Hazır',
+    statusColorMismatch: 'Renk uyuşmuyor',
+    statusTypeNotFound: 'Tür bulunamadı',
+    requiredTooltip: 'Gereken: {{name}} - {{color}}',
+    manuallySelected: 'Elle seçildi',
+    autoMatched: 'Otomatik eşleşti',
+    selectSlot: 'Yuva seç',
+    sameTypeDifferentColorDetail: 'Aynı tür, farklı renk: {{required}} gerekiyor, yuvada {{loaded}} var',
+    requiredTypeNotInPrinter: 'Gereken filament türü yazıcıda bulunamadı.',
     whenToPrint: 'Ne zaman yazdırılsın',
     asap: 'En kısa sürede',
     queue: 'Kuyruk',

+ 9 - 0
frontend/src/i18n/locales/uk.ts

@@ -5003,6 +5003,15 @@ export default {
     printerOffline: "Принтер не в мережі",
     sameTypeDifferentColor: "Одного типу, іншого кольору",
     filamentTypeNotLoaded: "Тип філаменту не завантажено",
+    statusReady: "Готово",
+    statusColorMismatch: "Колір не збігається",
+    statusTypeNotFound: "Тип не знайдено",
+    requiredTooltip: "Потрібно: {{name}} - {{color}}",
+    manuallySelected: "Вибрано вручну",
+    autoMatched: "Підібрано автоматично",
+    selectSlot: "Вибрати слот",
+    sameTypeDifferentColorDetail: "Того самого типу, іншого кольору: потрібен {{required}}, у слоті {{loaded}}",
+    requiredTypeNotInPrinter: "Потрібний тип філаменту не знайдено у принтері.",
     whenToPrint: "Коли друкувати",
     asap: "Якнайшвидше",
     queue: "Черга",

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4949,6 +4949,15 @@ export default {
     printerOffline: '打印机离线',
     sameTypeDifferentColor: '相同类型,不同颜色',
     filamentTypeNotLoaded: '耗材类型未装载',
+    statusReady: '就绪',
+    statusColorMismatch: '颜色不匹配',
+    statusTypeNotFound: '未找到类型',
+    requiredTooltip: '需要:{{name}} - {{color}}',
+    manuallySelected: '手动选择',
+    autoMatched: '自动匹配',
+    selectSlot: '选择槽位',
+    sameTypeDifferentColorDetail: '相同类型,不同颜色:需要 {{required}},槽位为 {{loaded}}',
+    requiredTypeNotInPrinter: '打印机中未找到所需的耗材类型。',
     whenToPrint: '何时打印',
     asap: '尽快',
     queue: '队列',

+ 9 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4949,6 +4949,15 @@ export default {
     printerOffline: '印表機離線',
     sameTypeDifferentColor: '相同類型,不同顏色',
     filamentTypeNotLoaded: '耗材類型未裝載',
+    statusReady: '就緒',
+    statusColorMismatch: '顏色不符',
+    statusTypeNotFound: '找不到類型',
+    requiredTooltip: '需要:{{name}} - {{color}}',
+    manuallySelected: '手動選擇',
+    autoMatched: '自動比對',
+    selectSlot: '選擇插槽',
+    sameTypeDifferentColorDetail: '相同類型,不同顏色:需要 {{required}},插槽為 {{loaded}}',
+    requiredTypeNotInPrinter: '印表機中找不到所需的耗材類型。',
     whenToPrint: '何時列印',
     asap: '盡快',
     queue: '佇列',

+ 40 - 0
frontend/src/utils/colors.ts

@@ -251,6 +251,46 @@ export function getColorName(hexColor: string, material?: string | null): string
   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.

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-KzwjhTgP.js


+ 1 - 1
static/index.html

@@ -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-B6R8pq98.js"></script>
+    <script type="module" crossorigin src="/assets/index-KzwjhTgP.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DjndScv6.css">
   </head>
   <body>

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott