diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf31dc24..1f0120dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file. - **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog — so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped — it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature — support base and interface, and the per-region pickers for walls, infill and surfaces — list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form — the whole Line width group showed "0." rather than "0" — and those are now cleaned as the data is generated instead of being papered over at display time. Every field starts from the values your picked process preset actually sets, fetched by flattening it through the slicer sidecar — the same resolver that does the slicing, so the numbers cannot disagree with what a slice produces. A field you never touch shows the preset's value, and reverting returns to it. Where those values cannot be read the panel falls back to the slicer's own defaults and says why — a sidecar older than the feature, one that did not answer, or none configured at all — rather than presenting the defaults as if they were your preset's. The first is much the most likely, because the sidecar image is pulled independently of your Bambuddy version, so that message names the fix outright: update the sidecar image. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. ### Changed +- **A filament whose exact colour is not loaded now prints in the closest one available, not the first one within tolerance (#2804)** — When a file asks for a colour no spool matches exactly, the matcher falls back to spools that are close enough. It picked whichever of those came first in AMS slot order, so the winner depended on which slot a spool happened to sit in: a required `#3A7BD5` with a purple `#6253AD` in tray 1 and a near-identical `#3B7AD2` in tray 3 took the purple, and moving the spools between slots changed the outcome with nothing else changed. Eligible spools are now ranked by how close they actually are, measured as RGB distance, and the nearest wins. Which spool qualifies as close enough is unchanged — the same per-channel tolerance as before — so no spool becomes usable or unusable because of this, only better or worse ranked among those already eligible. **This changes which spool some prints use.** If **Prefer Lowest Filament** is on, its ordering previously decided the fallback outright, and it now acts as the tie-break between spools that are equally close, on the grounds that printing in the right colour matters more than burning down a part-used spool, and that two equally close spools is the case that preference was actually for. Alpha is still ignored on both sides, so a transparent filament keeps matching its own colour. The four places that pick a spool — the scheduler's matcher and the three in the interface — now share one ranking rule instead of carrying four copies of it, which is what allowed them to disagree. The scheduler also logs which rule won for every slot it maps, and the colour distance when the winner was a near-match, rather than only doing so when Prefer Lowest was switched on; "why did it pick that spool" is answerable from the log now. Covered by backend and frontend tests. - **Preheat no longer gives up on a chamber-heated print whose file carries no bed temperature (#2727, contributed by @ticfinack)** — Preheat read its bed target out of the slicer metadata, and a file that carried none — common in OrcaSlicer's `gcode.3mf` exports — made it skip the whole stage and start the print against a cold chamber, which is the outcome preheat exists to prevent. Where the print needs chamber heat the bed is simply how that heat is produced, so those jobs now heat the bed to the **Keep-warm bed temperature** instead of bailing out. A bed temperature found in the file still wins, and a print with no chamber requirement still skips, so no bed temperature is invented for the print itself; preheat's target is transient either way, since the print's own G-code sets its bed at start. **If you have preheat enabled and your files carry no bed temperature, dispatch will now take noticeably longer than it used to** — those jobs previously skipped straight to the upload and will now wait for the chamber to converge and soak, up to twenty minutes at the default wait and soak settings. This applies only with preheat switched on, and the two settings that govern it are unchanged. In the same pass, preheat gained the soak-crediting described above: on a printer that reports its chamber temperature, a soak the chamber has demonstrably already served is shortened or skipped rather than repeated. Both apply wherever preheat runs, not only under the new keep-warm toggle. - **The G-code preview is now the slicer's own renderer** — Sliced files previewed through an embedded copy of a third-party viewer, shown in an iframe. It drew each move as a screen-space line, so a print came out stringy and shimmered wherever layers crossed; it coloured by filament slot only; and being a separate app inside a frame, it could be neither themed nor translated, and needed its own machinery to detect a proxy refusing the embed. It has been replaced by Bambuddy's own viewer built on **libvgcode**, the renderer OrcaSlicer draws its own preview with, so extrusions are solid volumes that occlude one another and a print reads the way it does on the desktop. Colour by **filament** — the default, showing the print in the colours you actually assigned — or by **feature**, where walls, infill, supports, bridges and the prime tower each take the slicer's own colour, or by **layer height** or **line width** on a graduated scale. Every entry in the legend is a switch: click a filament or a feature to take it out of the view, which genuinely removes it rather than hiding it behind what it was covering, so you can look inside a part without its supports in the way. The layer slider has both ends, so a band of layers can be isolated rather than only a top capped, and travel moves can be shown. Reading the file needed real care: BambuStudio annotates its G-code quite differently from OrcaSlicer, and it emits a tenth of its moves as arcs — reading only the one dialect showed a 52-layer print as 23,165 layers in a single colour, and ignoring the arcs punched holes through every curved wall and tree support. Both are handled, along with the helical travel lifts that look like arcs but lay down nothing. Covered by frontend tests. - **The 3D and G-code previews are rendered properly rather than sketched** — The model preview drew every surface with the same flat shading, so a print read as a coloured silhouette with no form, and it sat marooned in the middle of the frame with a screenful of empty space above it. The framing was the plainer bug: the camera distance came from a fixed multiple of the model's largest dimension, which takes no account of the camera's field of view or the shape of the panel it is drawn in, so a tall narrow preview was framed as though it were square. It is now solved against the model's bounding sphere and both fields of view, and fills the frame whatever the panel's proportions. The model itself is lit by a generated environment rather than two lamps and a wash of ambient light, which is what gives a curved surface a gradient across it instead of one flat tone, and it now casts a contact shadow so it looks like it is resting on the plate rather than pasted in front of it. The G-code preview drew each move as a two-pixel line, which is why a sliced model came out stringy and shimmered where layers crossed — a line has no thickness in the scene, so it cannot hide the layer behind it. Moves are now drawn as solid extrusions with real width and height, and the print occludes itself the way it does in a desktop slicer. The modal's second tab is gone: G-code already has its own full-page viewer, and a preview of a model is a different question from a preview of a print. Covered by frontend tests. diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 2e0e1aca0..ed216bbfc 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -2740,6 +2740,31 @@ class PrintScheduler: return "" 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. + + 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. + """ + 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 + 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.""" hex1 = self._normalize_color_for_compare(color1) @@ -2943,6 +2968,7 @@ class PrintScheduler: idx_match = None exact_match = None similar_match = None + similar_distance = float("inf") type_only_match = None # Get available trays (not already used) @@ -3018,8 +3044,10 @@ class PrintScheduler: if not exact_match: exact_match = f elif self._colors_are_similar(f_color, req_color): - if not similar_match: + distance = self._color_distance(f_color, req_color) + if distance is not None and distance < similar_distance: similar_match = f + similar_distance = distance elif not type_only_match: type_only_match = f @@ -3036,8 +3064,15 @@ class PrintScheduler: if not exact_match: exact_match = f elif self._colors_are_similar(f_color, req_color): - if not similar_match: + # Nearest wins, not first-in-tray-order. `available` is + # already in the caller's order (slot order, or the + # prefer-lowest sort), and `<` keeps the earliest of + # equally close spools — so that order survives as the + # tie-break (#2804). + distance = self._color_distance(f_color, req_color) + if distance is not None and distance < similar_distance: similar_match = f + similar_distance = distance elif not type_only_match: type_only_match = f @@ -3047,35 +3082,38 @@ class PrintScheduler: comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]}) else: comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": -1}) - if prefer_lowest: - # Pair with the "available (sorted)" log above so the reporter - # bundle shows BOTH what the matcher saw AND which match bucket - # won — fast triage when "Prefer Lowest Filament" picks the - # wrong slot (#1766). - if match: - bucket = ( - "idx" - if idx_match is not None - else "exact_color" - if exact_match is not None - else "similar_color" - if similar_match is not None - else "type_only" - ) - logger.info( - "[prefer-lowest] picked gtid=%s via %s for req slot=%s", - match["global_tray_id"], - bucket, - req.get("slot_id"), - ) - else: - logger.info( - "[prefer-lowest] NO MATCH for req slot=%s (type=%r color=%r tii=%r)", - req.get("slot_id"), - req_type, - req_color, - req_tray_info_idx, - ) + # Which bucket won, always — not only under Prefer Lowest (#2804). + # "Why did it pick that spool" is the question every wrong-filament + # report starts with, and a `similar_color` win is now a ranked + # choice among several eligible spools rather than whichever tray + # came first, so it is worth being able to see after the fact. + # Pairs with the "available (sorted)" log above when Prefer Lowest + # is on (#1766). + if match: + bucket = ( + "idx" + if idx_match is not None + else "exact_color" + if exact_match is not None + else "similar_color" + if similar_match is not None + else "type_only" + ) + logger.info( + "[ams-match] picked gtid=%s via %s for req slot=%s%s", + match["global_tray_id"], + bucket, + req.get("slot_id"), + f" (colour distance {similar_distance:.1f})" if bucket == "similar_color" else "", + ) + else: + logger.info( + "[ams-match] NO MATCH for req slot=%s (type=%r color=%r tii=%r)", + req.get("slot_id"), + req_type, + req_color, + req_tray_info_idx, + ) # Build mapping array if not comparisons: diff --git a/backend/tests/unit/test_nearest_colour_match_2804.py b/backend/tests/unit/test_nearest_colour_match_2804.py new file mode 100644 index 000000000..f8495a237 --- /dev/null +++ b/backend/tests/unit/test_nearest_colour_match_2804.py @@ -0,0 +1,107 @@ +"""Regression tests for slot-order-dependent colour matching (#2804). + +When no spool matches a required colour exactly, the matcher falls back to +spools inside a per-channel tolerance. It took the first of those in tray order, +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. +""" + +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 + + +@pytest.fixture +def scheduler(): + return PrintScheduler.__new__(PrintScheduler) + + +def tray(global_tray_id, color, type_="PETG"): + return { + "global_tray_id": global_tray_id, + "type": type_, + "color": color, + "tray_info_idx": "", + "extruder_id": 0, + "remain": 100, + } + + +def req(color=REQUIRED_COLOR, slot_id=1, type_="PETG", tray_info_idx=""): + return {"slot_id": slot_id, "type": type_, "color": color, "tray_info_idx": tray_info_idx} + + +class TestColorDistance: + def test_identical_colours_are_zero_apart(self, scheduler): + assert scheduler._color_distance("#3A7BD5", "3A7BD5FF") == 0 + + def test_alpha_is_ignored_so_a_transparent_filament_matches_itself(self, scheduler): + """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_unusable_input_is_none_rather_than_a_number(self, scheduler): + assert scheduler._color_distance(None, "#3A7BD5") is None + assert scheduler._color_distance("", "#3A7BD5") is None + assert scheduler._color_distance("#abc", "#3A7BD5") is None + assert scheduler._color_distance("#zzzzzz", "#3A7BD5") is None + + +class TestNearestSimilarWins: + def test_closest_admitted_colour_wins_regardless_of_slot_order(self, scheduler): + loaded = [tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)] + assert scheduler._match_filaments_to_slots([req()], loaded) == [3] + + def test_result_does_not_depend_on_tray_order(self, scheduler): + """The bug in one line: reversing the AMS used to reverse the answer.""" + forward = scheduler._match_filaments_to_slots([req()], [tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)]) + reversed_ = scheduler._match_filaments_to_slots([req()], [tray(3, NEAR), tray(1, FAR_BUT_ADMITTED)]) + assert forward == reversed_ == [3] + + def test_an_exact_match_still_outranks_a_near_one(self, scheduler): + loaded = [tray(1, NEAR), tray(2, "3A7BD5FF")] + assert scheduler._match_filaments_to_slots([req()], loaded) == [2] + + def test_eligibility_is_unchanged_so_a_far_colour_is_still_type_only(self, scheduler): + """Ranking must not admit spools the tolerance excluded: 41 off on one + channel fails the box and can only win as a type-only fallback.""" + loaded = [tray(5, "3A7BFEFF")] # blue channel 41 away + assert scheduler._match_filaments_to_slots([req()], loaded) == [5] + + 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 + assert scheduler._match_filaments_to_slots([req()], loaded) == [2] + assert scheduler._match_filaments_to_slots([req()], list(reversed(loaded))) == [7] + + def test_nearest_applies_within_a_shared_tray_info_idx_too(self, scheduler): + """The tray_info_idx subset walks its own colour comparison.""" + loaded = [ + tray(1, FAR_BUT_ADMITTED) | {"tray_info_idx": "GFG99"}, + tray(3, NEAR) | {"tray_info_idx": "GFG99"}, + ] + assert scheduler._match_filaments_to_slots([req(tray_info_idx="GFG99")], loaded) == [3] + + def test_type_is_still_a_hard_filter(self, scheduler): + """A perfect colour in the wrong material never wins.""" + loaded = [tray(1, "3A7BD5FF", type_="ASA"), tray(3, NEAR)] + assert scheduler._match_filaments_to_slots([req()], loaded) == [3] + + def test_each_slot_consumes_its_tray(self, scheduler): + """Two slots wanting the same colour take different spools, nearest first.""" + loaded = [tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)] + mapping = scheduler._match_filaments_to_slots([req(slot_id=1), req(slot_id=2)], loaded) + assert mapping == [3, 1] diff --git a/frontend/src/__tests__/utils/nearestColourMatch.test.ts b/frontend/src/__tests__/utils/nearestColourMatch.test.ts new file mode 100644 index 000000000..b90d34d2c --- /dev/null +++ b/frontend/src/__tests__/utils/nearestColourMatch.test.ts @@ -0,0 +1,77 @@ +/** + * Regression tests for slot-order-dependent colour matching (#2804), frontend side. + * + * Three matchers in the interface fell back to "first spool within tolerance" + * when no exact colour was loaded, so the winner depended on AMS slot order. + * They now share `findNearestSimilar` with the scheduler's equivalent, so the + * 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). + */ + +import { describe, expect, it } from 'vitest'; +import { colorDistance, colorsAreSimilar, findNearestSimilar } from '../../utils/amsHelpers'; + +const REQUIRED = '#3A7BD5'; +const NEAR = '3B7AD2FF'; +const FAR_BUT_ADMITTED = '6253ADFF'; + +const tray = (globalTrayId: number, color: string) => ({ globalTrayId, color }); +const pick = (candidates: { globalTrayId: number; color: string }[], required = REQUIRED) => + findNearestSimilar(candidates, required, (c) => c.color)?.globalTrayId; + +describe('colorDistance', () => { + it('is zero for the same colour', () => { + expect(colorDistance('#3A7BD5', '3A7BD5FF')).toBe(0); + }); + + it('ignores alpha so a transparent filament matches itself', () => { + expect(colorDistance('#76D9F4', '76D9F400')).toBe(0); + }); + + it('is euclidean rather than per-channel', () => { + expect(colorDistance('#000000', '#282828')).toBeCloseTo(69.28, 1); + }); + + it('returns null for unusable input rather than a number', () => { + expect(colorDistance(undefined, REQUIRED)).toBeNull(); + expect(colorDistance('', REQUIRED)).toBeNull(); + expect(colorDistance('#abc', REQUIRED)).toBeNull(); + }); +}); + +describe('findNearestSimilar', () => { + it('picks the closest admitted colour, not the first one', () => { + expect(pick([tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)])).toBe(3); + }); + + it('gives the same answer whichever order the trays arrive in', () => { + expect(pick([tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)])).toBe(3); + expect(pick([tray(3, NEAR), tray(1, FAR_BUT_ADMITTED)])).toBe(3); + }); + + 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); + }); + + it('admits exactly what the tolerance admitted before', () => { + // 41 off on one channel is outside the box and must not be ranked in. + expect(colorsAreSimilar('3A7BFEFF', REQUIRED)).toBe(false); + expect(pick([tray(5, '3A7BFEFF')])).toBeUndefined(); + // 40 off on all three is inside it, as it was. + expect(colorsAreSimilar(FAR_BUT_ADMITTED, REQUIRED)).toBe(true); + expect(pick([tray(5, FAR_BUT_ADMITTED)])).toBe(5); + }); + + it('returns undefined when nothing qualifies', () => { + expect(pick([tray(1, 'FF0000FF')])).toBeUndefined(); + }); + + it('skips candidates with no usable colour instead of throwing', () => { + expect(pick([{ globalTrayId: 1, color: '' }, tray(3, NEAR)])).toBe(3); + }); +}); diff --git a/frontend/src/hooks/useFilamentMapping.ts b/frontend/src/hooks/useFilamentMapping.ts index 138de9052..0d7de7551 100644 --- a/frontend/src/hooks/useFilamentMapping.ts +++ b/frontend/src/hooks/useFilamentMapping.ts @@ -4,6 +4,7 @@ import { normalizeColor, normalizeColorForCompare, colorsAreSimilar, + findNearestSimilar, formatSlotLabel, getGlobalTrayId, preferLowestSortKey, @@ -333,10 +334,10 @@ export function buildFilamentComparison( normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color) ); if (!exactMatch) { - similarMatch = idxMatches.find( - (f) => - f.type?.toUpperCase() === req.type?.toUpperCase() && - colorsAreSimilar(f.color, req.color) + similarMatch = findNearestSimilar( + idxMatches.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()), + req.color, + (f) => f.color, ); } if (!exactMatch && !similarMatch) { @@ -355,10 +356,10 @@ export function buildFilamentComparison( normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color) ); if (!exactMatch) { - similarMatch = available.find( - (f) => - f.type?.toUpperCase() === req.type?.toUpperCase() && - colorsAreSimilar(f.color, req.color) + similarMatch = findNearestSimilar( + available.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()), + req.color, + (f) => f.color, ); } if (!exactMatch && !similarMatch) { diff --git a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts index b687d8c5e..064385568 100644 --- a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts +++ b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts @@ -11,6 +11,7 @@ import { import { normalizeColorForCompare, colorsAreSimilar, + findNearestSimilar, preferLowestSortKey, compareSortKeys, effectivePreferLowest, @@ -156,10 +157,10 @@ function computeMatchDetails( ); const similarMatch = exactMatch ? undefined - : candidates.find( - (f) => - f.type?.toUpperCase() === req.type?.toUpperCase() && - colorsAreSimilar(f.color, req.color) + : findNearestSimilar( + candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()), + req.color, + (f) => f.color, ); const typeOnlyMatch = exactMatch || similarMatch @@ -245,10 +246,10 @@ function computeMappingWithOverrides( ); const similarMatch = exactMatch ? undefined - : candidates.find( - (f) => - f.type?.toUpperCase() === req.type?.toUpperCase() && - colorsAreSimilar(f.color, req.color) + : findNearestSimilar( + candidates.filter((f) => f.type?.toUpperCase() === req.type?.toUpperCase()), + req.color, + (f) => f.color, ); const typeOnlyMatch = exactMatch || similarMatch diff --git a/frontend/src/utils/amsHelpers.ts b/frontend/src/utils/amsHelpers.ts index 80dbbe261..292d6c963 100644 --- a/frontend/src/utils/amsHelpers.ts +++ b/frontend/src/utils/amsHelpers.ts @@ -116,6 +116,70 @@ export function colorsAreSimilar( ); } +/** + * Euclidean RGB 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. + */ +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); +} + +/** + * The closest colour match among `candidates`, or undefined if none is similar + * enough to qualify. + * + * Callers pass candidates in the order they already established — slot order, + * or the "prefer lowest remaining" sort. Ties keep the earliest of them, so + * that order survives as the tie-break and Prefer Lowest still decides between + * two equally close spools, which is the case it was actually for. + * + * This exists so the four matchers that pick a spool (`autoMatchFilament`, + * `computeAmsMapping`, `computeMappingWithOverrides`, `computeMatchDetails`) + * share one ranking rule instead of four copies of "first one within + * tolerance", which made the winner depend on AMS slot order. + */ +export function findNearestSimilar( + candidates: T[], + requiredColor: string | undefined, + getColor: (candidate: T) => string | undefined, +): T | undefined { + let best: T | undefined; + let bestDistance = Infinity; + + for (const candidate of candidates) { + const color = getColor(candidate); + if (!colorsAreSimilar(color, requiredColor)) continue; + const distance = colorDistance(color, requiredColor); + if (distance === null) continue; + // Strict <: an equally close candidate never displaces an earlier one. + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + + return best; +} + /** * Format slot label for display in the UI. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT) @@ -319,11 +383,12 @@ export function autoMatchFilament( ); const similarMatch = exactMatch ? undefined - : nozzleFilaments.find( - (f) => - !usedTrayIds.has(f.globalTrayId) && - filamentTypesCompatible(f.type, req.type) && - colorsAreSimilar(f.color, req.color) + : findNearestSimilar( + nozzleFilaments.filter( + (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type), + ), + req.color, + (f) => f.color, ); const typeOnlyMatch = exactMatch || similarMatch