Files
cable-normalizer/texture_gen.py

199 lines
6.2 KiB
Python

"""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