From be12f342c215f3873312e418bec93da9b0f3dcfe Mon Sep 17 00:00:00 2001 From: Pavulon87 Date: Tue, 14 Jul 2026 13:37:28 +0200 Subject: [PATCH] fix(ams): crash in _build_auto_ams_box_mapping for plain/no-dialog prints --- kobrax_moonraker_bridge.py | 167 ++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 3 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 36d71b9..ded9f16 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1747,7 +1747,41 @@ class KobraXBridge: return [int(color[0]), int(color[1]), int(color[2]), 255] return [255, 255, 255, 255] - def _build_auto_ams_box_mapping( + @staticmethod + def _hex_to_rgba(hex_color: str | None) -> list[int]: + """Convert a '#RRGGBB' (or 'RRGGBB') string from GCode metadata to [r,g,b,255].""" + if not hex_color: + return [255, 255, 255, 255] + h = str(hex_color).strip().lstrip("#") + if len(h) < 6: + return [255, 255, 255, 255] + try: + return [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255] + except ValueError: + return [255, 255, 255, 255] + + @staticmethod + def _gcode_filament_by_index(gcode_filaments: list[dict] | None) -> dict[int, dict]: + """Index parsed GCode filament metadata (_extract_filament_info) by paint/slot index.""" + by_index: dict[int, dict] = {} + if not gcode_filaments: + return by_index + for f in gcode_filaments: + try: + by_index[int(f.get("slot_index"))] = f + except (TypeError, ValueError): + continue + return by_index + + def _color_distance(self, rgb1: list[int], rgb2: list[int]) -> float: + """Calculate RGB Euclidean distance.""" + return ( + (rgb1[0] - rgb2[0]) ** 2 + + (rgb1[1] - rgb2[1]) ** 2 + + (rgb1[2] - rgb2[2]) ** 2 + ) ** 0.5 + + def _build_auto_ams_box_mapping_old( self, warn_on_empty_default: bool = False, loaded_slots: list[tuple[int, dict]] | None = None, @@ -1783,6 +1817,128 @@ class KobraXBridge: }) return result + + def _build_auto_ams_box_mapping( + self, + warn_on_empty_default: bool = False, + loaded_slots: list[tuple[int, dict]] | None = None, + gcode_filaments: list[dict] | None = None, + ) -> list[dict]: + """Build print mapping by matching G-code filament colors + with currently loaded AMS slots using RGB distance. + """ + + # IMPORTANT: loaded_slots is intentionally ignored whenever gcode_filaments + # is provided. Color matching needs access to ALL currently loaded slots, + # not just the ones whose index happens to coincide with the gcode's + # paint_index - otherwise a black filament physically loaded in slot 3 + # could never be matched to gcode paint_index 1 just because slot 1 + # holds a different color. loaded_slots is only used as a fallback pool + # when the caller has no gcode filament info to match colors against + # (see _build_auto_ams_box_mapping_old for that plain, index-based case). + loaded = loaded_slots if ( gcode_filaments is None or len(gcode_filaments) == 0 ) else None + if loaded is None: + loaded = self._select_loaded_slots_for_print( warn_on_empty_default=warn_on_empty_default ) + + if not loaded: + return [] + + loaded_map = {gidx: s for gidx, s in loaded} + log.debug(f"Loaded map: {loaded_map}") + + gcode_by_index = self._gcode_filament_by_index(gcode_filaments) + log.debug(f"GCode filaments by index: {gcode_by_index}") + + # Slots available for assignment. + available_slots = list(loaded_map.items()) + + result = [] + + max_idx = max(gcode_by_index.keys(), default=-1) + + # Maximum RGB distance: + # sqrt(255² + 255² + 255²) = 441 + # A threshold of 80–100 is reasonable for visually similar colors. + MAX_COLOR_DISTANCE = 100 + + for paint_index in range(max_idx + 1): + req = gcode_by_index.get(paint_index) + + paint_color = ( + self._hex_to_rgba(req.get("color_hex")) + if req else + [255, 255, 255, 255] + ) + + default_material = ( + req.get("material", "PLA") + if req else + "PLA" + ) + + best_match = None + best_distance = float("inf") + + if req: + requested_rgb = paint_color[:3] + + for slot_idx, slot in available_slots: + slot_color = slot.get("color") + + if not slot_color or len(slot_color) < 3: + continue + + # Skip slots with a different material type. + if slot.get("type") != default_material: + continue + + distance = self._color_distance( + requested_rgb, + slot_color[:3], + ) + + if distance < best_distance: + best_distance = distance + best_match = (slot_idx, slot) + + if best_match and best_distance <= MAX_COLOR_DISTANCE: + slot_idx, slot = best_match + + available_slots.remove(best_match) + + log.debug( + f"Matched paint index {paint_index}: " + f"GCODE={paint_color[:3]} " + f"AMS={slot.get('color')} " + f"distance={best_distance:.2f} " + f"slot={slot_idx}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(slot_idx), + "paint_color": paint_color, + "ams_color": self._slot_color_rgba(slot), + "material_type": slot.get("type", default_material), + }) + + else: + log.debug( + f"No AMS match for paint index {paint_index}: " + f"color={paint_color[:3]} " + f"best_distance={best_distance:.2f}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(paint_index), + "paint_color": paint_color, + "ams_color": [255, 255, 255, 255], + "material_type": default_material, + }) + + return result + def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: """Build print mapping from UI filament assignments. @@ -3170,7 +3326,7 @@ class KobraXBridge: return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: # No dialog -> all occupied slots as with a normal upload print - ams_box_mapping = self._build_auto_ams_box_mapping() + ams_box_mapping = self._build_auto_ams_box_mapping_old() auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) filename = gcode_file["filename"] @@ -3708,7 +3864,12 @@ class KobraXBridge: # used slots; used-but-unloaded -> a warning may follow later. loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) + used_gcode_filaments = [ + f for f in (gcode_filaments or []) + if used_paint_indices is not None and f.get("slot_index") in used_paint_indices + ] + + ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded, gcode_filaments=used_gcode_filaments) log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") payload = self._build_print_payload( filename, url, md5, filesize,