feat: initial commit for cable normalizer blender addon
This commit is contained in:
198
texture_gen.py
Normal file
198
texture_gen.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user