Przeglądaj źródła

Rank near-colour matches by how they look, and share one filament type table (#2804)

Three follow-ups to #2804, all bearing on one decision: which spool a print
uses when the exact colour is not loaded.

Colour ranking is now perceptual. The ranking added in #2804 measured RGB
distance, which rates a colour by how far apart the numbers are rather than
how far apart they look, and it overweights blue badly enough to invert the
answer: against a required #1E4821 green, a purple #38202F is the nearer of
two eligible spools by RGB and four times the further once measured properly.
Both sides now use CIEDE2000 -- perceptual_color_distance in
backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept
structurally identical so they can be read side by side. Verified against the
Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two
implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is
untouched, still the per-channel RGB box, so this only reorders spools that
already qualified.

Type matching now agrees between the interface and the scheduler. Bambu
firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler
has always matched them accordingly, but the interface compared raw type
strings and called that same pairing a mismatch. The badge contradicted what
the printer was about to do, and the manual override picker, which groups by
canonical type, offered the very spool the badge then rejected. The fifteen
comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts
and PrinterSelector.tsx now call filamentTypesCompatible.

The pipeline pre-flight reads the matcher's table instead of its own copy.
That copy had drifted into disagreeing in both directions: it aliased PLA
Basic to PLA where the matcher never has, so a run could clear the check and
then fail to map its slots, and it lacked the nylon grouping, so it flagged
runs the matcher handles without complaint. A check whose job is to predict
dispatch is wrong whenever it disagrees with dispatch, whichever way it leans,
so it and the scheduler now both read backend/app/utils/filament_types.py.

That canonicaliser deliberately does not strip surrounding whitespace. It
looks like a free improvement, but it would collapse a junk tray_type to ""
just as a 3MF declaring no filament type yields "", and a typeless requirement
would start matching a junk-typed tray instead of reporting the slot unmapped.
Padded type strings are worth handling on their own terms, with that case
addressed.

One behaviour change outside the ranking: the pre-flight is stricter for a
printer reporting a product name such as "PLA Basic" where the generic
material belongs, which it now flags rather than passes. Rare in practice,
since the printer reports material and product name in separate fields, and it
is the answer the matcher would give. Nothing about which spool a print
actually uses changed outside the colour ranking itself.

Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses
identical colours: two colours at equal RGB distance are not perceptually
tied, which is rather the point.
maziggy 3 tygodni temu
rodzic
commit
e6842e1d3c

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 8 - 30
backend/app/services/pipeline_eligibility.py

@@ -36,6 +36,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.printer import Printer
 from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.utils.filament_types import canonical_filament_type
 
 IssueKind = Literal[
     "printer_not_set",
@@ -80,36 +81,13 @@ class EligibilityReport:
     printer_reports: tuple[PerPrinterReport, ...] = ()
 
 
-# Same equivalence map as print_scheduler._canonical_filament_type but kept
-# local so this module has no upward dependency on the scheduler. Mirrors the
-# scheduler's behaviour: BBL-prefixed product names normalise to the base type
-# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
-# one needs the same entry.
-_FILAMENT_EQUIV_MAP = {
-    "PLA": "PLA",
-    "PLA BASIC": "PLA",
-    "PLA MATTE": "PLA",
-    "PLA SILK": "PLA",
-    "PLA PRO": "PLA",
-    "PLA TOUGH": "PLA",
-    "PETG": "PETG",
-    "PETG HF": "PETG",
-    "PETG BASIC": "PETG",
-    "PETG TRANSLUCENT": "PETG",
-    "ABS": "ABS",
-    "ASA": "ASA",
-    "TPU": "TPU",
-    "TPU 95A": "TPU",
-    "PC": "PC",
-    "PA": "PA",
-    "PA-CF": "PA",
-    "PVA": "PVA",
-}
-
-
-def _canonical(ftype: str) -> str:
-    upper = (ftype or "").strip().upper()
-    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+# This module's whole job is to predict what the dispatch matcher will do, so
+# it reads type equivalence from the same table the matcher does rather than
+# keeping a copy. The copy it used to keep had drifted into disagreeing in both
+# directions — it aliased "PLA Basic" to "PLA" where the matcher does not, so a
+# job could pass here and then fail on type; and it lacked the PA12-CF/PAHT-CF
+# grouping the matcher has, so a job the matcher handles fine was flagged.
+_canonical = canonical_filament_type
 
 
 def _normalise_colour(colour: str | None) -> str:

+ 21 - 43
backend/app/services/print_scheduler.py

@@ -55,6 +55,8 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.color_utils import perceptual_color_distance
+from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
@@ -227,23 +229,6 @@ _ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING
 # force-reconnect on the very next attempt — while still bounding the loop.
 DISPATCH_MAX_ATTEMPTS = 3
 
-# Filament type equivalence groups — types within the same group are
-# interchangeable on the printer side (Bambu Lab firmware treats them as compatible).
-_FILAMENT_TYPE_GROUPS: list[list[str]] = [
-    ["PA-CF", "PA12-CF", "PAHT-CF"],
-]
-_FILAMENT_EQUIV_MAP: dict[str, str] = {}
-for _group in _FILAMENT_TYPE_GROUPS:
-    _canonical = _group[0].upper()
-    for _t in _group:
-        _FILAMENT_EQUIV_MAP[_t.upper()] = _canonical
-
-
-def _canonical_filament_type(ftype: str) -> str:
-    """Return canonical type for equivalence matching."""
-    upper = ftype.upper()
-    return _FILAMENT_EQUIV_MAP.get(upper, upper)
-
 
 @dataclass(slots=True)
 class _ModelCandidate:
@@ -2141,18 +2126,16 @@ class PrintScheduler:
                 tray_type = tray.get("tray_type")
                 if tray_type:
                     color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                    loaded.append(
-                        (_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
-                    )
+                    loaded.append((canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or ""))
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
+                loaded.append((canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
 
         missing = []
         for o in force_overrides:
-            o_type = _canonical_filament_type(o.get("type") or "")
+            o_type = canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             o_idx = o.get("tray_info_idx") or ""
             satisfied = any(
@@ -2189,18 +2172,18 @@ class PrintScheduler:
                 for tray in ams_unit.get("tray", []):
                     tray_type = tray.get("tray_type")
                     if tray_type:
-                        loaded_types.add(_canonical_filament_type(tray_type))
+                        loaded_types.add(canonical_filament_type(tray_type))
 
         # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
-                loaded_types.add(_canonical_filament_type(vt_type))
+                loaded_types.add(canonical_filament_type(vt_type))
 
         # Find which required types are missing (using canonical type for equivalence)
         missing = []
         for req_type in required_types:
-            if _canonical_filament_type(req_type) not in loaded_types:
+            if canonical_filament_type(req_type) not in loaded_types:
                 missing.append(req_type)
 
         return missing
@@ -2741,29 +2724,24 @@ class PrintScheduler:
         return color.replace("#", "").lower()[:6]
 
     def _color_distance(self, color1: str | None, color2: str | None) -> float | None:
-        """Euclidean RGB distance, or None when either colour is unusable.
+        """Perceptual (CIEDE2000) distance, or None when either colour is unusable.
 
         Ranks the candidates ``_colors_are_similar`` admits (#2804). Eligibility
         stays the per-channel box that shipped — this only decides which of
         several eligible spools is closest, so nothing becomes usable or
         unusable because of it.
 
-        Alpha is dropped by ``_normalize_color_for_compare``, deliberately: the
-        alpha a slicer writes for a transparent filament is not a colour the
-        user chose, and counting it would stop a transparent filament matching
-        itself.
+        It ranks by how far apart the colours *look*, not how far apart their
+        numbers are. RGB distance overweights blue badly enough to invert the
+        answer: against a required ``#1E4821`` green, a purple ``#38202F`` is
+        the nearer of two eligible spools by RGB and the further by a factor of
+        four once measured perceptually.
+
+        Alpha is ignored, deliberately: the alpha a slicer writes for a
+        transparent filament is not a colour the user chose, and counting it
+        would stop a transparent filament matching itself.
         """
-        hex1 = self._normalize_color_for_compare(color1)
-        hex2 = self._normalize_color_for_compare(color2)
-        if not hex1 or not hex2 or len(hex1) < 6 or len(hex2) < 6:
-            return None
-        try:
-            dr = int(hex1[0:2], 16) - int(hex2[0:2], 16)
-            dg = int(hex1[2:4], 16) - int(hex2[2:4], 16)
-            db = int(hex1[4:6], 16) - int(hex2[4:6], 16)
-        except ValueError:
-            return None
-        return (dr * dr + dg * dg + db * db) ** 0.5
+        return perceptual_color_distance(color1, color2)
 
     def _colors_are_similar(self, color1: str | None, color2: str | None, threshold: int = 40) -> bool:
         """Check if two colors are visually similar within a threshold."""
@@ -3055,7 +3033,7 @@ class PrintScheduler:
             if not idx_match and not exact_match and not similar_match and not type_only_match:
                 for f in available:
                     f_type = (f.get("type") or "").upper()
-                    if _canonical_filament_type(f_type) != _canonical_filament_type(req_type):
+                    if canonical_filament_type(f_type) != canonical_filament_type(req_type):
                         continue
 
                     # Type matches - check color
@@ -3104,7 +3082,7 @@ class PrintScheduler:
                     match["global_tray_id"],
                     bucket,
                     req.get("slot_id"),
-                    f" (colour distance {similar_distance:.1f})" if bucket == "similar_color" else "",
+                    f" (deltaE {similar_distance:.2f})" if bucket == "similar_color" else "",
                 )
             else:
                 logger.info(

+ 139 - 0
backend/app/utils/color_utils.py

@@ -1,5 +1,7 @@
 """Color comparison utilities for RFID/firmware color matching."""
 
+import math
+
 
 def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
     """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.
@@ -22,3 +24,140 @@ def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
         return False
     dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5
     return dist <= threshold
+
+
+# --- Perceptual colour difference (CIEDE2000) ---------------------------------
+#
+# Ranking spools by RGB distance rates a colour by how far apart the numbers
+# are, which is not how far apart they look: RGB overweights blue badly, so a
+# required green could take a purple over a green that was numerically further
+# away. CIEDE2000 is the CIE's perceptual metric, and small differences — which
+# is all this ever sees, since candidates are already inside a narrow tolerance
+# — are exactly the regime its predecessors handle worst.
+#
+# Mirrored in `frontend/src/utils/amsHelpers.ts` (`colorDistance`). The two must
+# agree: the dialog must not promise a spool the scheduler would not pick.
+
+_D65_WHITE = (0.95047, 1.0, 1.08883)
+_DELTA = 6.0 / 29.0
+
+
+def _hex_to_lab(hex_color: str) -> tuple[float, float, float] | None:
+    """Convert ``RRGGBB(AA)`` to CIE L*a*b* under D65, or None if unusable.
+
+    Alpha is ignored: the alpha a slicer writes for a transparent filament is
+    not a colour the user chose, and counting it would stop a transparent
+    filament matching itself.
+    """
+    cleaned = hex_color.replace("#", "").strip().lower()
+    if len(cleaned) < 6:
+        return None
+    try:
+        channels = [int(cleaned[i : i + 2], 16) / 255.0 for i in (0, 2, 4)]
+    except ValueError:
+        return None
+
+    # sRGB gamma -> linear light.
+    r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels)
+
+    x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
+    y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
+    z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
+
+    def f(t: float) -> float:
+        return t ** (1.0 / 3.0) if t > _DELTA**3 else t / (3 * _DELTA * _DELTA) + 4.0 / 29.0
+
+    fx, fy, fz = (f(v / w) for v, w in zip((x, y, z), _D65_WHITE, strict=True))
+    return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
+
+
+def _ciede2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
+    """CIEDE2000 colour difference between two L*a*b* triples.
+
+    Straight transcription of the CIE formulation, with the parametric weights
+    kL = kC = kH = 1. Verified against the Sharma/Wu/Dalal published test set,
+    including the hue-discontinuity pairs that catch sign errors.
+    """
+    l1, a1, b1 = lab1
+    l2, a2, b2 = lab2
+
+    c1 = math.hypot(a1, b1)
+    c2 = math.hypot(a2, b2)
+    c_bar7 = ((c1 + c2) / 2.0) ** 7
+    g = 0.5 * (1.0 - math.sqrt(c_bar7 / (c_bar7 + 25.0**7)))
+
+    a1p = (1.0 + g) * a1
+    a2p = (1.0 + g) * a2
+    c1p = math.hypot(a1p, b1)
+    c2p = math.hypot(a2p, b2)
+
+    def hue(ap: float, bp: float) -> float:
+        if ap == 0.0 and bp == 0.0:
+            return 0.0
+        deg = math.degrees(math.atan2(bp, ap))
+        return deg + 360.0 if deg < 0 else deg
+
+    h1p = hue(a1p, b1)
+    h2p = hue(a2p, b2)
+
+    dlp = l2 - l1
+    dcp = c2p - c1p
+
+    chroma_product = c1p * c2p
+    if chroma_product == 0.0:
+        dhp = 0.0
+    else:
+        dhp = h2p - h1p
+        if dhp > 180.0:
+            dhp -= 360.0
+        elif dhp < -180.0:
+            dhp += 360.0
+    dhp_big = 2.0 * math.sqrt(chroma_product) * math.sin(math.radians(dhp) / 2.0)
+
+    l_bar = (l1 + l2) / 2.0
+    c_bar = (c1p + c2p) / 2.0
+
+    if chroma_product == 0.0:
+        h_bar = h1p + h2p
+    elif abs(h1p - h2p) <= 180.0:
+        h_bar = (h1p + h2p) / 2.0
+    elif h1p + h2p < 360.0:
+        h_bar = (h1p + h2p + 360.0) / 2.0
+    else:
+        h_bar = (h1p + h2p - 360.0) / 2.0
+
+    t = (
+        1.0
+        - 0.17 * math.cos(math.radians(h_bar - 30.0))
+        + 0.24 * math.cos(math.radians(2.0 * h_bar))
+        + 0.32 * math.cos(math.radians(3.0 * h_bar + 6.0))
+        - 0.20 * math.cos(math.radians(4.0 * h_bar - 63.0))
+    )
+
+    c_bar_p7 = c_bar**7
+    rc = 2.0 * math.sqrt(c_bar_p7 / (c_bar_p7 + 25.0**7))
+    sl = 1.0 + (0.015 * (l_bar - 50.0) ** 2) / math.sqrt(20.0 + (l_bar - 50.0) ** 2)
+    sc = 1.0 + 0.045 * c_bar
+    sh = 1.0 + 0.015 * c_bar * t
+    rt = -math.sin(math.radians(2.0 * (30.0 * math.exp(-(((h_bar - 275.0) / 25.0) ** 2))))) * rc
+
+    dl_term = dlp / sl
+    dc_term = dcp / sc
+    dh_term = dhp_big / sh
+    return math.sqrt(dl_term**2 + dc_term**2 + dh_term**2 + rt * dc_term * dh_term)
+
+
+def perceptual_color_distance(color1: str | None, color2: str | None) -> float | None:
+    """Perceptual distance between two hex colours, or None if either is unusable.
+
+    Returns a CIEDE2000 delta-E: ~1.0 is the threshold of a just-noticeable
+    difference, so the numbers are far smaller than the RGB distances they
+    replaced and cannot be compared against an RGB threshold.
+    """
+    if not color1 or not color2:
+        return None
+    lab1 = _hex_to_lab(color1)
+    lab2 = _hex_to_lab(color2)
+    if lab1 is None or lab2 is None:
+        return None
+    return _ciede2000(lab1, lab2)

+ 62 - 0
backend/app/utils/filament_types.py

@@ -0,0 +1,62 @@
+"""Filament type equivalence — one answer to "will this spool do for that
+requirement?", shared by everyone who asks it.
+
+``print_scheduler`` decides that question at dispatch; ``pipeline_eligibility``
+predicts the same answer before the queue runs. They used to carry a copy each,
+and the copies drifted until they disagreed in both directions: eligibility
+passed jobs the matcher then failed on type (it aliased ``PLA Basic`` to
+``PLA``, the matcher did not) and flagged jobs the matcher handled fine (the
+matcher groups ``PA12-CF`` with ``PA-CF``, eligibility did not). A predictor
+that disagrees with the thing it predicts is wrong whichever way it leans, so
+both now read from here.
+
+Deliberately *not* shared with ``PrintScheduler._normalize_filament_type``,
+which reduces a tray type to a drying-preset key. "Which drying profile?" is a
+genuinely different question with a different answer — PLA Silk dries like PLA
+but does not print like it — so folding those together would be the wrong kind
+of tidy.
+"""
+
+# Types within a group are interchangeable on the printer side; Bambu Lab
+# firmware treats them as the same material. The first entry is canonical.
+#
+# Product variants are deliberately absent. "PLA Silk" is not substitutable
+# for "PLA Basic" the way PA12-CF is for PA-CF — different temperature, flow
+# and finish, so standing one in for the other hands back a print nobody asked
+# for. It rarely arises anyway: the printer reports the generic material in
+# ``tray_type`` and the product name in ``tray_sub_brands``, so what reaches
+# this function is a bare "PLA".
+FILAMENT_TYPE_GROUPS: list[list[str]] = [
+    ["PA-CF", "PA12-CF", "PAHT-CF"],
+]
+
+_EQUIV_MAP: dict[str, str] = {}
+for _group in FILAMENT_TYPE_GROUPS:
+    _canonical = _group[0].upper()
+    for _type in _group:
+        _EQUIV_MAP[_type.upper()] = _canonical
+
+
+def canonical_filament_type(ftype: str | None) -> str:
+    """Return the canonical type name used for equivalence matching.
+
+    Deliberately does *not* strip surrounding whitespace, so this is byte-for-byte
+    the rule the dispatch matcher already applied, and the same one
+    ``canonicalFilamentType`` applies in ``frontend/src/utils/amsHelpers.ts``.
+
+    Stripping looks like an improvement — it would let a padded " PETG " match
+    "PETG" — but it also collapses a whitespace-only ``tray_type`` to "", and a
+    3MF whose filament element carries no ``type`` attribute yields "" as well
+    (``filament_requirements``). The two would then compare equal, so a
+    requirement with no declared type would start matching a tray whose type is
+    junk, where today it correctly reports the slot unmapped. Handling padded
+    types is worth doing on its own terms, with that case addressed; it is not
+    worth smuggling in here.
+    """
+    upper = (ftype or "").upper()
+    return _EQUIV_MAP.get(upper, upper)
+
+
+def filament_types_compatible(a: str | None, b: str | None) -> bool:
+    """Whether two filament types may stand in for one another."""
+    return canonical_filament_type(a) == canonical_filament_type(b)

+ 56 - 1
backend/tests/integration/test_pipeline_runs_api.py

@@ -230,9 +230,11 @@ class TestCheckEligibility:
         )
         src = await library_file_factory()
 
+        # The printer reports the generic material in tray_type and the product
+        # name in tray_sub_brands, so this is the shape a real AMS sends.
         live_status = {
             "connected": True,
-            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
+            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]},
         }
         with patch(
             "backend.app.api.routes.pipeline_runs._load_printer_status",
@@ -248,6 +250,59 @@ class TestCheckEligibility:
         assert body["issues"] == []
         assert body["target_printer_name"] == printer.name
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_product_name_in_tray_type_is_reported_as_a_mismatch(
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        """Eligibility answers with the dispatch matcher's type rules, not its own.
+
+        It used to alias "PLA Basic" to "PLA" and pass this; the matcher never
+        did, so the run cleared the pre-flight and then failed to map the slot.
+        Flagging it here is the honest answer even though it is the stricter one.
+        """
+        from backend.app.models.local_preset import LocalPreset
+
+        preset = LocalPreset(
+            name="My PLA",
+            preset_type="filament",
+            source="manual",
+            setting="{}",
+            filament_type="PLA",
+            default_filament_colour="#FFFFFF",
+        )
+        db_session.add(preset)
+        await db_session.commit()
+        await db_session.refresh(preset)
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(
+            target_printer_id=printer.id,
+            filament_presets=[{"source": "local", "id": str(preset.id)}],
+        )
+        src = await library_file_factory()
+
+        live_status = {
+            "connected": True,
+            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
+        }
+        with patch(
+            "backend.app.api.routes.pipeline_runs._load_printer_status",
+            new=AsyncMock(return_value=live_status),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert [i["kind"] for i in body["issues"]] == ["filament_type_mismatch"]
+
 
 class TestRunPipeline:
     """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""

+ 130 - 0
backend/tests/unit/test_filament_type_equivalence.py

@@ -0,0 +1,130 @@
+"""The dispatch matcher and the pipeline pre-flight must agree on filament types.
+
+``pipeline_eligibility`` exists to tell the operator, before a run starts, what
+the matcher will do when it starts. It used to answer that from its own copy of
+the equivalence table, and the copies had drifted into disagreeing in both
+directions:
+
+  - it aliased ``PLA Basic`` to ``PLA`` where the matcher does not, so a job
+    could clear the pre-flight and then fail on type at dispatch;
+  - it lacked the ``PA12-CF``/``PAHT-CF`` grouping the matcher has, so a job the
+    matcher handles fine was flagged as a mismatch.
+
+Both now read ``backend.app.utils.filament_types``. These tests pin the shared
+answer and, more importantly, pin that the two modules give the *same* answer —
+that is the property that broke, and it cannot be caught by testing either side
+alone.
+"""
+
+import pytest
+
+from backend.app.services.pipeline_eligibility import _canonical as eligibility_canonical
+from backend.app.services.print_scheduler import canonical_filament_type as scheduler_canonical
+from backend.app.utils.filament_types import (
+    FILAMENT_TYPE_GROUPS,
+    canonical_filament_type,
+    filament_types_compatible,
+)
+
+# Every type either module has ever had an opinion about, plus the shapes the
+# printer actually emits.
+VOCABULARY = [
+    "PLA",
+    "PLA Basic",
+    "PLA Matte",
+    "PLA Silk",
+    "PLA Pro",
+    "PLA Tough",
+    "PETG",
+    "PETG HF",
+    "PETG Basic",
+    "PETG Translucent",
+    "ABS",
+    "ASA",
+    "TPU",
+    "TPU 95A",
+    "PC",
+    "PA",
+    "PA-CF",
+    "PA12-CF",
+    "PAHT-CF",
+    "PVA",
+    "",
+    "   ",  # whitespace-only: must behave identically on both sides
+    "EXOTIC_WOOD",
+]
+
+
+class TestSchedulerAndEligibilityAgree:
+    @pytest.mark.parametrize("ftype", VOCABULARY)
+    def test_both_modules_canonicalise_identically(self, ftype):
+        assert scheduler_canonical(ftype) == eligibility_canonical(ftype)
+
+    @pytest.mark.parametrize("a", VOCABULARY)
+    @pytest.mark.parametrize("b", ["PLA", "PA-CF", "PA12-CF", "PETG"])
+    def test_both_modules_agree_on_compatibility(self, a, b):
+        """The pre-flight must never claim a pairing the matcher would reject,
+        nor flag one the matcher would accept."""
+        assert (scheduler_canonical(a) == scheduler_canonical(b)) == (
+            eligibility_canonical(a) == eligibility_canonical(b)
+        )
+
+    def test_the_two_regressions_that_prompted_this(self):
+        # Used to differ: eligibility aliased the product name, the matcher did not.
+        assert eligibility_canonical("PLA Basic") == scheduler_canonical("PLA Basic")
+        assert not filament_types_compatible("PLA Basic", "PLA")
+        # Used to differ the other way: the matcher grouped the nylons, eligibility did not.
+        assert eligibility_canonical("PA12-CF") == scheduler_canonical("PA12-CF")
+        assert filament_types_compatible("PA12-CF", "PA-CF")
+
+
+class TestEquivalenceGroups:
+    def test_nylon_variants_are_interchangeable(self):
+        for variant in ("PA-CF", "PA12-CF", "PAHT-CF"):
+            assert filament_types_compatible(variant, "PA-CF"), variant
+
+    def test_carbon_filled_nylon_is_not_plain_nylon(self):
+        """PA-CF is filled and PA is not; standing one in for the other changes
+        the part."""
+        assert not filament_types_compatible("PA-CF", "PA")
+
+    def test_product_variants_are_not_aliases(self):
+        """Silk, Matte and Basic print differently even though they dry alike,
+        so the matcher must not substitute one for another."""
+        for variant in ("PLA Silk", "PLA Matte", "PLA Basic", "PLA Tough"):
+            assert not filament_types_compatible(variant, "PLA"), variant
+
+    def test_every_group_canonicalises_to_its_first_entry(self):
+        for group in FILAMENT_TYPE_GROUPS:
+            for member in group:
+                assert canonical_filament_type(member) == group[0].upper()
+
+
+class TestNormalisation:
+    def test_matching_is_case_insensitive(self):
+        assert filament_types_compatible("pla", "PLA")
+        assert filament_types_compatible("pa12-cf", "PA-CF")
+
+    def test_missing_type_canonicalises_to_empty(self):
+        assert canonical_filament_type(None) == ""
+        assert canonical_filament_type("") == ""
+
+    def test_surrounding_whitespace_is_preserved_on_purpose(self):
+        """Stripping would be a quiet behaviour change, not a tidy-up.
+
+        A whitespace-only tray_type would collapse to "", and so does the type
+        of a 3MF filament element that declares none — so a typeless
+        requirement would start matching a junk-typed tray instead of reporting
+        the slot unmapped. Both sides of the app agree on the unstripped rule,
+        which is what matters here; padded types are a separate fix.
+        """
+        assert canonical_filament_type("  PETG  ") == "  PETG  "
+        assert not filament_types_compatible("  PETG  ", "PETG")
+        assert not filament_types_compatible("   ", "")
+
+    def test_an_unknown_type_passes_through_uppercased(self):
+        """Third-party materials still compare against themselves rather than
+        collapsing into one bucket."""
+        assert canonical_filament_type("exotic_wood") == "EXOTIC_WOOD"
+        assert filament_types_compatible("exotic_wood", "EXOTIC_WOOD")
+        assert not filament_types_compatible("EXOTIC_WOOD", "PLA")

+ 29 - 10
backend/tests/unit/test_nearest_colour_match_2804.py

@@ -6,9 +6,13 @@ so the winner depended on which slot a spool sat in rather than on which colour
 was closest.
 
 The worked example throughout is the maintainer's: a required ``#3A7BD5`` with a
-purple ``#6253AD`` in tray 1 (40/40/40 off — admitted by the box, distance ~69)
-and a near-identical ``#3B7AD2`` in tray 3 (distance ~3). Both qualify; the
-purple used to win on position alone.
+purple ``#6253AD`` in tray 1 (40/40/40 off — admitted by the box) and a
+near-identical ``#3B7AD2`` in tray 3. Both qualify; the purple used to win on
+position alone.
+
+Ranking is by CIEDE2000 delta-E, so the two are ~18.5 and ~0.5 apart rather
+than the ~69 and ~3 an RGB metric reported. Eligibility is still the per-channel
+RGB box, unchanged — only the ordering within it is perceptual.
 """
 
 import pytest
@@ -16,8 +20,8 @@ import pytest
 from backend.app.services.print_scheduler import PrintScheduler
 
 REQUIRED_COLOR = "#3A7BD5"
-NEAR = "3B7AD2FF"  # distance ~3
-FAR_BUT_ADMITTED = "6253ADFF"  # 40/40/40 off — inside the box, distance ~69
+NEAR = "3B7AD2FF"  # dE00 ~0.5
+FAR_BUT_ADMITTED = "6253ADFF"  # 40/40/40 off — inside the box, dE00 ~18.5
 
 
 @pytest.fixture
@@ -48,9 +52,19 @@ class TestColorDistance:
         """The alpha a slicer writes is not a colour the user chose."""
         assert scheduler._color_distance("#76D9F4", "76D9F400") == 0
 
-    def test_distance_is_euclidean_not_per_channel(self, scheduler):
-        # 40 off on each channel is sqrt(3 * 40^2) ~= 69.28, not 40.
-        assert scheduler._color_distance("#000000", "#282828") == pytest.approx(69.28, abs=0.01)
+    def test_distance_is_perceptual_not_per_channel(self, scheduler):
+        # A near-match is ranked by how different it looks, not by how far
+        # apart the numbers are. Scale is CIEDE2000 delta-E, where ~1 is a
+        # just-noticeable difference — see test_perceptual_color_distance.py
+        # for the formula's verification against published reference data.
+        assert scheduler._color_distance("#000000", "#282828") == pytest.approx(9.91, abs=0.01)
+
+    def test_a_perceptually_nearer_colour_beats_a_numerically_nearer_one(self, scheduler):
+        # Against a green requirement, a purple is the closer of the two by RGB
+        # distance (49.7 vs 56.9) and much the further once measured
+        # perceptually. Both are inside the tolerance, so ranking alone decides.
+        required = "#1E4821"
+        assert scheduler._color_distance("#43683E", required) < scheduler._color_distance("#38202F", required)
 
     def test_unusable_input_is_none_rather_than_a_number(self, scheduler):
         assert scheduler._color_distance(None, "#3A7BD5") is None
@@ -82,8 +96,13 @@ class TestNearestSimilarWins:
 
     def test_ties_keep_the_caller_order_so_prefer_lowest_still_decides(self, scheduler):
         """Two spools equally close: the incoming order wins, which is the
-        prefer-lowest sort when that preference is on."""
-        loaded = [tray(2, "3A7BD0FF"), tray(7, "3A7BDAFF")]  # both 5 away
+        prefer-lowest sort when that preference is on.
+
+        Same colour in both trays, so the tie is exact. Two *different* colours
+        at equal RGB distance are not perceptually tied — that is the whole
+        point of the metric — so they cannot be used to test this any more.
+        """
+        loaded = [tray(2, NEAR), tray(7, NEAR)]
         assert scheduler._match_filaments_to_slots([req()], loaded) == [2]
         assert scheduler._match_filaments_to_slots([req()], list(reversed(loaded))) == [7]
 

+ 150 - 0
backend/tests/unit/test_perceptual_color_distance.py

@@ -0,0 +1,150 @@
+"""Verification for the CIEDE2000 colour metric used to rank spool matches.
+
+The matcher ranks the spools its tolerance admits by how far their colour is
+from the one the file asks for. That ranking is only as trustworthy as the
+metric, so the formula is pinned against the published reference set rather
+than against numbers this codebase produced — an implementation that agrees
+with 31 independently published values is right; one that agrees with its own
+output is merely consistent.
+
+``_ciede2000`` is private, and driven directly here on purpose: the reference
+data is expressed in L*a*b*, so going through the hex entry point would fold
+the sRGB conversion into what is meant to test the difference formula alone.
+"""
+
+import math
+
+import pytest
+
+from backend.app.utils.color_utils import _ciede2000, _hex_to_lab, perceptual_color_distance
+
+# Sharma, Wu & Dalal, "The CIEDE2000 Color-Difference Formula", Table 1.
+# Pairs 9-12 straddle the hue-angle discontinuity and are what catch a sign
+# error in the mean-hue branch; pairs 30-31 sit near black where the lightness
+# weighting dominates.
+SHARMA_PAIRS = [
+    ((50.0000, 2.6772, -79.7751), (50.0000, 0.0000, -82.7485), 2.0425),
+    ((50.0000, 3.1571, -77.2803), (50.0000, 0.0000, -82.7485), 2.8615),
+    ((50.0000, 2.8361, -74.0200), (50.0000, 0.0000, -82.7485), 3.4412),
+    ((50.0000, -1.3802, -84.2814), (50.0000, 0.0000, -82.7485), 1.0000),
+    ((50.0000, -1.1848, -84.8006), (50.0000, 0.0000, -82.7485), 1.0000),
+    ((50.0000, -0.9009, -85.5211), (50.0000, 0.0000, -82.7485), 1.0000),
+    ((50.0000, 0.0000, 0.0000), (50.0000, -1.0000, 2.0000), 2.3669),
+    ((50.0000, -1.0000, 2.0000), (50.0000, 0.0000, 0.0000), 2.3669),
+    ((50.0000, 2.4900, -0.0010), (50.0000, -2.4900, 0.0009), 7.1792),
+    ((50.0000, 2.4900, -0.0010), (50.0000, -2.4900, 0.0010), 7.1792),
+    ((50.0000, 2.4900, -0.0010), (50.0000, -2.4900, 0.0011), 7.2195),
+    ((50.0000, 2.4900, -0.0010), (50.0000, -2.4900, 0.0012), 7.2195),
+    ((50.0000, 2.5000, 0.0000), (50.0000, 0.0000, -2.5000), 4.3065),
+    ((50.0000, 2.5000, 0.0000), (73.0000, 25.0000, -18.0000), 27.1492),
+    ((50.0000, 2.5000, 0.0000), (61.0000, -5.0000, 29.0000), 22.8977),
+    ((50.0000, 2.5000, 0.0000), (56.0000, -27.0000, -3.0000), 31.9030),
+    ((50.0000, 2.5000, 0.0000), (58.0000, 24.0000, 15.0000), 19.4535),
+    ((50.0000, 2.5000, 0.0000), (50.0000, 3.1736, 0.5854), 1.0000),
+    ((50.0000, 2.5000, 0.0000), (50.0000, 3.2972, 0.0000), 1.0000),
+    ((50.0000, 2.5000, 0.0000), (50.0000, 1.8634, 0.5757), 1.0000),
+    ((50.0000, 2.5000, 0.0000), (50.0000, 3.2592, 0.3350), 1.0000),
+    ((60.2574, -34.0099, 36.2677), (60.4626, -34.1751, 39.4387), 1.2644),
+    ((63.0109, -31.0961, -5.8663), (62.8187, -29.7946, -4.0864), 1.2630),
+    ((61.2901, 3.7196, -5.3901), (61.4292, 2.2480, -4.9620), 1.8731),
+    ((35.0831, -44.1164, 3.7933), (35.0232, -40.0716, 1.5901), 1.8645),
+    ((22.7233, 20.0904, -46.6940), (23.0331, 14.9730, -42.5619), 2.0373),
+    ((36.4612, 47.8580, 18.3852), (36.2715, 50.5065, 21.2231), 1.4146),
+    ((90.8027, -2.0831, 1.4410), (91.1528, -1.6435, 0.0447), 1.4441),
+    ((90.9257, -0.5406, -0.9208), (88.6381, -0.8985, -0.7239), 1.5381),
+    ((6.7747, -0.2908, -2.4247), (5.8714, -0.0985, -2.2286), 0.6377),
+    ((2.0776, 0.0795, -1.1350), (0.9033, -0.0636, -0.5514), 0.9082),
+]
+
+
+class TestAgainstPublishedReference:
+    @pytest.mark.parametrize(("lab1", "lab2", "expected"), SHARMA_PAIRS)
+    def test_matches_sharma_reference_value(self, lab1, lab2, expected):
+        assert _ciede2000(lab1, lab2) == pytest.approx(expected, abs=1e-4)
+
+    @pytest.mark.parametrize(("lab1", "lab2", "_expected"), SHARMA_PAIRS)
+    def test_is_symmetric(self, lab1, lab2, _expected):
+        """Which spool is 'first' must not change how far apart two colours are."""
+        assert _ciede2000(lab1, lab2) == pytest.approx(_ciede2000(lab2, lab1), abs=1e-12)
+
+    def test_a_colour_is_zero_from_itself(self):
+        assert _ciede2000((50.0, 2.5, 0.0), (50.0, 2.5, 0.0)) == 0.0
+
+
+class TestHexEntryPoint:
+    def test_identical_colours_are_zero_apart(self):
+        assert perceptual_color_distance("#3A7BD5", "3A7BD5FF") == 0.0
+
+    def test_alpha_is_ignored_so_a_transparent_filament_matches_itself(self):
+        assert perceptual_color_distance("#76D9F4", "76D9F400") == 0.0
+
+    @pytest.mark.parametrize("bad", [None, "", "#abc", "#zzzzzz", "   "])
+    def test_unusable_input_is_none_rather_than_a_number(self, bad):
+        assert perceptual_color_distance(bad, "#3A7BD5") is None
+        assert perceptual_color_distance("#3A7BD5", bad) is None
+
+    def test_black_and_white_are_the_full_lightness_range_apart(self):
+        # L* runs 0..100, and with no chroma difference dE00 reduces to dL/SL.
+        assert perceptual_color_distance("#000000", "#FFFFFF") == pytest.approx(100.0, abs=0.01)
+
+    def test_pure_hues_are_far_apart(self):
+        assert perceptual_color_distance("#FF0000", "#0000FF") > 50
+
+
+class TestWhyItReplacedRgbDistance:
+    """RGB distance rates a colour by how far apart the numbers are, which is
+    not how far apart they look. These are the cases that motivated the swap."""
+
+    def test_rgb_would_rank_a_purple_above_a_green_for_a_green_requirement(self):
+        required = "#1E4821"  # dark green
+        purple = "#38202F"
+        green = "#43683E"
+
+        # Both sit inside the per-channel tolerance, so both are eligible and
+        # the ranking alone decides which one prints.
+        assert all(
+            abs(int(required[1:][i : i + 2], 16) - int(c[1:][i : i + 2], 16)) <= 40
+            for c in (purple, green)
+            for i in (0, 2, 4)
+        )
+
+        def rgb_distance(a, b):
+            return math.dist(
+                [int(a[1:][i : i + 2], 16) for i in (0, 2, 4)],
+                [int(b[1:][i : i + 2], 16) for i in (0, 2, 4)],
+            )
+
+        # The old metric put the purple nearer...
+        assert rgb_distance(purple, required) < rgb_distance(green, required)
+        # ...and the perceptual one puts the green nearer, by a wide margin.
+        assert perceptual_color_distance(green, required) < perceptual_color_distance(purple, required)
+
+    def test_equal_rgb_distances_are_not_equally_visible(self):
+        """Five steps of blue either side of the same colour are identical in
+        RGB and measurably different perceptually — which is why the tie-break
+        test uses genuinely identical colours."""
+        required = "#3A7BD5"
+        assert perceptual_color_distance("#3A7BD0", required) != perceptual_color_distance("#3A7BDA", required)
+
+
+class TestLabConversion:
+    def test_reference_white_maps_to_l100_and_no_chroma(self):
+        # Not exact: the sRGB->XYZ matrix and the D65 white point are each
+        # rounded independently in the standards, so white lands a few parts in
+        # 10^6 off L*=100. Immaterial next to a just-noticeable difference of 1.
+        lab = _hex_to_lab("FFFFFF")
+        assert lab is not None
+        light, a, b = lab
+        assert light == pytest.approx(100.0, abs=1e-4)
+        assert a == pytest.approx(0.0, abs=1e-3)
+        assert b == pytest.approx(0.0, abs=1e-3)
+
+    def test_black_maps_to_the_origin(self):
+        assert _hex_to_lab("000000") == pytest.approx((0.0, 0.0, 0.0), abs=1e-9)
+
+    def test_greys_have_no_chroma(self):
+        for grey in ("404040", "808080", "C0C0C0"):
+            lab = _hex_to_lab(grey)
+            assert lab is not None
+            assert lab[1] == pytest.approx(0.0, abs=1e-3)
+            assert lab[2] == pytest.approx(0.0, abs=1e-3)

+ 48 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -1397,3 +1397,51 @@ describe('colour verdict is independent of how the tray was found (#2687)', () =
     expect(item.colorMatch).toBe(false);
   });
 });
+
+describe('filament type equivalence groups reach the matcher', () => {
+  // The scheduler has always canonicalised types before comparing, so PA12-CF
+  // satisfies a PA-CF requirement at dispatch. The interface compared the raw
+  // strings, so it called the same pairing a type mismatch — the badge
+  // contradicted what the printer would actually do, and the manual override
+  // picker (which groups by canonical type) offered the very spool the badge
+  // then rejected. Both sides now use `filamentTypesCompatible`.
+  const wantPaCf = {
+    filaments: [{ slot_id: 1, type: 'PA-CF', color: '#1A1A1A', used_grams: 20 }],
+  };
+  const pa12Loaded = createPrinterStatus([
+    { id: 0, tray: [{ id: 0, tray_type: 'PA12-CF', tray_color: '1A1A1AFF' }] },
+  ]);
+
+  it('matches PA12-CF against a PA-CF requirement', () => {
+    const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {});
+    expect(item.loaded?.globalTrayId).toBe(0);
+    expect(item.status).toBe('match');
+  });
+
+  it('agrees with the mapping the request is built from', () => {
+    expect(
+      buildAmsMapping(buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {})),
+    ).toEqual([0]);
+  });
+
+  it('still refuses a genuinely different material', () => {
+    const petg = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PETG', tray_color: '1A1A1AFF' }] },
+    ]);
+    const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(petg), {});
+    expect(item.status).toBe('mismatch');
+  });
+
+  it('does not treat a product variant as an alias for the base material', () => {
+    // "PLA Silk" dries like PLA but does not print like it, so the matcher
+    // must not substitute one for the other. Matches the backend's rule.
+    const silk = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA Silk', tray_color: 'FFFFFFFF' }] },
+    ]);
+    const wantPla = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 20 }],
+    };
+    const [item] = buildFilamentComparison(wantPla, buildLoadedFilaments(silk), {});
+    expect(item.status).toBe('mismatch');
+  });
+});

+ 31 - 7
frontend/src/__tests__/utils/nearestColourMatch.test.ts

@@ -7,8 +7,13 @@
  * dialog cannot promise a spool the backend would not have picked.
  *
  * The worked example is the maintainer's: required `#3A7BD5`, a purple
- * `#6253AD` in tray 1 (40/40/40 off — admitted, distance ~69) and a
- * near-identical `#3B7AD2` in tray 3 (distance ~3).
+ * `#6253AD` in tray 1 (40/40/40 off — admitted) and a near-identical `#3B7AD2`
+ * in tray 3.
+ *
+ * Ranking is by CIEDE2000 delta-E, matching the backend's
+ * `perceptual_color_distance`, so those two are ~18.5 and ~0.5 apart rather
+ * than the ~69 and ~3 an RGB metric reported. Eligibility is still the
+ * per-channel RGB box, unchanged — only the ordering within it is perceptual.
  */
 
 import { describe, expect, it } from 'vitest';
@@ -31,14 +36,31 @@ describe('colorDistance', () => {
     expect(colorDistance('#76D9F4', '76D9F400')).toBe(0);
   });
 
-  it('is euclidean rather than per-channel', () => {
-    expect(colorDistance('#000000', '#282828')).toBeCloseTo(69.28, 1);
+  it('is perceptual rather than per-channel', () => {
+    // CIEDE2000 delta-E, where ~1 is a just-noticeable difference. The backend
+    // must give the same answer for the same pair.
+    expect(colorDistance('#000000', '#282828')).toBeCloseTo(9.91, 2);
+    expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(0.4797, 3);
+    expect(colorDistance(FAR_BUT_ADMITTED, REQUIRED)).toBeCloseTo(18.4853, 3);
+  });
+
+  it('ranks a perceptually nearer colour above a numerically nearer one', () => {
+    // Against a green requirement, a purple is closer by RGB distance
+    // (49.7 vs 56.9) and much further perceptually. Both are inside the
+    // tolerance, so the ranking alone decides which one prints.
+    const green = '#1E4821';
+    expect(colorDistance('#43683E', green)!).toBeLessThan(colorDistance('#38202F', green)!);
+  });
+
+  it('is symmetric, so which spool is listed first cannot change the answer', () => {
+    expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(colorDistance(REQUIRED, NEAR)!, 12);
   });
 
   it('returns null for unusable input rather than a number', () => {
     expect(colorDistance(undefined, REQUIRED)).toBeNull();
     expect(colorDistance('', REQUIRED)).toBeNull();
     expect(colorDistance('#abc', REQUIRED)).toBeNull();
+    expect(colorDistance('#zzzzzz', REQUIRED)).toBeNull();
   });
 });
 
@@ -53,9 +75,11 @@ describe('findNearestSimilar', () => {
   });
 
   it('keeps the incoming order on a tie, so prefer-lowest still decides', () => {
-    // Both 5 away — whichever the caller put first wins.
-    expect(pick([tray(2, '3A7BD0FF'), tray(7, '3A7BDAFF')])).toBe(2);
-    expect(pick([tray(7, '3A7BDAFF'), tray(2, '3A7BD0FF')])).toBe(7);
+    // Same colour in both trays, so the tie is exact. Two *different* colours
+    // at equal RGB distance are not perceptually tied — that is the whole point
+    // of the metric — so they cannot be used to test this any more.
+    expect(pick([tray(2, NEAR), tray(7, NEAR)])).toBe(2);
+    expect(pick([tray(7, NEAR), tray(2, NEAR)])).toBe(7);
   });
 
   it('admits exactly what the tolerance admitted before', () => {

+ 2 - 1
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -17,6 +17,7 @@ import { isGcodeCompatible } from '../../utils/printer';
 import {
   normalizeColorForCompare,
   colorsAreSimilar,
+  filamentTypesCompatible,
   autoMatchFilament,
   filterFilamentsByNozzle,
   effectivePreferLowest,
@@ -122,7 +123,7 @@ function InlineMappingEditor({
     // Determine status
     let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
     if (loaded) {
-      const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
+      const typeMatch = filamentTypesCompatible(loaded.type, req.type);
       const colorMatch =
         normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
         colorsAreSimilar(loaded.color, req.color);

+ 8 - 7
frontend/src/hooks/useFilamentMapping.ts

@@ -4,6 +4,7 @@ import {
   normalizeColor,
   normalizeColorForCompare,
   colorsAreSimilar,
+  filamentTypesCompatible,
   findNearestSimilar,
   formatSlotLabel,
   getGlobalTrayId,
@@ -257,7 +258,7 @@ export function buildFilamentComparison(
       const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
 
       if (manualLoaded) {
-        const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+        const typeMatch = filamentTypesCompatible(manualLoaded.type, req.type);
         const colorMatch = coloursMatch(manualLoaded.color, req.color);
 
         let status: FilamentStatus;
@@ -330,19 +331,19 @@ export function buildFilamentComparison(
         }
         exactMatch = idxMatches.find(
           (f) =>
-            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            filamentTypesCompatible(f.type, req.type) &&
             normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
         );
         if (!exactMatch) {
           similarMatch = findNearestSimilar(
-            idxMatches.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
+            idxMatches.filter((f) => filamentTypesCompatible(f.type, req.type)),
             req.color,
             (f) => f.color,
           );
         }
         if (!exactMatch && !similarMatch) {
           typeOnlyMatch = idxMatches.find(
-            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+            (f) => filamentTypesCompatible(f.type, req.type)
           );
         }
       }
@@ -352,19 +353,19 @@ export function buildFilamentComparison(
     if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
       exactMatch = available.find(
         (f) =>
-          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          filamentTypesCompatible(f.type, req.type) &&
           normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
       );
       if (!exactMatch) {
         similarMatch = findNearestSimilar(
-          available.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
+          available.filter((f) => filamentTypesCompatible(f.type, req.type)),
           req.color,
           (f) => f.color,
         );
       }
       if (!exactMatch && !similarMatch) {
         typeOnlyMatch = available.find(
-          (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+          (f) => filamentTypesCompatible(f.type, req.type)
         );
       }
     }

+ 8 - 7
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -11,6 +11,7 @@ import {
 import {
   normalizeColorForCompare,
   colorsAreSimilar,
+  filamentTypesCompatible,
   findNearestSimilar,
   preferLowestSortKey,
   compareSortKeys,
@@ -116,7 +117,7 @@ function computeMatchDetails(
       const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
 
       if (manualLoaded) {
-        const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+        const typeMatch = filamentTypesCompatible(manualLoaded.type, req.type);
         const colorMatch =
           normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
           colorsAreSimilar(manualLoaded.color, req.color);
@@ -152,13 +153,13 @@ function computeMatchDetails(
 
     const exactMatch = candidates.find(
       (f) =>
-        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        filamentTypesCompatible(f.type, req.type) &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
       : findNearestSimilar(
-          candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
+          candidates.filter((f) => filamentTypesCompatible(f.type, req.type)),
           req.color,
           (f) => f.color,
         );
@@ -166,7 +167,7 @@ function computeMatchDetails(
       exactMatch || similarMatch
         ? undefined
         : candidates.find(
-            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+            (f) => filamentTypesCompatible(f.type, req.type)
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 
@@ -241,13 +242,13 @@ function computeMappingWithOverrides(
 
     const exactMatch = candidates.find(
       (f) =>
-        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        filamentTypesCompatible(f.type, req.type) &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
       : findNearestSimilar(
-          candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()),
+          candidates.filter((f) => filamentTypesCompatible(f.type, req.type)),
           req.color,
           (f) => f.color,
         );
@@ -255,7 +256,7 @@ function computeMappingWithOverrides(
       exactMatch || similarMatch
         ? undefined
         : candidates.find(
-            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+            (f) => filamentTypesCompatible(f.type, req.type)
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 

+ 118 - 14
frontend/src/utils/amsHelpers.ts

@@ -116,31 +116,135 @@ export function colorsAreSimilar(
   );
 }
 
+const D65_WHITE: readonly [number, number, number] = [0.95047, 1.0, 1.08883];
+const LAB_DELTA = 6 / 29;
+
 /**
- * Euclidean RGB distance between two hex colours, or null if either is unusable.
+ * Convert a hex colour to CIE L*a*b* under D65, or null if it is unusable.
+ *
+ * Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
+ * slicer writes for a transparent filament is not a colour the user chose, and
+ * counting it would stop a transparent filament matching itself.
+ */
+function hexToLab(color: string | undefined): [number, number, number] | null {
+  const hex = normalizeColorForCompare(color);
+  if (!hex || hex.length < 6) return null;
+
+  const channels = [0, 2, 4].map((i) => parseInt(hex.substring(i, i + 2), 16) / 255);
+  if (channels.some(Number.isNaN)) return null;
+
+  // sRGB gamma -> linear light.
+  const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
+
+  const xyz: [number, number, number] = [
+    0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
+    0.2126729 * r + 0.7151522 * g + 0.072175 * b,
+    0.0193339 * r + 0.119192 * g + 0.9503041 * b,
+  ];
+
+  const f = (t: number) =>
+    t > LAB_DELTA ** 3 ? Math.cbrt(t) : t / (3 * LAB_DELTA * LAB_DELTA) + 4 / 29;
+  const [fx, fy, fz] = xyz.map((v, i) => f(v / D65_WHITE[i]));
+
+  return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
+}
+
+/**
+ * CIEDE2000 colour difference between two L*a*b* triples.
+ *
+ * Straight transcription of the CIE formulation with kL = kC = kH = 1, kept
+ * structurally identical to `perceptual_color_distance` in
+ * `backend/app/utils/color_utils.py` so the two can be read side by side. They
+ * must agree: the dialog must not promise a spool the scheduler would not pick.
+ */
+function ciede2000(lab1: [number, number, number], lab2: [number, number, number]): number {
+  const [l1, a1, b1] = lab1;
+  const [l2, a2, b2] = lab2;
+  const rad = (deg: number) => (deg * Math.PI) / 180;
+
+  const c1 = Math.hypot(a1, b1);
+  const c2 = Math.hypot(a2, b2);
+  const cBar7 = ((c1 + c2) / 2) ** 7;
+  const g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + 25 ** 7)));
+
+  const a1p = (1 + g) * a1;
+  const a2p = (1 + g) * a2;
+  const c1p = Math.hypot(a1p, b1);
+  const c2p = Math.hypot(a2p, b2);
+
+  const hue = (ap: number, bp: number) => {
+    if (ap === 0 && bp === 0) return 0;
+    const deg = (Math.atan2(bp, ap) * 180) / Math.PI;
+    return deg < 0 ? deg + 360 : deg;
+  };
+  const h1p = hue(a1p, b1);
+  const h2p = hue(a2p, b2);
+
+  const dlp = l2 - l1;
+  const dcp = c2p - c1p;
+
+  const chromaProduct = c1p * c2p;
+  let dhp = 0;
+  if (chromaProduct !== 0) {
+    dhp = h2p - h1p;
+    if (dhp > 180) dhp -= 360;
+    else if (dhp < -180) dhp += 360;
+  }
+  const dhpBig = 2 * Math.sqrt(chromaProduct) * Math.sin(rad(dhp) / 2);
+
+  const lBar = (l1 + l2) / 2;
+  const cBar = (c1p + c2p) / 2;
+
+  let hBar: number;
+  if (chromaProduct === 0) hBar = h1p + h2p;
+  else if (Math.abs(h1p - h2p) <= 180) hBar = (h1p + h2p) / 2;
+  else if (h1p + h2p < 360) hBar = (h1p + h2p + 360) / 2;
+  else hBar = (h1p + h2p - 360) / 2;
+
+  const t =
+    1 -
+    0.17 * Math.cos(rad(hBar - 30)) +
+    0.24 * Math.cos(rad(2 * hBar)) +
+    0.32 * Math.cos(rad(3 * hBar + 6)) -
+    0.2 * Math.cos(rad(4 * hBar - 63));
+
+  const cBarP7 = cBar ** 7;
+  const rc = 2 * Math.sqrt(cBarP7 / (cBarP7 + 25 ** 7));
+  const sl = 1 + (0.015 * (lBar - 50) ** 2) / Math.sqrt(20 + (lBar - 50) ** 2);
+  const sc = 1 + 0.045 * cBar;
+  const sh = 1 + 0.015 * cBar * t;
+  const rt = -Math.sin(rad(2 * (30 * Math.exp(-(((hBar - 275) / 25) ** 2))))) * rc;
+
+  const dL = dlp / sl;
+  const dC = dcp / sc;
+  const dH = dhpBig / sh;
+  return Math.sqrt(dL * dL + dC * dC + dH * dH + rt * dC * dH);
+}
+
+/**
+ * Perceptual distance between two hex colours, or null if either is unusable.
  *
  * Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
  * per-channel box that shipped; this only decides which of several eligible
  * spools is closest, so no spool becomes usable or unusable because of it.
  *
- * Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
- * slicer writes for a transparent filament is not a colour the user chose, and
- * counting it would stop a transparent filament matching itself.
+ * It ranks by how far apart the colours *look*, not how far apart their numbers
+ * are. RGB distance overweights blue badly enough to invert the answer: against
+ * a required `#1E4821` green, a purple `#38202F` is the nearer of two eligible
+ * spools by RGB and four times the further once measured perceptually.
+ *
+ * The scale is CIEDE2000 delta-E, where ~1 is a just-noticeable difference —
+ * far smaller numbers than the RGB distances this replaced, and not comparable
+ * against an RGB threshold.
  */
 export function colorDistance(
   color1: string | undefined,
   color2: string | undefined,
 ): number | null {
-  const hex1 = normalizeColorForCompare(color1);
-  const hex2 = normalizeColorForCompare(color2);
-  if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return null;
-
-  const dr = parseInt(hex1.substring(0, 2), 16) - parseInt(hex2.substring(0, 2), 16);
-  const dg = parseInt(hex1.substring(2, 4), 16) - parseInt(hex2.substring(2, 4), 16);
-  const db = parseInt(hex1.substring(4, 6), 16) - parseInt(hex2.substring(4, 6), 16);
-  if (Number.isNaN(dr) || Number.isNaN(dg) || Number.isNaN(db)) return null;
-
-  return Math.sqrt(dr * dr + dg * dg + db * db);
+  const lab1 = hexToLab(color1);
+  const lab2 = hexToLab(color2);
+  if (!lab1 || !lab2) return null;
+  return ciede2000(lab1, lab2);
 }
 
 /**

Plik diff jest za duży
+ 0 - 0
static/assets/index-BVhCaZnn.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-D686R9Ft.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DAVz1Dw0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D4EtyCBI.css">
+    <script type="module" crossorigin src="/assets/index-D686R9Ft.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BVhCaZnn.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików