"""Lightweight label texture generator using PIL. Generates label textures for cables with uniform font sizing. The 'fit-to-longest' algorithm ensures all labels use the same font size, calibrated to the longest text across all cables. """ from PIL import Image, ImageDraw, ImageFont # --- Constants --- # Default texture canvas dimensions (8:1 aspect ratio) DEFAULT_WIDTH = 4096 DEFAULT_HEIGHT = 512 # Reference font size used for measuring relative text widths _REFERENCE_FONT_SIZE = 200 # Horizontal margin in pixels on each side of the text DEFAULT_MARGIN_PX = 40 # Fallback font if no custom font is provided _FALLBACK_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial.ttf" def _load_font(font_path, font_size): """Load a TrueType font, falling back gracefully on failure.""" paths_to_try = [font_path, _FALLBACK_FONT_PATH] if font_path else [_FALLBACK_FONT_PATH] for path in paths_to_try: if not path: continue try: return ImageFont.truetype(path, font_size) except (OSError, IOError): continue # Ultimate fallback: PIL default bitmap font (ugly but functional) return ImageFont.load_default() def measure_text_width(text, font_path, font_size): """Measure the rendered pixel width of a text string. Returns the width in pixels at the given font size. """ font = _load_font(font_path, font_size) # Use a temporary image just for measurement img = Image.new('RGBA', (1, 1)) draw = ImageDraw.Draw(img) bbox = draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0] def calculate_uniform_font_size(text_pairs, font_path, canvas_width, prefix_gap_px=0, margin_px=DEFAULT_MARGIN_PX): """Calculate the largest font size that fits ALL texts within the canvas. This is the core of the 'fit-to-longest' algorithm: 1. Measure all texts at a reference font size 2. Find the widest one 3. Scale the font size so the widest text fits in (canvas_width - 2*margin) Returns the calculated font size as an integer. """ if not text_pairs: return _REFERENCE_FONT_SIZE # We subtract prefix_gap_px from the usable width because the gap is absolute # (in final pixels) and does not scale with the font size. usable_width = canvas_width - (2 * margin_px) - prefix_gap_px if usable_width <= 0: raise ValueError(f"Canvas too narrow: {canvas_width}px with {margin_px}px margins and {prefix_gap_px}px gap") max_width = 0 for prefix, text in text_pairs: w1 = measure_text_width(prefix, font_path, _REFERENCE_FONT_SIZE) if prefix else 0 w2 = measure_text_width(text, font_path, _REFERENCE_FONT_SIZE) if text else 0 max_width = max(max_width, w1 + w2) if max_width == 0: return _REFERENCE_FONT_SIZE # Scale the reference font size proportionally # At _REFERENCE_FONT_SIZE, the widest text is max_width pixels. # We need it to be usable_width pixels. font_size = int((_REFERENCE_FONT_SIZE * usable_width) / max_width) # Clamp to reasonable bounds return max(8, min(font_size, 500)) def generate_label_texture( prefix, text, prefix_gap_px, font_path, font_size, text_color=(255, 255, 255, 255), bg_color=(0, 0, 0, 0), width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT, align='left', margin_px=DEFAULT_MARGIN_PX, ): """Render a text label onto a PIL Image. Args: prefix: The prefix string (can be empty). text: The label string to render. prefix_gap_px: The gap between prefix and text in pixels. font_path: Path to a .ttf/.otf font file. font_size: Font size in pixels. text_color: RGBA tuple for the text color. bg_color: RGBA tuple for the background. width: Canvas width in pixels. height: Canvas height in pixels. align: 'left' or 'right'. margin_px: Horizontal margin in pixels. Returns: A PIL.Image.Image in RGBA mode. """ img = Image.new('RGBA', (width, height), bg_color) draw = ImageDraw.Draw(img) font = _load_font(font_path, font_size) # Measure prefix if prefix: p_bbox = draw.textbbox((0, 0), prefix, font=font) p_width = p_bbox[2] - p_bbox[0] p_height = p_bbox[3] - p_bbox[1] else: p_width, p_height, p_bbox = 0, 0, [0, 0, 0, 0] # Measure text if text: t_bbox = draw.textbbox((0, 0), text, font=font) t_width = t_bbox[2] - t_bbox[0] t_height = t_bbox[3] - t_bbox[1] else: t_width, t_height, t_bbox = 0, 0, [0, 0, 0, 0] actual_gap = prefix_gap_px if (prefix and text) else 0 total_width = p_width + actual_gap + t_width # Vertical centering max_height = max(p_height, t_height) max_y_offset = min(p_bbox[1] if prefix else 9999, t_bbox[1] if text else 9999) if max_y_offset == 9999: max_y_offset = 0 y = (height - max_height) // 2 - max_y_offset # Horizontal positioning if align == 'right': x = width - margin_px - total_width else: x = margin_px if prefix: draw.text((x, y), prefix, font=font, fill=text_color) x += p_width + actual_gap if text: draw.text((x, y), text, font=font, fill=text_color) return img def pil_image_to_blender(image_name, pil_img): """Convert a PIL Image to a Blender image datablock. Creates or overwrites a Blender image with the given name, packs it so it's stored inside the .blend file. """ import bpy import numpy as np width, height = pil_img.size # Ensure RGBA if pil_img.mode != 'RGBA': pil_img = pil_img.convert('RGBA') # Create or reuse the Blender image if image_name in bpy.data.images: bl_img = bpy.data.images[image_name] if bl_img.size[0] != width or bl_img.size[1] != height: bl_img.scale(width, height) else: bl_img = bpy.data.images.new(image_name, width, height, alpha=True) # PIL stores top-to-bottom, Blender expects bottom-to-top flipped = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Convert to float32 array normalized 0-1 (Blender pixel format) pixels = np.array(flipped, dtype=np.float32) / 255.0 bl_img.pixels.foreach_set(pixels.ravel()) bl_img.pack() bl_img.update() return bl_img