199 lines
6.2 KiB
Python
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
|