diff --git a/a1_swap_mod_packer/builder.py b/a1_swap_mod_packer/builder.py index 334fae2..657985e 100644 --- a/a1_swap_mod_packer/builder.py +++ b/a1_swap_mod_packer/builder.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import os +import re import shutil import tempfile import zipfile @@ -34,6 +35,9 @@ except Exception: # pragma: no cover ImageDraw = None ImageFont = None +PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE) +MAX_COMPOSITE_PREVIEW_INPUTS = 9 + def load_plate_sources(job: PlateJob) -> list[PlateSource]: if job.copies < 1: @@ -99,6 +103,14 @@ def process_plate_gcode( return text.rstrip("\n") + "\n" +def save_png_bytes(image: object) -> bytes: + import io + + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes: if Image is None or ImageDraw is None: return png_bytes @@ -129,9 +141,133 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b x = margin y = image.height - margin - (text_bbox[3] - text_bbox[1]) draw.text((x, y), label, font=font, fill=(0, 255, 0, 255)) - output = io.BytesIO() - image.save(output, format="PNG") - return output.getvalue() + return save_png_bytes(image) + + +def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None: + match = GCODE_MEMBER_RE.match(gcode_member) + if not match: + return None + suffix = "_small" if small else "" + return f"Metadata/plate_{match.group(1)}{suffix}.png" + + +def preview_member_sort_key(member_name: str) -> tuple[int, int, str]: + match = PREVIEW_MEMBER_RE.match(member_name) + plate_number = int(match.group(1)) if match else 9999 + is_small = 1 if member_name.lower().endswith("_small.png") else 0 + return (is_small, plate_number, member_name.lower()) + + +def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COMPOSITE_PREVIEW_INPUTS) -> list[PlateSource]: + selected: list[PlateSource] = [] + seen_paths: set[Path] = set() + for source in sources: + key = source.source_3mf.resolve(strict=False) + if key in seen_paths: + continue + seen_paths.add(key) + selected.append(source) + if len(selected) >= max_inputs: + break + return selected + + +def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small: bool = False) -> str | None: + names = set(archive.namelist()) + active_member = resolve_output_gcode_member(archive, source.member_name) + preferred = preview_member_name_for_gcode_member(active_member, small) + if preferred in names: + return preferred + alternate = preview_member_name_for_gcode_member(active_member, not small) + if alternate in names: + return alternate + + candidates = [ + name + for name in names + if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small + ] + if not candidates: + candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)] + if not candidates: + candidates = [ + name + for name in names + if name.lower().startswith("metadata/") and name.lower().endswith(".png") + ] + if not candidates: + return None + return sorted(candidates, key=preview_member_sort_key)[0] + + +def read_source_preview_image(source: PlateSource, small: bool = False) -> object | None: + if Image is None: + return None + import io + + try: + with zipfile.ZipFile(source.source_3mf, "r") as archive: + member = source_preview_member(archive, source, small) + if member is None: + return None + data = archive.read(member) + return Image.open(io.BytesIO(data)).convert("RGBA") + except Exception: + return None + + +def preview_grid_dimensions(count: int) -> tuple[int, int]: + if count <= 1: + return (1, 1) + if count <= 2: + return (2, 1) + if count <= 4: + return (2, 2) + return (3, (count + 2) // 3) + + +def resize_image_to_fit(image: object, width: int, height: int) -> object: + resized = image.copy() + resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC) + resized.thumbnail((max(1, width), max(1, height)), resampling) + return resized + + +def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: str, small: bool = False) -> bytes: + if Image is None: + return png_bytes + import io + + try: + base_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + except Exception: + return update_preview_label(png_bytes, label, small) + + source_images = [ + image + for image in (read_source_preview_image(source, small) for source in select_preview_sources(sources)) + if image is not None + ] + if not source_images: + return update_preview_label(png_bytes, label, small) + + width, height = base_image.size + canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0)) + columns, rows = preview_grid_dimensions(len(source_images)) + for index, image in enumerate(source_images): + column = index % columns + row = index // columns + left = width * column // columns + right = width * (column + 1) // columns + top = height * row // rows + bottom = height * (row + 1) // rows + fitted = resize_image_to_fit(image, right - left, bottom - top) + x = left + max(0, (right - left - fitted.width) // 2) + y = top + max(0, (bottom - top - fitted.height) // 2) + canvas.paste(fitted, (x, y), fitted) + + return update_preview_label(save_png_bytes(canvas), label, small) def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) -> str: @@ -148,14 +284,11 @@ def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) def preview_members_for_gcode_member(gcode_member: str) -> set[str]: - match = GCODE_MEMBER_RE.match(gcode_member) - if not match: + full_member = preview_member_name_for_gcode_member(gcode_member, small=False) + small_member = preview_member_name_for_gcode_member(gcode_member, small=True) + if full_member is None or small_member is None: return set() - plate_index = match.group(1) - return { - f"Metadata/plate_{plate_index}.png", - f"Metadata/plate_{plate_index}_small.png", - } + return {full_member, small_member} def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str: @@ -171,7 +304,7 @@ def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sourc if name == "Metadata/slice_info.config": data = update_first_slice_info(data, sources, options) elif options.add_preview_label and name in preview_members: - data = update_preview_label(data, f"{len(sources)} plates", small=name.endswith("_small.png")) + data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png")) dst.writestr(item, data) dst.writestr(gcode_member, gcode_bytes) dst.writestr(f"{gcode_member}.md5", md5) diff --git a/tests/test_active_plate_member.py b/tests/test_active_plate_member.py index b139ead..2f41264 100644 --- a/tests/test_active_plate_member.py +++ b/tests/test_active_plate_member.py @@ -9,11 +9,17 @@ from pathlib import Path from a1_swap_mod_packer.builder import ( preview_members_for_gcode_member, resolve_output_gcode_member, + select_preview_sources, write_output_3mf, ) from a1_swap_mod_packer.metadata import read_model_settings_gcode_members from a1_swap_mod_packer.models import BuildOptions, PlateSource +try: + from PIL import Image +except Exception: # pragma: no cover + Image = None + class ActivePlateMemberTest(unittest.TestCase): def test_write_output_preserves_configured_plate_member(self) -> None: @@ -56,8 +62,72 @@ class ActivePlateMemberTest(unittest.TestCase): self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5) self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest()) + def test_preview_composite_uses_first_nine_unique_input_files(self) -> None: + if Image is None: + self.skipTest("Pillow is not installed") + + colors = [ + (255, 0, 0, 255), + (0, 0, 255, 255), + (255, 255, 0, 255), + (255, 0, 255, 255), + (0, 255, 255, 255), + (128, 0, 0, 255), + (0, 0, 128, 255), + (128, 128, 0, 255), + (128, 0, 128, 255), + (255, 128, 0, 255), + ] + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sources: list[PlateSource] = [] + for index, color in enumerate(colors): + source_3mf = root / f"source_{index}.3mf" + self.write_plate2_archive(source_3mf, image_color=color, image_size=300) + sources.append( + PlateSource( + source_3mf=source_3mf, + member_name="Metadata/plate_2.gcode", + gcode_text="G1 X0\n", + ) + ) + + selected = select_preview_sources(sources) + self.assertEqual(len(selected), 9) + self.assertEqual(selected[-1].source_3mf.name, "source_8.3mf") + + output_3mf = root / "out.3mf" + options = BuildOptions( + swap_gcode="", + output_3mf=output_3mf, + add_preview_label=True, + apply_gcode_patches=False, + ) + write_output_3mf(sources[0].source_3mf, output_3mf, b"G1 X1\n", sources, options) + + with zipfile.ZipFile(output_3mf, "r") as archive: + image_bytes = archive.read("Metadata/plate_2.png") + + import io + + image = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + sampled_colors = { + image.getpixel((50, 50)), + image.getpixel((150, 50)), + image.getpixel((250, 50)), + image.getpixel((50, 150)), + image.getpixel((150, 150)), + image.getpixel((250, 150)), + image.getpixel((50, 250)), + image.getpixel((150, 250)), + image.getpixel((250, 250)), + } + self.assertTrue(set(colors[:9]).issubset(sampled_colors)) + self.assertNotIn(colors[9], sampled_colors) + @staticmethod - def write_plate2_archive(path: Path) -> None: + def write_plate2_archive(path: Path, image_color: tuple[int, int, int, int] | None = None, image_size: int = 128) -> None: model_settings = """ @@ -82,8 +152,20 @@ class ActivePlateMemberTest(unittest.TestCase): archive.writestr("Metadata/slice_info.config", slice_info) archive.writestr("Metadata/plate_2.gcode", "G1 X0\n") archive.writestr("Metadata/plate_2.gcode.md5", "old") - archive.writestr("Metadata/plate_2.png", b"png") - archive.writestr("Metadata/plate_2_small.png", b"png") + if image_color is None or Image is None: + archive.writestr("Metadata/plate_2.png", b"png") + archive.writestr("Metadata/plate_2_small.png", b"png") + else: + import io + + image = Image.new("RGBA", (image_size, image_size), image_color) + output = io.BytesIO() + image.save(output, format="PNG") + archive.writestr("Metadata/plate_2.png", output.getvalue()) + small = image.resize((max(1, image_size // 2), max(1, image_size // 2))) + output = io.BytesIO() + small.save(output, format="PNG") + archive.writestr("Metadata/plate_2_small.png", output.getvalue()) if __name__ == "__main__":