From b1858a91292e3d36163dce7328dcbe06d5b762f1 Mon Sep 17 00:00:00 2001 From: RNL Date: Mon, 23 Feb 2026 23:16:56 +1100 Subject: [PATCH] bambu color import manage tool added --- .../commands/bambu_import_colors.py | 418 ++++++++++++++++++ docs/Bambu_Color_Catalog/ABS.txt | 24 + docs/Bambu_Color_Catalog/ASA.txt | 6 + docs/Bambu_Color_Catalog/PA6-GF.txt | 8 + docs/Bambu_Color_Catalog/PC FR.txt | 3 + docs/Bambu_Color_Catalog/PETG HF.txt | 14 + docs/Bambu_Color_Catalog/PETG Translucent.txt | 8 + docs/Bambu_Color_Catalog/PLA Basic.txt | 60 +++ docs/Bambu_Color_Catalog/PLA Matte.txt | 50 +++ docs/Bambu_Color_Catalog/PLA Wood.txt | 6 + 10 files changed, 597 insertions(+) create mode 100644 bambu_run/management/commands/bambu_import_colors.py create mode 100644 docs/Bambu_Color_Catalog/ABS.txt create mode 100644 docs/Bambu_Color_Catalog/ASA.txt create mode 100644 docs/Bambu_Color_Catalog/PA6-GF.txt create mode 100644 docs/Bambu_Color_Catalog/PC FR.txt create mode 100644 docs/Bambu_Color_Catalog/PETG HF.txt create mode 100644 docs/Bambu_Color_Catalog/PETG Translucent.txt create mode 100644 docs/Bambu_Color_Catalog/PLA Basic.txt create mode 100644 docs/Bambu_Color_Catalog/PLA Matte.txt create mode 100644 docs/Bambu_Color_Catalog/PLA Wood.txt diff --git a/bambu_run/management/commands/bambu_import_colors.py b/bambu_run/management/commands/bambu_import_colors.py new file mode 100644 index 0000000..832c6f1 --- /dev/null +++ b/bambu_run/management/commands/bambu_import_colors.py @@ -0,0 +1,418 @@ +""" +Management command to import Bambu Lab filament color catalogs into the FilamentColor database. + +Parses .txt color catalog files (one file per filament sub-type) and creates or skips +FilamentColor records. FilamentType records are auto-created as needed. + +Usage: + # Import a single file + python manage.py bambu_import_colors docs/Bambu_Color_Catalog/PLA\ Basic.txt + + # Import all .txt files in a directory + python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ + + # Dry-run (preview without writing) + python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --dry-run + + # Fail instead of auto-creating missing FilamentType entries + python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --no-auto-create-filament-type + +File naming convention: + The stem determines filament type and sub-type: + PLA Basic.txt → type=PLA, sub_type=PLA Basic + PA6-GF.txt → type=PA6, sub_type=PA6-GF + ABS.txt → type=ABS, sub_type=ABS + +Supported file formats: + Format 1 (multi-line): Format 2 (same-line / tab-separated): + Jade White Black Walnut #4F3F24 + Hex:#FFFFFF Rosewood #4C241C + + Hex values may appear as: Hex:#RRGGBB Hex: #RRGGBB #RRGGBB RRGGBB +""" + +import logging +import re +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from bambu_run.models import FilamentColor, FilamentType + +logger = logging.getLogger("bambu_run.import_colors") + +BRAND = "Bambu Lab" + +# ─── Parsing helpers ────────────────────────────────────────────────────────── + +_SAME_LINE_RE = re.compile( + r'^(.+?)\s+(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE +) +_HEX_ONLY_RE = re.compile( + r'^\s*(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE +) + + +def _stem_to_type_and_subtype(stem): + """ + Derive (filament_type, filament_sub_type) from a file stem. + + The sub-type is the full stem. The type is everything before the first + space or hyphen. + + "PLA Basic" → ("PLA", "PLA Basic") + "PA6-GF" → ("PA6", "PA6-GF") + "ABS" → ("ABS", "ABS") + "PETG HF" → ("PETG", "PETG HF") + """ + sub_type = stem + m = re.search(r'[ -]', stem) + filament_type = stem[: m.start()] if m else stem + return filament_type, sub_type + + +def _parse_file(path): + """ + Parse a color catalog file and return a list of (color_name, hex_code) tuples. + + hex_code is always 6-char uppercase without '#'. + + Raises ValueError if the file cannot be read. + """ + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + raise ValueError(f"Cannot read file: {exc}") from exc + + lines = text.splitlines() + colors = [] + i = 0 + + while i < len(lines): + stripped = lines[i].strip() + i += 1 + + if not stripped: + continue + + # ── Format 2: color name + hex on the same line ───────────────────── + m = _SAME_LINE_RE.match(stripped) + if m: + colors.append((m.group(1).strip(), m.group(2).upper())) + continue + + # ── Orphaned hex line with no preceding name — skip ────────────────── + if _HEX_ONLY_RE.match(stripped): + logger.warning(" [parse] Orphaned hex line (no preceding name): '%s'", stripped) + continue + + # ── Format 1: color name on this line, hex on the next ────────────── + color_name = stripped + found_hex = False + + while i < len(lines): + next_stripped = lines[i].strip() + i += 1 # tentatively consume + + if not next_stripped: + continue # skip blank lines between name and hex + + m_hex = _HEX_ONLY_RE.match(next_stripped) + if m_hex: + colors.append((color_name, m_hex.group(1).upper())) + found_hex = True + else: + # Not a hex line — put it back for the outer loop + i -= 1 + logger.warning( + " [parse] Expected hex after '%s', got '%s' — skipping name", + color_name, + next_stripped, + ) + break # look-ahead done (one non-empty line checked) + + if not found_hex: + logger.warning( + " [parse] Color '%s' has no hex line following it — skipping", color_name + ) + + return colors + + +# ─── Command ────────────────────────────────────────────────────────────────── + + +class Command(BaseCommand): + help = ( + "Import Bambu Lab filament color catalog .txt files into the FilamentColor database. " + "Accepts a single .txt file or a directory of .txt files." + ) + + def add_arguments(self, parser): + parser.add_argument( + "path", + help="Path to a single .txt catalog file or a directory containing .txt files.", + ) + parser.add_argument( + "--auto-create-filament-type", + default=True, + action="store_true", + dest="auto_create", + help="Auto-create FilamentType entries when missing (default: enabled).", + ) + parser.add_argument( + "--no-auto-create-filament-type", + action="store_false", + dest="auto_create", + help="Skip colors whose FilamentType entry does not exist instead of creating it.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview what would be imported without writing to the database.", + ) + + def handle(self, *args, **options): + input_path = Path(options["path"]).expanduser().resolve() + auto_create = options["auto_create"] + dry_run = options["dry_run"] + + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN — no changes will be written.\n")) + + # ── Collect files to process ───────────────────────────────────────── + if input_path.is_dir(): + files = sorted(input_path.glob("*.txt")) + if not files: + raise CommandError(f"No .txt files found in: {input_path}") + self.stdout.write(f"Found {len(files)} .txt file(s) in {input_path}\n") + elif input_path.is_file(): + if input_path.suffix.lower() != ".txt": + raise CommandError(f"Expected a .txt file, got: {input_path.name}") + files = [input_path] + else: + raise CommandError(f"Path does not exist: {input_path}") + + # ── Counters ───────────────────────────────────────────────────────── + total_created = 0 + total_skipped_dup = 0 + total_skipped_no_type = 0 + total_errors = 0 + + for file_path in files: + created, skipped_dup, skipped_no_type, errors = self._process_file( + file_path, auto_create=auto_create, dry_run=dry_run + ) + total_created += created + total_skipped_dup += skipped_dup + total_skipped_no_type += skipped_no_type + total_errors += errors + + # ── Summary ────────────────────────────────────────────────────────── + self.stdout.write("\n" + "─" * 50) + self.stdout.write( + self.style.SUCCESS(f" Created: {total_created}") + ) + self.stdout.write(f" Skipped (duplicate): {total_skipped_dup}") + if total_skipped_no_type: + self.stdout.write( + self.style.WARNING(f" Skipped (no type): {total_skipped_no_type}") + ) + if total_errors: + self.stdout.write( + self.style.ERROR(f" Errors: {total_errors}") + ) + if dry_run: + self.stdout.write(self.style.WARNING("\nDRY RUN complete — nothing was written.")) + + # ── Per-file processing ─────────────────────────────────────────────────── + + def _process_file(self, file_path, *, auto_create, dry_run): + """Process one catalog file. Returns (created, skipped_dup, skipped_no_type, errors).""" + stem = file_path.stem + filament_type, filament_sub_type = _stem_to_type_and_subtype(stem) + + self.stdout.write( + f"\nProcessing: {file_path.name} " + f"→ type={filament_type!r} sub_type={filament_sub_type!r}" + ) + + # ── Parse file ─────────────────────────────────────────────────────── + try: + colors = _parse_file(file_path) + except ValueError as exc: + self.stderr.write(self.style.ERROR(f" ERROR reading file: {exc}")) + return 0, 0, 0, 1 + + if not colors: + self.stdout.write(self.style.WARNING(" No colors parsed — skipping file.")) + return 0, 0, 0, 0 + + self.stdout.write(f" Parsed {len(colors)} color(s).") + + # ── Resolve FilamentType ───────────────────────────────────────────── + filament_type_obj = self._resolve_filament_type( + filament_type, filament_sub_type, auto_create=auto_create, dry_run=dry_run + ) + if filament_type_obj is None and not auto_create: + self.stdout.write( + self.style.WARNING( + f" No FilamentType for type={filament_type!r} " + f"sub_type={filament_sub_type!r} brand={BRAND!r} — " + f"skipping all {len(colors)} color(s) in this file." + ) + ) + return 0, 0, len(colors), 0 + + # ── Import colors ──────────────────────────────────────────────────── + created = skipped_dup = skipped_no_type = errors = 0 + + for color_name, hex_code in colors: + result = self._import_color( + color_name=color_name, + hex_code=hex_code, + filament_type=filament_type, + filament_sub_type=filament_sub_type, + filament_type_obj=filament_type_obj, + dry_run=dry_run, + ) + if result == "created": + created += 1 + elif result == "duplicate": + skipped_dup += 1 + elif result == "no_type": + skipped_no_type += 1 + elif result == "error": + errors += 1 + + self.stdout.write( + f" → created={created} duplicate={skipped_dup} " + f"no_type={skipped_no_type} errors={errors}" + ) + return created, skipped_dup, skipped_no_type, errors + + def _resolve_filament_type(self, filament_type, filament_sub_type, *, auto_create, dry_run): + """ + Return the matching FilamentType instance. + + If none exists: + - auto_create=True → create it (or simulate in dry-run) and return it + - auto_create=False → return None + """ + try: + obj = FilamentType.objects.get( + type=filament_type, + sub_type=filament_sub_type, + brand=BRAND, + ) + return obj + except FilamentType.DoesNotExist: + pass + + if not auto_create: + return None + + if dry_run: + self.stdout.write( + self.style.NOTICE( + f" [dry-run] Would create FilamentType: " + f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}" + ) + ) + return None # can't return a real object in dry-run + + try: + with transaction.atomic(): + obj, created = FilamentType.objects.get_or_create( + type=filament_type, + sub_type=filament_sub_type, + brand=BRAND, + ) + if created: + self.stdout.write( + self.style.SUCCESS( + f" Created FilamentType: " + f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}" + ) + ) + return obj + except Exception as exc: + self.stderr.write( + self.style.ERROR( + f" ERROR creating FilamentType " + f"(type={filament_type!r} sub_type={filament_sub_type!r}): {exc}" + ) + ) + return None + + def _import_color( + self, + *, + color_name, + hex_code, + filament_type, + filament_sub_type, + filament_type_obj, + dry_run, + ): + """ + Import a single (color_name, hex_code) entry. + + Returns one of: "created", "duplicate", "no_type", "error" + """ + if filament_type_obj is None: + # dry-run path: FilamentType would have been created but isn't real yet + if dry_run: + self.stdout.write( + f" [dry-run] Would create: {color_name!r} #{hex_code} " + f"({filament_type} / {filament_sub_type})" + ) + return "created" + return "no_type" + + # ── Duplicate check ────────────────────────────────────────────────── + # All five fields must match to be considered a duplicate: + # color_code (exact), color_name (case-insensitive), brand, + # denormalised filament_type + filament_sub_type + duplicate = FilamentColor.objects.filter( + color_code=hex_code, + color_name__iexact=color_name, + brand=BRAND, + filament_type=filament_type, + filament_sub_type=filament_sub_type, + ).exists() + + if duplicate: + logger.debug(" Duplicate — skipping: %s #%s", color_name, hex_code) + return "duplicate" + + if dry_run: + self.stdout.write( + f" [dry-run] Would create: {color_name!r} #{hex_code} " + f"({filament_type} / {filament_sub_type})" + ) + return "created" + + # ── Write to database ──────────────────────────────────────────────── + try: + with transaction.atomic(): + FilamentColor.objects.create( + color_code=hex_code, + color_name=color_name, + filament_type_fk=filament_type_obj, + filament_type=filament_type, + filament_sub_type=filament_sub_type, + brand=BRAND, + ) + self.stdout.write( + f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})" + ) + return "created" + except Exception as exc: + self.stderr.write( + self.style.ERROR( + f" ERROR saving {color_name!r} #{hex_code}: {exc}" + ) + ) + return "error" diff --git a/docs/Bambu_Color_Catalog/ABS.txt b/docs/Bambu_Color_Catalog/ABS.txt new file mode 100644 index 0000000..767030c --- /dev/null +++ b/docs/Bambu_Color_Catalog/ABS.txt @@ -0,0 +1,24 @@ +White +Hex:#FFFFFF +Bambu Green +Hex:#00AE42 +Olive +Hex:#789D4A +Azure +Hex:#489FDF +Navy Blue +Hex:#0C2340 +Blue +Hex:#0A2CA5 +Tangerine Yellow +Hex:#FFC72C +Orange +Hex:#FF6A13 +Red +Hex:#D32941 +Purple +Hex:#AF1685 +Silver +Hex:#87909A +Black +Hex:#000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/ASA.txt b/docs/Bambu_Color_Catalog/ASA.txt new file mode 100644 index 0000000..15c77fd --- /dev/null +++ b/docs/Bambu_Color_Catalog/ASA.txt @@ -0,0 +1,6 @@ +White #FFFAF2 +Gray #8A949E +Red #E02928 +Green #00A6A0 +Blue #2140B4 +Black #000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PA6-GF.txt b/docs/Bambu_Color_Catalog/PA6-GF.txt new file mode 100644 index 0000000..c501623 --- /dev/null +++ b/docs/Bambu_Color_Catalog/PA6-GF.txt @@ -0,0 +1,8 @@ +White #EAEAE4 +Yellow #FFCE00 +Lime #C5ED48 +Blue #75AED8 +Orange #FF4800 +Brown #5B492F +Gray #353533 +Black #000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PC FR.txt b/docs/Bambu_Color_Catalog/PC FR.txt new file mode 100644 index 0000000..c4024f5 --- /dev/null +++ b/docs/Bambu_Color_Catalog/PC FR.txt @@ -0,0 +1,3 @@ +White #FFFFFF +Gray #A8A8AA +Black #000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PETG HF.txt b/docs/Bambu_Color_Catalog/PETG HF.txt new file mode 100644 index 0000000..a019073 --- /dev/null +++ b/docs/Bambu_Color_Catalog/PETG HF.txt @@ -0,0 +1,14 @@ +Yellow #FFD00B +Orange #F75403 +Green #00AE42 +Red #EB3A3A +Blue #002E96 +Black #000000 +White #FFFFFF +Cream #F9DFB9 +Lime Green #6EE53C +Forest Green #39541A +Lake Blue #1F79E5 +Peanut Brown #875718 +Gray #ADB1B2 +Dark Gray #515151 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PETG Translucent.txt b/docs/Bambu_Color_Catalog/PETG Translucent.txt new file mode 100644 index 0000000..efa2c5e --- /dev/null +++ b/docs/Bambu_Color_Catalog/PETG Translucent.txt @@ -0,0 +1,8 @@ +Translucent Gray #8E8E8E +Translucent Light Blue #61B0FF +Translucent Olive #748C45 +Translucent Brown #C9A381 +Translucent Teal #77EDD7 +Translucent Orange #FF911A +Translucent Purple #D6ABFF +Translucent Pink #F9C1BD \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PLA Basic.txt b/docs/Bambu_Color_Catalog/PLA Basic.txt new file mode 100644 index 0000000..b6319da --- /dev/null +++ b/docs/Bambu_Color_Catalog/PLA Basic.txt @@ -0,0 +1,60 @@ +Jade White +Hex:#FFFFFF +Magenta +Hex:#EC008C +Gold +Hex:#E4BD68 +Mistletoe Green +Hex:#3F8E43 +Red +Hex:#C12E1F +Purple +Hex:#5E43B7 +Beige +Hex:#F7E6DE +Pink +Hex:#F55A74 +Sunflower Yellow +Hex:#FEC600 +Bronze +Hex:#847D48 +Turquoise +Hex:#00B1B7 +Indigo Purple +Hex:#482960 +Light Gray +Hex:#D1D3D5 +Hot Pink +Hex:#F5547C +Yellow +Hex:#F4EE2A +Cocoa Brown +Hex:#6F5034 +Cyan +Hex:#0086D6 +Blue Grey +Hex:#5B6579 +Silver +Hex:#A6A9AA +Orange +Hex:#FF6A13 +Bright Green +Hex:#BECF00 +Brown +Hex:#9D432C +Blue +Hex:#0A2989 +Dark Gray +Hex:#545454 +Gray +Hex:#8E9089 +Pumpkin Orange +Hex:#FF9016 +Bambu Green +Hex:#00AE42 +Maroon Red +Hex:#9D2235 +Cobalt Blue +Hex:#0056B8 +Black +Hex:#000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PLA Matte.txt b/docs/Bambu_Color_Catalog/PLA Matte.txt new file mode 100644 index 0000000..4d8d351 --- /dev/null +++ b/docs/Bambu_Color_Catalog/PLA Matte.txt @@ -0,0 +1,50 @@ +Ivory White +Hex:#FFFFFF +Bone White +Hex:#CBC6B8 +Desert Tan +Hex:#E8DBB7 +Latte Brown +Hex:#D3B7A7 +Caramel +Hex:#AE835B +Terracotta +Hex:#B15533 +Dark Brown +Hex:#7D6556 +Dark Chocolate +Hex:#4D3324 +Lilac Purple +Hex:#AE96D4 +Sakura Pink +Hex:#E8AFCF +Mandarin Orange +Hex:#F99963 +Lemon Yellow +Hex:#F7D959 +Plum +Hex:#950051 +Scarlet Red +Hex:#DE4343 +Dark Red +Hex:#BB3D43 +Dark Green +Hex:#68724D +Grass Green +Hex:#61C680 +Apple Green +Hex:#C2E189 +Ice Blue +Hex:#A3D8E1 +Sky Blue +Hex:#56B7E6 +Marine Blue +Hex:#0078BF +Dark Blue +Hex:#042F56 +Ash Gray +Hex:#9B9EA0 +Nardo Gray +Hex:#757575 +Charcoal +Hex:#000000 \ No newline at end of file diff --git a/docs/Bambu_Color_Catalog/PLA Wood.txt b/docs/Bambu_Color_Catalog/PLA Wood.txt new file mode 100644 index 0000000..3416a34 --- /dev/null +++ b/docs/Bambu_Color_Catalog/PLA Wood.txt @@ -0,0 +1,6 @@ +Black Walnut #4F3F24 +Rosewood #4C241C +Clay Brown #995F11 +Classic Birch #918669 +White Oak #D6CCA3 +Ochre Yellow #C98935 \ No newline at end of file