nbt2blueprint/helpers.py

381 lines
16 KiB
Python

import re
from PIL import Image, ImageDraw, ImageFont # Make sure ImageDraw is imported
import os
import zipfile
TEXTURE_SIZE = 16 # Default Minecraft texture size in pixels
# --- Constants for Blueprint Generation ---
OUTPUT_BLUEPRINT_DIR = "blueprints" # Directory to save generated blueprints
# --- Define the name for your missing texture file ---
MISSING_TEXTURE_FILENAME = "missing_texture.png" # Make sure this matches your file name
# --- Function to extract base block name from Java block string ---
def get_base_java_block_name(java_block_string, overrides):
"""
Extracts the base block name (e.g., 'stone', 'oak_log') from a full
Java block string (e.g., 'minecraft:stone', 'minecraft:oak_log[axis=y]'),
applying texture name overrides.
"""
# 1. Get the raw base name from the Java block string
name_without_prefix = java_block_string.replace("minecraft:", "")
raw_base_name = name_without_prefix.split('[')[0] # e.g., "spruce_trapdoor"
# 2. Apply override if defined, otherwise return the raw_base_name
overridden_name = overrides.get(raw_base_name, raw_base_name)
return overridden_name
def parse_block_states(java_block_string):
"""
Parses a Java block string to extract its state properties.
e.g., "minecraft:spruce_trapdoor[facing=north,half=bottom]" -> {'facing': 'north', 'half': 'bottom'}
"""
states = {}
match = re.search(r'\[(.*?)\]', java_block_string)
if match:
properties_str = match.group(1)
# Split by comma, then by equals sign
for prop_pair in properties_str.split(','):
if '=' in prop_pair:
key, value = prop_pair.split('=', 1)
states[key] = value
return states
def extract_java_textures(java_jar_path, output_dir):
"""
Extracts block textures from a Minecraft Java Edition client JAR.
Args:
java_jar_path (str): Full path to the Minecraft client JAR file.
output_dir (str): Directory where the extracted textures will be saved (e.g., 'java_textures' will be created inside this).
"""
if not os.path.exists(java_jar_path):
print(f"Error: Java JAR file not found at {java_jar_path}")
return
texture_source_path_in_jar = 'assets/minecraft/textures/block/'
extracted_texture_path = os.path.join(output_dir, 'java_textures') # Save to a subfolder
os.makedirs(extracted_texture_path, exist_ok=True)
print(f"Extracting textures from {java_jar_path} to {extracted_texture_path}...")
with zipfile.ZipFile(java_jar_path, 'r') as zip_ref:
for member in zip_ref.namelist():
if member.startswith(texture_source_path_in_jar) and member.endswith('.png'):
# Get the relative path within the block textures folder
relative_path = os.path.relpath(member, texture_source_path_in_jar)
# Construct the full output path
destination_path = os.path.join(extracted_texture_path, relative_path)
# Ensure the directory exists
os.makedirs(os.path.dirname(destination_path), exist_ok=True)
# Extract the file
with open(destination_path, 'wb') as outfile:
outfile.write(zip_ref.read(member))
print("Texture extraction complete.")
def create_arrow_texture(direction, size=TEXTURE_SIZE):
"""
Creates a red arrow pointing in the specified direction.
Args:
direction (str): 'north', 'south', 'east', 'west'.
size (int): The size of the square texture (e.g., 16).
Returns:
PIL.Image: An RGBA image of the arrow.
"""
arrow_img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) # Transparent background
draw = ImageDraw.Draw(arrow_img)
line_color = (255, 0, 0, 255) # Red with full alpha
line_width = 1 # Pixels
center_x, center_y = size // 2, size // 2
# Define arrow head size relative to TEXTURE_SIZE
# head_size = size // 4 # Example: 4 pixels for 16x16 texture
head_size = size // 8 # Example: 2 pixels for 16x16 texture
if direction == 'north': # Arrow points up (towards decreasing Z)
draw.line([(center_x, size), (center_x, 0)], fill=line_color, width=line_width)
draw.polygon([(center_x, 0), (center_x - head_size, head_size), (center_x + head_size, head_size)], fill=line_color)
elif direction == 'south': # Arrow points down (towards increasing Z)
draw.line([(center_x, 0), (center_x, size)], fill=line_color, width=line_width)
draw.polygon([(center_x, size), (center_x - head_size, size - head_size), (center_x + head_size, size - head_size)], fill=line_color)
elif direction == 'west': # Arrow points left (towards decreasing X)
draw.line([(size, center_y), (0, center_y)], fill=line_color, width=line_width)
draw.polygon([(0, center_y), (head_size, center_y - head_size), (head_size, center_y + head_size)], fill=line_color)
elif direction == 'east': # Arrow points right (towards increasing X)
draw.line([(0, center_y), (size, center_y)], fill=line_color, width=line_width)
draw.polygon([(size, center_y), (size - head_size, center_y - head_size), (size - head_size, center_y + head_size)], fill=line_color)
else:
print(f"Warning: Unknown direction '{direction}' for arrow.")
return Image.new('RGBA', (size, size), (0, 0, 0, 0)) # Return transparent if direction is invalid
return arrow_img
def draw_door_state_overlay(texture: Image.Image, is_open: bool):
"""
Draws a small overlay symbol in the top-left corner of the texture to indicate door state.
- Green open rectangle for open
- Red closed rectangle for closed
"""
draw = ImageDraw.Draw(texture)
size = texture.width
overlay_size = size // 4 # 1/4 of texture size
x0, y0 = 2, 2
x1, y1 = x0 + overlay_size, y0 + overlay_size
if is_open:
# Draw a green open rectangle (open door shape)
draw.rectangle([x0, y0, x1, y1], outline=(0, 200, 0, 255), width=2)
# Draw a gap to indicate "open"
draw.line([x0 + overlay_size//2, y0, x1, y1], fill=(0, 200, 0, 255), width=2)
else:
# Draw a red closed rectangle (closed door shape)
draw.rectangle([x0, y0, x1, y1], outline=(200, 0, 0, 255), width=2)
# Draw a line to indicate "closed"
draw.line([x0, y0, x1, y1], fill=(200, 0, 0, 255), width=2)
def combine_door_halves(
bottom_texture: Image.Image,
top_texture: Image.Image,
half: str = "lower",
overlay_opacity: int = 128 # 0-255, 128 = 50% opacity
) -> Image.Image:
"""
Combines the bottom and top door textures into a single 16x16 image.
The 'other' half is shown with a semi-transparent white overlay.
Args:
bottom_texture: PIL.Image for the door bottom (8x16 or 16x16, will be cropped).
top_texture: PIL.Image for the door top (8x16 or 16x16, will be cropped).
half: "lower" or "upper" (which half is the real one)
overlay_opacity: Opacity for the overlay (0=transparent, 255=opaque)
Returns:
PIL.Image: The combined 16x16 image.
"""
# Ensure both textures are 16x16 (crop or resize if needed)
size = 16
bottom = bottom_texture.crop((0, 0, size, size))
top = top_texture.crop((0, 0, size, size))
# Create a new blank image
combined = Image.new("RGBA", (size, size), (0, 0, 0, 0))
if half == "lower":
# Place bottom half as normal
combined.paste(bottom, (0, 8))
# Place top half above, faded
faded_top = top.crop((0, 0, size, 8)).copy()
overlay = Image.new("RGBA", (size, 8), (255, 255, 255, overlay_opacity))
faded_top = Image.alpha_composite(faded_top, overlay)
combined.paste(faded_top, (0, 0), faded_top)
else: # half == "upper"
# Place top half as normal
combined.paste(top, (0, 0))
# Place bottom half below, faded
faded_bottom = bottom.crop((0, 8, size, 16)).copy()
overlay = Image.new("RGBA", (size, 8), (255, 255, 255, overlay_opacity))
faded_bottom = Image.alpha_composite(faded_bottom, overlay)
combined.paste(faded_bottom, (0, 8), faded_bottom)
return combined
def draw_door_overlay_symbols(
texture: Image.Image,
show_lock: bool = False,
hinge_side: str = "left"
):
"""
Draws overlay symbols in the top-left corner of the texture for doors:
- Optionally draws an unlocked (open) padlock (if show_lock is True)
- Optionally draws a small gray vertical bar on the hinge side ('left' or 'right')
Args:
texture (PIL.Image): The texture to draw on.
show_lock (bool): If True, draws an unlocked padlock.
hinge_side (str): 'left' or 'right' to indicate hinge side, or None.
"""
# Flip texture if hinge is right
if hinge_side =="right":
texture = texture.transpose(Image.FLIP_LEFT_RIGHT)
draw = ImageDraw.Draw(texture)
size = texture.width
overlay_size = size // 4 # 1/4 of texture size
x0, y0 = 2, 2
x1, y1 = x0 + overlay_size, y0 + overlay_size
# Draw hinge bar if specified
if hinge_side in ("left", "right"):
bar_width = max(1, overlay_size // 6)
bar_color = (120, 120, 120, 255) # Gray
if hinge_side == "left":
bar_x0 = x0
bar_x1 = x0 + bar_width
else: # "right"
bar_x0 = x1 - bar_width
bar_x1 = x1
draw.rectangle([bar_x0, y0, bar_x1, y1], fill=bar_color)
if show_lock:
# Draw an unlocked (open) padlock
lock_body_top = y1 - overlay_size // 3
lock_body_bottom = y1 - 2
lock_body_left = x0 + overlay_size // 4
lock_body_right = x1 - overlay_size // 4
# Body of the lock
draw.rectangle(
[lock_body_left, lock_body_top, lock_body_right, lock_body_bottom],
fill=(200, 200, 0, 255), outline=(120, 120, 0, 255), width=1
)
# Shackle of the lock (open arc)
shackle_box = [
lock_body_left - overlay_size // 8, lock_body_top - overlay_size // 4,
lock_body_right + overlay_size // 8, lock_body_top + overlay_size // 4
]
# Draw only part of the arc to look "open"
draw.arc(shackle_box, start=30, end=150, fill=(120, 120, 0, 255), width=2)
def render_grid(
textures_2d,
texture_size,
normal_padding=1,
bold_padding=3,
grid_line_color=(150, 150, 150, 255),
grid_line_bold_color=(80, 80, 80, 255),
grid_line_width=1,
grid_line_bold_width=3,
font_path="arial.ttf",
font_size=10,
margin=24
):
"""
Renders a 2D grid of textures with variable padding for gridlines, bold every 5th line,
and draws numbering outside the grid in an expanded canvas.
Args:
textures_2d: 2D list of PIL.Image objects (row-major: [z][x])
texture_size: Size of each texture square (pixels)
normal_padding: Space (pixels) between cells for normal gridlines
bold_padding: Space (pixels) between cells for every 5th (bold) gridline
grid_line_color: Color for normal grid lines
grid_line_bold_color: Color for every 5th grid line
grid_line_width: Width for normal grid lines
grid_line_bold_width: Width for every 5th grid line
font_path: Path to the font file
font_size: Font size for numbering
margin: Margin in pixels for numbering outside the grid
Returns:
PIL.Image: The rendered grid image with variable padding, gridlines, and outside numbering
"""
length = len(textures_2d)
width = len(textures_2d[0]) if length > 0 else 0
# Build padding arrays for columns and rows
paddings_col = []
for col in range(width + 1):
if col % 5 == 0:
paddings_col.append(bold_padding)
else:
paddings_col.append(normal_padding)
paddings_row = []
for row in range(length + 1):
if row % 5 == 0:
paddings_row.append(bold_padding)
else:
paddings_row.append(normal_padding)
# Calculate total image size (without margin)
img_width = width * texture_size + sum(paddings_col)
img_height = length * texture_size + sum(paddings_row)
# Create expanded image with margin for numbering (top and left)
expanded_width = img_width + margin
expanded_height = img_height + margin
out_img = Image.new('RGBA', (expanded_width, expanded_height), (255, 255, 255, 0))
draw = ImageDraw.Draw(out_img)
# Try to load a default font; fallback if not available
try:
font = ImageFont.truetype(font_path, font_size)
except Exception:
font = ImageFont.load_default()
# Precompute cumulative paddings for fast lookup
cum_paddings_col = [0]
for pad in paddings_col[:-1]:
cum_paddings_col.append(cum_paddings_col[-1] + pad + texture_size)
cum_paddings_row = [0]
for pad in paddings_row[:-1]:
cum_paddings_row.append(cum_paddings_row[-1] + pad + texture_size)
# Paste textures with variable padding, offset by margin
for z in range(length):
for x in range(width):
tx = margin + sum(paddings_col[:x+1]) + x * texture_size
ty = margin + sum(paddings_row[:z+1]) + z * texture_size
out_img.paste(textures_2d[z][x], (tx, ty), textures_2d[z][x])
# Draw vertical gridlines and outside numbering
x = margin
for col in range(width + 1):
is_bold = (col % 5 == 0)
color = grid_line_bold_color if is_bold else grid_line_color
line_width = grid_line_bold_width if is_bold else grid_line_width
pad = paddings_col[col]
offset = (pad - line_width) // 2
draw.line([(x + offset, margin), (x + offset, margin + img_height)], fill=color, width=line_width)
# Draw numbering outside the grid (above the grid)
if is_bold and col != 0 and x < margin + img_width:
text = str(col)
bbox = font.getbbox(text)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
text_x = x + offset - text_width // 2 + line_width // 2
text_y = (margin - text_height - 2) if (margin - text_height - 2) > 0 else 2
draw.rectangle(
[text_x, text_y, text_x + text_width, text_y + text_height],
fill=(255, 255, 255, 180)
)
draw.text((text_x, text_y), text, fill=(0, 0, 0, 255), font=font)
x += pad + (texture_size if col < width else 0)
# Draw horizontal gridlines and outside numbering
y = margin
for row in range(length + 1):
is_bold = (row % 5 == 0)
color = grid_line_bold_color if is_bold else grid_line_color
line_width = grid_line_bold_width if is_bold else grid_line_width
pad = paddings_row[row]
offset = (pad - line_width) // 2
draw.line([(margin, y + offset), (margin + img_width, y + offset)], fill=color, width=line_width)
# Draw numbering outside the grid (to the left of the grid)
if is_bold and row != 0 and y < margin + img_height:
text = str(row)
bbox = font.getbbox(text)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
text_x = (margin - text_width - 2) if (margin - text_width - 2) > 0 else 2
text_y = y + offset - text_height // 2 + line_width // 2
draw.rectangle(
[text_x, text_y, text_x + text_width, text_y + text_height],
fill=(255, 255, 255, 180)
)
draw.text((text_x, text_y), text, fill=(0, 0, 0, 255), font=font)
y += pad + (texture_size if row < length else 0)
return out_img