1663 lines
62 KiB
Python
1663 lines
62 KiB
Python
"""Blender headless colored-patch texture generator (YAML-free).
|
||
|
||
The host CLI (orisys-design texture) reads design.yaml, writes a JSON job file,
|
||
and invokes this script. Blender only needs stdlib json — not PyYAML:
|
||
|
||
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
|
||
--job <abs/build/textures/finger/r01/_texture_job.json>
|
||
|
||
Manual single-target mode (cannot be combined with --job) derives same-stem
|
||
SVG/PDF paths from the PNG path:
|
||
|
||
--uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
|
||
--width 3280 --height 3200 --vector-width-mm 164 --vector-height-mm 160 \
|
||
--nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
|
||
|
||
Scope: the colored "patches" mode only. checker/dots helpers remain available to
|
||
callers but are not reachable from the headless entry point. The mesh must
|
||
already carry the requested UV maps: UV unwrapping is still an in-app Blender
|
||
step and is never performed here. A scene saved in Edit mode is handled by
|
||
switching to Object mode in memory only — the .blend is never written.
|
||
|
||
- Mesh UVs are filled white so that non-visible parts become white.
|
||
- Texture background (no-mesh areas) stays black.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import bpy
|
||
import numpy as np
|
||
from mathutils import Vector
|
||
|
||
# Blender -P does not consistently include the script directory on sys.path.
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
from patch_geometry import clip_rect
|
||
from patch_projection import build_patch_geometry
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Common Utilities
|
||
# ----------------------------------------------------------------------
|
||
def cross_rectangles(width, height, cx_uv, cy_uv, length_px, thickness_px):
|
||
"""Return horizontal/vertical cross arms as bottom-left pixel rectangles."""
|
||
cx = int(round(cx_uv * (width - 1)))
|
||
cy = int(round(cy_uv * (height - 1)))
|
||
half_len = length_px // 2
|
||
half_thick = thickness_px // 2
|
||
|
||
horizontal = (
|
||
max(0, cx - half_len),
|
||
max(0, cy - half_thick),
|
||
min(width - 1, cx + half_len),
|
||
min(height - 1, cy + half_thick),
|
||
)
|
||
vertical = (
|
||
max(0, cx - half_thick),
|
||
max(0, cy - half_len),
|
||
min(width - 1, cx + half_thick),
|
||
min(height - 1, cy + half_len),
|
||
)
|
||
return [
|
||
(x0, y0, x1 - x0 + 1, y1 - y0 + 1)
|
||
for x0, y0, x1, y1 in (horizontal, vertical)
|
||
]
|
||
|
||
|
||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||
"""Draw a cross (plus sign), using the same rectangles as vector output."""
|
||
for x0, y0, rect_width, rect_height in cross_rectangles(
|
||
width, height, cx_uv, cy_uv, length_px, thickness_px
|
||
):
|
||
for y in range(y0, y0 + rect_height):
|
||
for x in range(x0, x0 + rect_width):
|
||
idx = (y * width + x) * 4
|
||
pixels[idx:idx+4] = color
|
||
|
||
def fill_background(pixels, width, height, color):
|
||
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
|
||
r, g, b, a = color
|
||
for y in range(height):
|
||
for x in range(width):
|
||
idx = (y * width + x) * 4
|
||
pixels[idx] = r
|
||
pixels[idx+1] = g
|
||
pixels[idx+2] = b
|
||
pixels[idx+3] = a
|
||
|
||
def fill_polygon(image, pixels, width, height, polygon, color):
|
||
"""Fill a polygon in the pixel array (flattened, RGBA)."""
|
||
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
|
||
if len(poly) < 3:
|
||
return
|
||
min_x = max(0, min(p[0] for p in poly))
|
||
max_x = min(width-1, max(p[0] for p in poly))
|
||
min_y = max(0, min(p[1] for p in poly))
|
||
max_y = min(height-1, max(p[1] for p in poly))
|
||
for y in range(min_y, max_y + 1):
|
||
intersections = []
|
||
for i in range(len(poly)):
|
||
x1, y1 = poly[i]
|
||
x2, y2 = poly[(i+1) % len(poly)]
|
||
if y1 == y2:
|
||
continue
|
||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||
t = (y - y1) / (y2 - y1)
|
||
x = x1 + t * (x2 - x1)
|
||
intersections.append(x)
|
||
intersections.sort()
|
||
for k in range(0, len(intersections), 2):
|
||
if k+1 >= len(intersections):
|
||
break
|
||
x_start = int(round(intersections[k]))
|
||
x_end = int(round(intersections[k+1]))
|
||
x_start = max(0, min(width-1, x_start))
|
||
x_end = max(0, min(width-1, x_end))
|
||
for x in range(x_start, x_end + 1):
|
||
idx = (y * width + x) * 4
|
||
pixels[idx:idx+4] = color
|
||
|
||
def fill_polygon_value(grid, width, height, polygon, value):
|
||
"""Fill a polygon into a 2D scalar grid."""
|
||
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
|
||
if len(poly) < 3:
|
||
return
|
||
min_x = max(0, min(p[0] for p in poly))
|
||
max_x = min(width - 1, max(p[0] for p in poly))
|
||
min_y = max(0, min(p[1] for p in poly))
|
||
max_y = min(height - 1, max(p[1] for p in poly))
|
||
for y in range(min_y, max_y + 1):
|
||
intersections = []
|
||
for i in range(len(poly)):
|
||
x1, y1 = poly[i]
|
||
x2, y2 = poly[(i + 1) % len(poly)]
|
||
if y1 == y2:
|
||
continue
|
||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||
t = (y - y1) / (y2 - y1)
|
||
x = x1 + t * (x2 - x1)
|
||
intersections.append(x)
|
||
intersections.sort()
|
||
for k in range(0, len(intersections), 2):
|
||
if k + 1 >= len(intersections):
|
||
break
|
||
x_start = int(round(intersections[k]))
|
||
x_end = int(round(intersections[k + 1]))
|
||
x_start = max(0, min(width - 1, x_start))
|
||
x_end = max(0, min(width - 1, x_end))
|
||
grid[y, x_start:x_end + 1] = value
|
||
|
||
def unwrap_periodic_values(values):
|
||
"""
|
||
Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
|
||
"""
|
||
arr = np.asarray(values, dtype=np.float64).copy()
|
||
if arr.size == 0:
|
||
return arr
|
||
if arr.max() - arr.min() > 0.5:
|
||
arr[arr < 0.5] += 1.0
|
||
return arr
|
||
|
||
def wrap_uv_polygon_for_raster(uv_poly):
|
||
"""
|
||
Return a seam-aware version of a UV polygon.
|
||
|
||
UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
|
||
single polygon, Blender's repeated UVs turn into a long diagonal triangle
|
||
across the texture. We unwrap seam-crossing polygons first, then draw wrapped
|
||
copies in the texture tile(s) they overlap.
|
||
"""
|
||
if len(uv_poly) < 3:
|
||
return []
|
||
|
||
uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
|
||
uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
|
||
uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
|
||
return [tuple(pt) for pt in uv_arr]
|
||
|
||
def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||
"""
|
||
Convert one UV polygon into one or more pixel polygons, duplicating seam-
|
||
crossing polygons into adjacent tiles so rasterization stays local.
|
||
"""
|
||
unwrapped = wrap_uv_polygon_for_raster(uv_poly)
|
||
if len(unwrapped) < 3:
|
||
return []
|
||
|
||
u_vals = [u for u, _ in unwrapped]
|
||
v_vals = [v for _, v in unwrapped]
|
||
u_shift_start = int(np.floor(min(u_vals)))
|
||
u_shift_end = int(np.floor(max(u_vals)))
|
||
v_shift_start = int(np.floor(min(v_vals)))
|
||
v_shift_end = int(np.floor(max(v_vals)))
|
||
|
||
pixel_polys = []
|
||
for u_shift in range(u_shift_start, u_shift_end + 1):
|
||
for v_shift in range(v_shift_start, v_shift_end + 1):
|
||
shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
|
||
shifted_u = [u for u, _ in shifted]
|
||
shifted_v = [v for _, v in shifted]
|
||
if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
|
||
continue
|
||
if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
|
||
continue
|
||
pixel_polys.append([
|
||
(u * (tex_width - 1), v * (tex_height - 1))
|
||
for u, v in shifted
|
||
])
|
||
return pixel_polys
|
||
|
||
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
||
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
|
||
cos_a = np.cos(angle)
|
||
sin_a = np.sin(angle)
|
||
rx = abs(a * cos_a) + abs(b * sin_a)
|
||
ry = abs(a * sin_a) + abs(b * cos_a)
|
||
x_min = int(max(0, cx - rx))
|
||
x_max = int(min(width-1, cx + rx))
|
||
y_min = int(max(0, cy - ry))
|
||
y_max = int(min(height-1, cy + ry))
|
||
for y in range(y_min, y_max + 1):
|
||
for x in range(x_min, x_max + 1):
|
||
dx = x - cx
|
||
dy = y - cy
|
||
u = dx * cos_a + dy * sin_a
|
||
v = -dx * sin_a + dy * cos_a
|
||
if (u/a)**2 + (v/b)**2 <= 1.0:
|
||
idx = (y * width + x) * 4
|
||
pixels[idx:idx+4] = color
|
||
|
||
def get_uv_from_face(obj, face_index, location, uv_layer=None):
|
||
"""World‑space location → UV coordinate on the mesh face.
|
||
|
||
``uv_layer`` selects the mesh UV map explicitly. ``None`` keeps the previous
|
||
active-layer behaviour, which the non-headless checker/dot helpers rely on.
|
||
"""
|
||
me = obj.data
|
||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||
if layer is None:
|
||
return None
|
||
local_loc = obj.matrix_world.inverted() @ location
|
||
poly = me.polygons[face_index]
|
||
vert_indices = poly.vertices
|
||
loop_indices = poly.loop_indices
|
||
if len(vert_indices) < 3:
|
||
return None
|
||
v0 = me.vertices[vert_indices[0]].co
|
||
v1 = me.vertices[vert_indices[1]].co
|
||
v2 = me.vertices[vert_indices[2]].co
|
||
uv_data = layer.data
|
||
uv0 = uv_data[loop_indices[0]].uv
|
||
uv1 = uv_data[loop_indices[1]].uv
|
||
uv2 = uv_data[loop_indices[2]].uv
|
||
def tri_area(a, b, c):
|
||
return 0.5 * ((b - a).cross(c - a)).length
|
||
total_area = tri_area(v0, v1, v2)
|
||
if total_area == 0:
|
||
return None
|
||
w0 = tri_area(local_loc, v1, v2) / total_area
|
||
w1 = tri_area(local_loc, v2, v0) / total_area
|
||
w2 = 1.0 - w0 - w1
|
||
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
|
||
return Vector((uv.x, uv.y))
|
||
|
||
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||
"""Camera raycast through normalised sensor coordinates (0‑1)."""
|
||
sx = (u_ndc - 0.5) * frame_width
|
||
sy = (v_ndc - 0.5) * frame_height
|
||
direction_cam = Vector((sx, sy, -1.0))
|
||
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
|
||
direction_world.normalize()
|
||
origin = cam.matrix_world.translation
|
||
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
|
||
depsgraph, origin, direction_world)
|
||
if hit and hit_obj == obj:
|
||
return location, face_index
|
||
return None, None
|
||
|
||
def raycast_uv_from_camera(
|
||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer=None
|
||
):
|
||
"""Camera raycast through normalized sensor coordinates and return UV."""
|
||
loc, face_idx = raycast_from_camera(
|
||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
||
)
|
||
if loc is None:
|
||
return None
|
||
return get_uv_from_face(obj, face_idx, loc, uv_layer)
|
||
|
||
def convex_hull_uv(points):
|
||
"""Return UV points ordered around their 2D convex hull."""
|
||
unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
|
||
unique = sorted(set(unwrapped))
|
||
if len(unique) < 3:
|
||
return None
|
||
|
||
def cross(o, a, b):
|
||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||
|
||
lower = []
|
||
for p in unique:
|
||
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
|
||
lower.pop()
|
||
lower.append(p)
|
||
|
||
upper = []
|
||
for p in reversed(unique):
|
||
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
|
||
upper.pop()
|
||
upper.append(p)
|
||
|
||
hull = lower[:-1] + upper[:-1]
|
||
if len(hull) < 3:
|
||
return None
|
||
return [Vector((u, v)) for u, v in hull]
|
||
|
||
def sample_patch_uv_polygon(
|
||
scene, depsgraph, cam, obj,
|
||
u_start, u_end, v_start, v_end,
|
||
frame_width, frame_height,
|
||
edge_sample_grid=5, uv_layer=None
|
||
):
|
||
"""
|
||
Build a UV polygon for one camera-space patch.
|
||
|
||
Full interior cells use the four corners. Edge cells that only partially hit
|
||
the mesh are sampled more densely and converted to a convex hull so they are
|
||
not dropped just because some corners miss the silhouette.
|
||
|
||
``uv_layer`` selects which mesh UV map the raycasts are resolved against.
|
||
"""
|
||
corners_ndc = [
|
||
(u_start, v_start),
|
||
(u_end, v_start),
|
||
(u_end, v_end),
|
||
(u_start, v_end),
|
||
]
|
||
|
||
corner_uvs = []
|
||
for u_ndc, v_ndc in corners_ndc:
|
||
uv = raycast_uv_from_camera(
|
||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||
)
|
||
if uv is not None:
|
||
corner_uvs.append(uv)
|
||
|
||
if len(corner_uvs) >= 3:
|
||
return corner_uvs
|
||
|
||
sampled_uvs = list(corner_uvs)
|
||
for sy in range(edge_sample_grid):
|
||
v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
|
||
for sx in range(edge_sample_grid):
|
||
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
|
||
uv = raycast_uv_from_camera(
|
||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||
)
|
||
if uv is not None:
|
||
sampled_uvs.append(uv)
|
||
|
||
return convex_hull_uv(sampled_uvs)
|
||
|
||
def mesh_uv_pixel_polygons(obj, tex_width, tex_height, uv_layer=None):
|
||
"""Return every mesh face as seam-aware pixel-space polygons."""
|
||
me = obj.data
|
||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||
if not layer:
|
||
return []
|
||
uv_data = layer.data
|
||
pixel_polygons = []
|
||
for poly in me.polygons:
|
||
uv_poly = []
|
||
for loop_idx in poly.loop_indices:
|
||
uv = uv_data[loop_idx].uv
|
||
uv_poly.append((uv.x, uv.y))
|
||
pixel_polygons.extend(
|
||
uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height)
|
||
)
|
||
return pixel_polygons
|
||
|
||
|
||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
|
||
"""
|
||
Fill all triangles of one UV map with 'color' (default white).
|
||
This marks every part of the mesh, visible or not, as white.
|
||
|
||
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
|
||
behaviour used by the checker/dot helpers.
|
||
"""
|
||
pixel_polygons = mesh_uv_pixel_polygons(
|
||
obj, tex_width, tex_height, uv_layer=uv_layer
|
||
)
|
||
if not pixel_polygons:
|
||
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
||
return
|
||
for pixel_poly in pixel_polygons:
|
||
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||
|
||
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
||
"""Rasterize the active UV map to a boolean mesh mask."""
|
||
mask = np.zeros((tex_height, tex_width), dtype=bool)
|
||
me = obj.data
|
||
uv_layer = me.uv_layers.active
|
||
if not uv_layer:
|
||
return mask
|
||
uv_data = uv_layer.data
|
||
for poly in me.polygons:
|
||
uv_poly = []
|
||
for loop_idx in poly.loop_indices:
|
||
uv = uv_data[loop_idx].uv
|
||
uv_poly.append((uv.x, uv.y))
|
||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||
fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
|
||
return mask
|
||
|
||
def paint_mask_color(pixels, width, height, mask, color):
|
||
"""Paint all True pixels of a boolean mask with an RGBA color."""
|
||
r, g, b, a = color
|
||
ys, xs = np.nonzero(mask)
|
||
for y, x in zip(ys, xs):
|
||
idx = (y * width + x) * 4
|
||
pixels[idx:idx+4] = (r, g, b, a)
|
||
|
||
def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
|
||
"""
|
||
Fill small white slivers inside the mesh by expanding nearby checker colors.
|
||
|
||
`patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
|
||
"""
|
||
for _ in range(max_gap_px):
|
||
candidate = mesh_mask & (patch_state < 0)
|
||
if not np.any(candidate):
|
||
break
|
||
|
||
black_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||
white_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||
|
||
for dy in (-1, 0, 1):
|
||
for dx in (-1, 0, 1):
|
||
if dx == 0 and dy == 0:
|
||
continue
|
||
|
||
src_y0 = max(0, -dy)
|
||
src_y1 = height - max(0, dy)
|
||
src_x0 = max(0, -dx)
|
||
src_x1 = width - max(0, dx)
|
||
dst_y0 = max(0, dy)
|
||
dst_y1 = height - max(0, -dy)
|
||
dst_x0 = max(0, dx)
|
||
dst_x1 = width - max(0, -dx)
|
||
|
||
neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
|
||
black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
|
||
white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
|
||
|
||
fillable = candidate & ((black_counts + white_counts) > 0)
|
||
if not np.any(fillable):
|
||
break
|
||
|
||
patch_state[fillable] = np.where(
|
||
black_counts[fillable] > white_counts[fillable], 0, 1
|
||
).astype(np.int8)
|
||
|
||
paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
|
||
paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Color generation (for patch mode)
|
||
# ----------------------------------------------------------------------
|
||
def merge_intervals(intervals):
|
||
if not intervals: return []
|
||
intervals.sort(key=lambda x: x[0])
|
||
merged = [intervals[0]]
|
||
for current in intervals[1:]:
|
||
last = merged[-1]
|
||
if current[0] <= last[1]:
|
||
merged[-1] = (last[0], max(last[1], current[1]))
|
||
else:
|
||
merged.append(current)
|
||
return merged
|
||
|
||
def get_allowed_intervals(neighbor_values, delta):
|
||
forbidden = []
|
||
for c in neighbor_values:
|
||
low = max(0, c - delta)
|
||
high = min(1, c + delta)
|
||
forbidden.append((low, high))
|
||
merged = merge_intervals(forbidden)
|
||
allowed = []
|
||
if merged:
|
||
if merged[0][0] > 0:
|
||
allowed.append((0, merged[0][0]))
|
||
for i in range(1, len(merged)):
|
||
allowed.append((merged[i-1][1], merged[i][0]))
|
||
if merged[-1][1] < 1:
|
||
allowed.append((merged[-1][1], 1))
|
||
else:
|
||
allowed.append((0, 1))
|
||
return allowed
|
||
|
||
def sample_from_allowed(allowed):
|
||
lengths = [end - start for start, end in allowed]
|
||
total_length = sum(lengths)
|
||
if total_length <= 0: return None
|
||
point = np.random.rand() * total_length
|
||
cumulative = 0
|
||
for (start, end), length in zip(allowed, lengths):
|
||
cumulative += length
|
||
if point <= cumulative:
|
||
return np.random.uniform(start, end)
|
||
return allowed[-1][0]
|
||
|
||
def sample_channel(neighbor_values, delta):
|
||
allowed = get_allowed_intervals(neighbor_values, delta)
|
||
if not allowed: return None
|
||
return sample_from_allowed(allowed)
|
||
|
||
def generate_valid_color(neighbors, r):
|
||
delta = np.sqrt(r)
|
||
color = np.zeros(3)
|
||
for channel in range(3):
|
||
neighbor_channel = [n[channel] for n in neighbors]
|
||
sampled = sample_channel(neighbor_channel, delta)
|
||
if sampled is None:
|
||
sampled = np.random.rand()
|
||
color[channel] = sampled
|
||
return color
|
||
|
||
def precompute_cell_colors(nx, ny, r):
|
||
"""Assign every seeded grid cell once, independent of geometry/UV visibility."""
|
||
colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||
for j in range(ny):
|
||
for i in range(nx):
|
||
neighbors = []
|
||
if j > 0:
|
||
neighbors.append(colors[j - 1, i])
|
||
if i > 0:
|
||
neighbors.append(colors[j, i - 1])
|
||
colors[j, i] = generate_valid_color(neighbors, r) if neighbors else np.random.rand(3)
|
||
return colors
|
||
|
||
|
||
def checker_patch_color(i, j):
|
||
"""Return a black/white checker color for patch index (i, j)."""
|
||
if (i + j) % 2 == 0:
|
||
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
|
||
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Native vector writers (SVG + PDF; no raster image embedding)
|
||
# ----------------------------------------------------------------------
|
||
def _format_number(value):
|
||
"""Compact deterministic decimal formatting for SVG/PDF coordinates."""
|
||
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
|
||
return "0" if text in ("", "-0") else text
|
||
|
||
|
||
def _clamp_channel(value):
|
||
return max(0.0, min(1.0, float(value)))
|
||
|
||
|
||
def _svg_fill_attributes(color):
|
||
r, g, b, a = (_clamp_channel(channel) for channel in color)
|
||
fill = f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
|
||
opacity = "" if a >= 0.999999 else f' fill-opacity="{_format_number(a)}"'
|
||
return f'fill="{fill}"{opacity} stroke="none"'
|
||
|
||
|
||
def _svg_polygon_element(element_id, polygon, color, indent=" "):
|
||
points = " ".join(
|
||
f"{_format_number(x)},{_format_number(y)}" for x, y in polygon
|
||
)
|
||
return (
|
||
f'{indent}<polygon id="{element_id}" points="{points}" '
|
||
f'{_svg_fill_attributes(color)}/>'
|
||
)
|
||
|
||
|
||
def _xml_escape(value):
|
||
return (
|
||
str(value)
|
||
.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.replace('"', """)
|
||
)
|
||
|
||
|
||
def _warn_if_page_aspect_differs(
|
||
texture_width, texture_height, width_mm, height_mm, tolerance=1e-6
|
||
):
|
||
"""Warn when the vector page would non-uniformly scale the artwork.
|
||
|
||
SVG/PDF use the full page box, so a page whose aspect ratio differs from the
|
||
raster's stretches patches along one axis. The default metadata matches
|
||
(164x160 mm for 3280x3200 px), so this only fires on deliberate mismatch.
|
||
"""
|
||
raster_aspect = float(texture_width) / float(texture_height)
|
||
page_aspect = float(width_mm) / float(height_mm)
|
||
if abs(raster_aspect - page_aspect) <= tolerance * max(raster_aspect, page_aspect):
|
||
return
|
||
print(
|
||
f"WARNING: vector page aspect {page_aspect:.6f} "
|
||
f"({width_mm:g}x{height_mm:g} mm) differs from the raster aspect "
|
||
f"{raster_aspect:.6f} ({texture_width}x{texture_height} px); geometry is "
|
||
f"scaled non-uniformly ({width_mm / texture_width:g} mm/px horizontally "
|
||
f"vs {height_mm / texture_height:g} mm/px vertically).",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
|
||
def _clip_polygon_half_plane(points, axis, boundary, keep_greater):
|
||
"""Clip a polygon against one half-plane (Sutherland-Hodgman step)."""
|
||
if not points:
|
||
return []
|
||
|
||
def inside(point):
|
||
return point[axis] >= boundary if keep_greater else point[axis] <= boundary
|
||
|
||
result = []
|
||
previous = points[-1]
|
||
previous_inside = inside(previous)
|
||
for current in points:
|
||
current_inside = inside(current)
|
||
if current_inside != previous_inside:
|
||
denominator = current[axis] - previous[axis]
|
||
if denominator != 0.0:
|
||
t = (boundary - previous[axis]) / denominator
|
||
result.append(
|
||
(
|
||
previous[0] + t * (current[0] - previous[0]),
|
||
previous[1] + t * (current[1] - previous[1]),
|
||
)
|
||
)
|
||
if current_inside:
|
||
result.append(current)
|
||
previous, previous_inside = current, current_inside
|
||
return result
|
||
|
||
|
||
def clip_polygon_to_page(polygon, width, height):
|
||
"""Clip a pixel-space polygon to the page rectangle; [] when fully outside.
|
||
|
||
Seam-crossing UV faces and patches are duplicated at ±1 UV period, so their
|
||
pixel polygons can extend past the texture. The raster clamps each scanline,
|
||
but vector output would otherwise carry stray objects outside the artwork.
|
||
"""
|
||
points = [(float(x), float(y)) for x, y in polygon]
|
||
for axis, boundary, keep_greater in (
|
||
(0, 0.0, True),
|
||
(0, float(width), False),
|
||
(1, 0.0, True),
|
||
(1, float(height), False),
|
||
):
|
||
points = _clip_polygon_half_plane(points, axis, boundary, keep_greater)
|
||
if len(points) < 3:
|
||
return []
|
||
return points
|
||
|
||
|
||
def _clipped_polygons(polygons, width, height):
|
||
clipped = []
|
||
for polygon in polygons:
|
||
page_polygon = clip_polygon_to_page(polygon, width, height)
|
||
if page_polygon:
|
||
clipped.append(page_polygon)
|
||
return clipped
|
||
|
||
|
||
def _clipped_shapes(shapes, width, height):
|
||
clipped = []
|
||
for polygon, color in shapes:
|
||
page_polygon = clip_polygon_to_page(polygon, width, height)
|
||
if page_polygon:
|
||
clipped.append((page_polygon, color))
|
||
return clipped
|
||
|
||
|
||
def write_svg_vector(
|
||
output_path, texture_width, texture_height, width_mm, height_mm,
|
||
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map
|
||
):
|
||
"""Write editable grouped SVG geometry; no raster data is embedded."""
|
||
lines = [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
(
|
||
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
||
f'width="{_format_number(width_mm)}mm" '
|
||
f'height="{_format_number(height_mm)}mm" '
|
||
f'viewBox="0 0 {texture_width} {texture_height}" '
|
||
f'preserveAspectRatio="none">'
|
||
),
|
||
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>",
|
||
(
|
||
" <desc>Native vector geometry grouped as background, mesh, "
|
||
"camera-space patches, and reference cross.</desc>"
|
||
),
|
||
(
|
||
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
|
||
),
|
||
' <g id="background">',
|
||
(
|
||
f' <rect id="background-fill" x="0" y="0" '
|
||
f'width="{texture_width}" height="{texture_height}" '
|
||
f'fill="#000000" stroke="none"/>'
|
||
),
|
||
" </g>",
|
||
' <g id="mesh">',
|
||
]
|
||
for index, polygon in enumerate(mesh_polygons, start=1):
|
||
lines.append(
|
||
_svg_polygon_element(
|
||
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
|
||
)
|
||
)
|
||
lines.extend([" </g>", ' <g id="patches">'])
|
||
for index, (polygon, color) in enumerate(patch_shapes, start=1):
|
||
lines.append(
|
||
_svg_polygon_element(f"patch-{index:04d}", polygon, color)
|
||
)
|
||
lines.extend([" </g>", ' <g id="reference-cross">'])
|
||
cross_attributes = _svg_fill_attributes(cross_color)
|
||
for index, (x, y, rect_width, rect_height) in enumerate(cross_rects, start=1):
|
||
lines.append(
|
||
f' <rect id="cross-arm-{index}" x="{_format_number(x)}" '
|
||
f'y="{_format_number(y)}" width="{_format_number(rect_width)}" '
|
||
f'height="{_format_number(rect_height)}" {cross_attributes}/>'
|
||
)
|
||
lines.extend([" </g>", " </g>", "</svg>", ""])
|
||
|
||
path = Path(output_path)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text("\n".join(lines), encoding="utf-8")
|
||
print(f"SVG vector texture saved to {path}")
|
||
|
||
|
||
def _pdf_set_fill(color):
|
||
r, g, b = (_clamp_channel(channel) for channel in color[:3])
|
||
return f"{_format_number(r)} {_format_number(g)} {_format_number(b)} rg"
|
||
|
||
|
||
def _pdf_polygon_commands(polygon, scale_x, scale_y):
|
||
if len(polygon) < 3:
|
||
return []
|
||
first_x, first_y = polygon[0]
|
||
commands = [
|
||
f"{_format_number(first_x * scale_x)} {_format_number(first_y * scale_y)} m"
|
||
]
|
||
commands.extend(
|
||
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} l"
|
||
for x, y in polygon[1:]
|
||
)
|
||
commands.extend(["h", "f"])
|
||
return commands
|
||
|
||
|
||
def _build_pdf(objects):
|
||
"""Assemble a minimal PDF 1.4 document from already-numbered objects."""
|
||
document = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||
offsets = [0]
|
||
for object_number, payload in enumerate(objects, start=1):
|
||
offsets.append(len(document))
|
||
document.extend(f"{object_number} 0 obj\n".encode("ascii"))
|
||
document.extend(payload)
|
||
if not payload.endswith(b"\n"):
|
||
document.extend(b"\n")
|
||
document.extend(b"endobj\n")
|
||
|
||
xref_offset = len(document)
|
||
document.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
||
document.extend(b"0000000000 65535 f \n")
|
||
for offset in offsets[1:]:
|
||
document.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
||
document.extend(
|
||
(
|
||
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||
f"startxref\n{xref_offset}\n%%EOF\n"
|
||
).encode("ascii")
|
||
)
|
||
return bytes(document)
|
||
|
||
|
||
def write_pdf_vector(
|
||
output_path, texture_width, texture_height, width_mm, height_mm,
|
||
mesh_polygons, patch_shapes, cross_rects, cross_color
|
||
):
|
||
"""Write native vector PDF paths at the configured physical page size."""
|
||
page_width_pt = float(width_mm) * 72.0 / 25.4
|
||
page_height_pt = float(height_mm) * 72.0 / 25.4
|
||
scale_x = page_width_pt / float(texture_width)
|
||
scale_y = page_height_pt / float(texture_height)
|
||
|
||
content = ["q", "% background", "0 0 0 rg"]
|
||
content.append(
|
||
f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f"
|
||
)
|
||
content.extend(["% mesh", "1 1 1 rg"])
|
||
for polygon in mesh_polygons:
|
||
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
|
||
|
||
content.append("% camera-space patches")
|
||
for polygon, color in patch_shapes:
|
||
content.append(_pdf_set_fill(color))
|
||
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
|
||
|
||
cross_alpha = _clamp_channel(cross_color[3])
|
||
has_cross_alpha = bool(cross_rects) and cross_alpha < 0.999999
|
||
content.extend(["% reference cross", "q"])
|
||
if has_cross_alpha:
|
||
content.append("/CrossAlpha gs")
|
||
content.append(_pdf_set_fill(cross_color))
|
||
for x, y, rect_width, rect_height in cross_rects:
|
||
content.append(
|
||
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} "
|
||
f"{_format_number(rect_width * scale_x)} "
|
||
f"{_format_number(rect_height * scale_y)} re f"
|
||
)
|
||
content.extend(["Q", "Q", ""])
|
||
content_bytes = "\n".join(content).encode("ascii")
|
||
|
||
if has_cross_alpha:
|
||
resources = b"<< /ExtGState << /CrossAlpha 5 0 R >> >>"
|
||
else:
|
||
resources = b"<< >>"
|
||
objects = [
|
||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||
(
|
||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "
|
||
+ _format_number(page_width_pt).encode("ascii")
|
||
+ b" "
|
||
+ _format_number(page_height_pt).encode("ascii")
|
||
+ b"] /Resources "
|
||
+ resources
|
||
+ b" /Contents 4 0 R >>"
|
||
),
|
||
(
|
||
f"<< /Length {len(content_bytes)} >>\nstream\n".encode("ascii")
|
||
+ content_bytes
|
||
+ b"endstream"
|
||
),
|
||
]
|
||
if has_cross_alpha:
|
||
objects.append(
|
||
(
|
||
f"<< /Type /ExtGState /ca {_format_number(cross_alpha)} "
|
||
f"/CA {_format_number(cross_alpha)} >>"
|
||
).encode("ascii")
|
||
)
|
||
|
||
path = Path(output_path)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_bytes(_build_pdf(objects))
|
||
print(f"PDF vector texture saved to {path}")
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Mode 1: Colored Patches
|
||
# ----------------------------------------------------------------------
|
||
def generate_patch_texture(
|
||
obj, cam, scene, depsgraph,
|
||
nx=40, ny=30, r=0.05,
|
||
texture_width=2048, texture_height=2048,
|
||
output_path=None, svg_path=None, pdf_path=None,
|
||
vector_width_mm=164.0, vector_height_mm=160.0,
|
||
uv_centre=None, cross_length=40,
|
||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||
uv_layer=None, image_name="PatchTexture", patch_colors=None, geometry=None
|
||
):
|
||
if not output_path:
|
||
raise ValueError("output_path is required (absolute PNG path)")
|
||
if svg_path or pdf_path:
|
||
_warn_if_page_aspect_differs(
|
||
texture_width, texture_height, vector_width_mm, vector_height_mm
|
||
)
|
||
if uv_layer is None:
|
||
uv_layer = obj.data.uv_layers.active
|
||
if uv_layer is None:
|
||
raise ValueError("A UV layer is required")
|
||
if patch_colors is None:
|
||
patch_colors = precompute_cell_colors(nx, ny, r)
|
||
if geometry is None:
|
||
geometry = build_patch_geometry(
|
||
obj, cam, scene, depsgraph, [uv_layer.name], nx, ny, edge_sample_grid
|
||
)[uv_layer.name]
|
||
|
||
def pixel_polygon(uv_polygon):
|
||
# Authored per-loop UVs are authoritative, including wide/mirrored islands.
|
||
# Clip to this output tile; do not infer a periodic seam or draw copies.
|
||
return [(u * (texture_width - 1), v * (texture_height - 1))
|
||
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)]
|
||
|
||
# Create image – start with black background
|
||
img_name = image_name
|
||
if img_name in bpy.data.images:
|
||
bpy.data.images.remove(bpy.data.images[img_name])
|
||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||
pixels = list(image.pixels)
|
||
|
||
# Fill entire image with black (no mesh areas)
|
||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||
|
||
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
|
||
# the exact same polygons for native SVG/PDF output.
|
||
mesh_polygons = [pixel_polygon(poly) for poly in geometry['mesh']]
|
||
mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3]
|
||
for pixel_poly in mesh_polygons:
|
||
fill_polygon(
|
||
None, pixels, texture_width, texture_height,
|
||
pixel_poly, (1.0, 1.0, 1.0, 1.0)
|
||
)
|
||
|
||
# Draw the visible patches and retain each editable vector shape.
|
||
patch_shapes = []
|
||
for i, j, uv_poly in geometry['patches']:
|
||
color = patch_colors[j, i]
|
||
rgba = (color[0], color[1], color[2], 1.0)
|
||
pixel_poly = pixel_polygon(uv_poly)
|
||
if len(pixel_poly) < 3:
|
||
continue
|
||
patch_shapes.append((pixel_poly, rgba))
|
||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||
|
||
cross_shapes = []
|
||
if uv_centre is not None:
|
||
cross_shapes = cross_rectangles(
|
||
texture_width, texture_height,
|
||
uv_centre[0], uv_centre[1],
|
||
cross_length, cross_thickness,
|
||
)
|
||
draw_cross(pixels, texture_width, texture_height,
|
||
uv_centre[0], uv_centre[1],
|
||
cross_length, cross_thickness, cross_color)
|
||
|
||
image.pixels[:] = pixels
|
||
image.update()
|
||
out_path = Path(output_path)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
image.filepath_raw = str(out_path)
|
||
image.file_format = 'PNG'
|
||
image.save()
|
||
print(f"PNG raster texture saved to {out_path}")
|
||
|
||
uv_map_name = uv_layer.name if uv_layer is not None else "active"
|
||
if svg_path or pdf_path:
|
||
# Clip seam-wrapped duplicates to the page so nothing sits outside the
|
||
# artwork once the geometry is opened in an editor.
|
||
page_mesh_polygons = _clipped_polygons(mesh_polygons, texture_width, texture_height)
|
||
page_patch_shapes = _clipped_shapes(patch_shapes, texture_width, texture_height)
|
||
if svg_path:
|
||
write_svg_vector(
|
||
svg_path, texture_width, texture_height,
|
||
vector_width_mm, vector_height_mm,
|
||
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
|
||
uv_map_name,
|
||
)
|
||
if pdf_path:
|
||
write_pdf_vector(
|
||
pdf_path, texture_width, texture_height,
|
||
vector_width_mm, vector_height_mm,
|
||
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
|
||
)
|
||
return image
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Mode 2: Black/White Checker Patches
|
||
# ----------------------------------------------------------------------
|
||
def generate_checker_texture(
|
||
obj, cam, scene, depsgraph,
|
||
nx=40, ny=40,
|
||
texture_width=2048, texture_height=2048,
|
||
output_path=None, uv_centre=None, cross_length=40,
|
||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||
gap_fill_px=6
|
||
):
|
||
if not output_path:
|
||
raise ValueError("output_path is required (absolute PNG path)")
|
||
cam_data = cam.data
|
||
render = scene.render
|
||
res_x = render.resolution_x
|
||
res_y = render.resolution_y
|
||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||
sensor_width = cam_data.sensor_width
|
||
sensor_height = sensor_width / (res_x / res_y)
|
||
else:
|
||
sensor_height = cam_data.sensor_height
|
||
sensor_width = sensor_height * (res_x / res_y)
|
||
focal = cam_data.lens
|
||
frame_width = sensor_width / focal
|
||
frame_height = sensor_height / focal
|
||
|
||
edge_sample_grid = max(2, int(edge_sample_grid))
|
||
patch_uv_polygons = []
|
||
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||
skipped_patches = 0
|
||
|
||
print(f"Raycasting {nx*ny} checker patches...")
|
||
for j in range(ny):
|
||
v_start = j / ny
|
||
v_end = (j+1) / ny
|
||
for i in range(nx):
|
||
u_start = i / nx
|
||
u_end = (i+1) / nx
|
||
uv_poly = sample_patch_uv_polygon(
|
||
scene, depsgraph, cam, obj,
|
||
u_start, u_end, v_start, v_end,
|
||
frame_width, frame_height,
|
||
edge_sample_grid=edge_sample_grid
|
||
)
|
||
if uv_poly is None:
|
||
patch_uv_polygons.append(None)
|
||
skipped_patches += 1
|
||
continue
|
||
patch_uv_polygons.append(uv_poly)
|
||
patch_colors[j, i] = checker_patch_color(i, j)
|
||
print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
|
||
|
||
img_name = "CheckerTexture"
|
||
if img_name in bpy.data.images:
|
||
bpy.data.images.remove(bpy.data.images[img_name])
|
||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||
pixels = list(image.pixels)
|
||
mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
|
||
patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
|
||
|
||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||
paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
|
||
|
||
idx = 0
|
||
for j in range(ny):
|
||
for i in range(nx):
|
||
uv_poly = patch_uv_polygons[idx]; idx += 1
|
||
if uv_poly is None:
|
||
continue
|
||
color = patch_colors[j,i]
|
||
rgba = (color[0], color[1], color[2], 1.0)
|
||
patch_value = 1 if np.mean(color) >= 0.5 else 0
|
||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||
fill_polygon_value(
|
||
patch_state,
|
||
texture_width,
|
||
texture_height,
|
||
pixel_poly,
|
||
patch_value,
|
||
)
|
||
|
||
complete_checker_gaps(
|
||
pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
|
||
)
|
||
|
||
if uv_centre is not None:
|
||
draw_cross(pixels, texture_width, texture_height,
|
||
uv_centre[0], uv_centre[1],
|
||
cross_length, cross_thickness, cross_color)
|
||
|
||
image.pixels[:] = pixels
|
||
image.update()
|
||
out_path = Path(output_path)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
image.filepath_raw = str(out_path)
|
||
image.file_format = 'PNG'
|
||
image.save()
|
||
print(f"Checker texture saved to {out_path}")
|
||
return image
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Mode 3: Circular Dot Markers
|
||
# ----------------------------------------------------------------------
|
||
def generate_dot_texture(
|
||
obj, cam, scene, depsgraph,
|
||
dot_diameter_pixels=12,
|
||
spacing_pixels=40,
|
||
dot_color=(0,0,0,1), # RGBA 0-1
|
||
texture_width=2048, texture_height=2048,
|
||
output_path=None,uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
|
||
):
|
||
if not output_path:
|
||
raise ValueError("output_path is required (absolute PNG path)")
|
||
render = scene.render
|
||
res_x = render.resolution_x
|
||
res_y = render.resolution_y
|
||
cam_data = cam.data
|
||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||
sensor_width = cam_data.sensor_width
|
||
sensor_height = sensor_width / (res_x / res_y)
|
||
else:
|
||
sensor_height = cam_data.sensor_height
|
||
sensor_width = sensor_height * (res_x / res_y)
|
||
focal = cam_data.lens
|
||
frame_width = sensor_width / focal
|
||
frame_height = sensor_height / focal
|
||
|
||
margin = spacing_pixels // 2
|
||
x_centers = np.arange(margin, res_x, spacing_pixels)
|
||
y_centers = np.arange(margin, res_y, spacing_pixels)
|
||
offset_pix = max(2, dot_diameter_pixels // 4)
|
||
du_ndc = offset_pix / res_x
|
||
dv_ndc = offset_pix / res_y
|
||
|
||
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
|
||
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
|
||
for y_pix in y_centers:
|
||
v_ndc = y_pix / res_y
|
||
for x_pix in x_centers:
|
||
u_ndc = x_pix / res_x
|
||
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||
u_ndc, v_ndc, frame_width, frame_height)
|
||
if center_loc is None: continue
|
||
center_uv = get_uv_from_face(obj, face_idx, center_loc)
|
||
if center_uv is None: continue
|
||
|
||
offsets = [
|
||
( u_ndc + du_ndc, v_ndc),
|
||
( u_ndc - du_ndc, v_ndc),
|
||
( u_ndc, v_ndc + dv_ndc),
|
||
( u_ndc, v_ndc - dv_ndc)
|
||
]
|
||
uv_offsets = []
|
||
for (u_off, v_off) in offsets:
|
||
uv_off = raycast_uv_from_camera(
|
||
scene, depsgraph, cam, obj,
|
||
u_off, v_off, frame_width, frame_height
|
||
)
|
||
uv_offsets.append(uv_off)
|
||
|
||
scale_u = 1.0 / res_x
|
||
scale_v = 1.0 / res_y
|
||
r_pix = dot_diameter_pixels / 2.0
|
||
|
||
right_uv, left_uv, up_uv, down_uv = uv_offsets
|
||
if right_uv is not None and left_uv is not None:
|
||
du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
|
||
a_uv = r_pix * du_dx.length * scale_u
|
||
elif right_uv is not None:
|
||
du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
|
||
a_uv = r_pix * du_dx.length * scale_u
|
||
elif left_uv is not None:
|
||
du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
|
||
a_uv = r_pix * du_dx.length * scale_u
|
||
else:
|
||
du_dx = Vector((1.0, 0.0))
|
||
a_uv = r_pix / texture_width
|
||
|
||
if up_uv is not None and down_uv is not None:
|
||
dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
|
||
b_uv = r_pix * dv_dx.length * scale_v
|
||
elif up_uv is not None:
|
||
dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
|
||
b_uv = r_pix * dv_dx.length * scale_v
|
||
elif down_uv is not None:
|
||
dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
|
||
b_uv = r_pix * dv_dx.length * scale_v
|
||
else:
|
||
b_uv = r_pix / texture_height
|
||
|
||
a_uv = max(a_uv, 1.0 / texture_width)
|
||
b_uv = max(b_uv, 1.0 / texture_height)
|
||
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
|
||
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
|
||
|
||
print(f"Found {len(dots)} valid dots.")
|
||
|
||
# Create image
|
||
img_name = "DotTexture"
|
||
if img_name in bpy.data.images:
|
||
bpy.data.images.remove(bpy.data.images[img_name])
|
||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||
pixels = list(image.pixels)
|
||
|
||
# Black background (no mesh)
|
||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||
# White for all mesh areas
|
||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||
# Draw dots
|
||
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
|
||
cx_px = cx_uv * (texture_width - 1)
|
||
cy_px = cy_uv * (texture_height - 1)
|
||
a_px = a_uv * texture_width
|
||
b_px = b_uv * texture_height
|
||
draw_ellipse(pixels, texture_width, texture_height,
|
||
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
|
||
|
||
if uv_centre is not None:
|
||
draw_cross(pixels, texture_width, texture_height,
|
||
uv_centre[0], uv_centre[1],
|
||
cross_length, cross_thickness, cross_color)
|
||
|
||
image.pixels[:] = pixels
|
||
image.update()
|
||
out_path = Path(output_path)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
image.filepath_raw = str(out_path)
|
||
image.file_format = 'PNG'
|
||
image.save()
|
||
print(f"Dot texture saved to {out_path}")
|
||
return image
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Headless configuration (job file from `orisys-design texture`)
|
||
# ----------------------------------------------------------------------
|
||
_DEFAULT_MESH = "sensing_surface"
|
||
_DEFAULT_CAMERA = "cam_sensor"
|
||
|
||
|
||
class ConfigError(ValueError):
|
||
"""Invalid job/flags (exit code 2)."""
|
||
|
||
|
||
class SceneError(RuntimeError):
|
||
"""Missing or unusable scene data (exit code 1)."""
|
||
|
||
|
||
def _argv_after_double_dash():
|
||
if "--" not in sys.argv:
|
||
return []
|
||
return sys.argv[sys.argv.index("--") + 1:]
|
||
|
||
|
||
def parse_args(argv):
|
||
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
|
||
parser.add_argument(
|
||
"--job", type=Path, default=None,
|
||
help="JSON texture job written by orisys-design (artifacts + objects + settings)",
|
||
)
|
||
parser.add_argument(
|
||
"--output", type=Path, default=None,
|
||
help="Absolute output PNG for a manual single-target run (needs --uv-map)",
|
||
)
|
||
parser.add_argument(
|
||
"--uv-map", dest="uv_map", default=None,
|
||
help="Mesh UV map name for a manual single-target run (with --output)",
|
||
)
|
||
parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)")
|
||
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
|
||
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
|
||
parser.add_argument("--height", type=int, default=None, help="Texture height (overrides job)")
|
||
parser.add_argument(
|
||
"--vector-width-mm", dest="vector_width_mm", type=float, default=None,
|
||
help="SVG/PDF physical width in millimetres (overrides job)",
|
||
)
|
||
parser.add_argument(
|
||
"--vector-height-mm", dest="vector_height_mm", type=float, default=None,
|
||
help="SVG/PDF physical height in millimetres (overrides job)",
|
||
)
|
||
parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)")
|
||
parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)")
|
||
parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation")
|
||
parser.add_argument(
|
||
"--edge-sample-grid", dest="edge_sample_grid", type=int, default=None,
|
||
help="Edge sampling grid for partially covered patches",
|
||
)
|
||
parser.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours")
|
||
parser.add_argument(
|
||
"--no-cross", dest="no_cross", action="store_true", default=None,
|
||
help="Do not draw the reference cross",
|
||
)
|
||
parser.add_argument("--cross-length", dest="cross_length", type=int, default=None)
|
||
parser.add_argument("--cross-thickness", dest="cross_thickness", type=int, default=None)
|
||
parser.add_argument(
|
||
"--cross-color", dest="cross_color", type=float, nargs=4,
|
||
metavar=("R", "G", "B", "A"), default=None,
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def _load_job(path):
|
||
if not path.is_file():
|
||
raise ConfigError(f"Texture job not found: {path}")
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
if not isinstance(data, dict):
|
||
raise ConfigError(f"Texture job must be a JSON object: {path}")
|
||
return data
|
||
|
||
|
||
def _as_int(value, label, minimum=1):
|
||
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
|
||
raise ConfigError(f"{label}: must be an integer >= {minimum} (got {value!r})")
|
||
return int(value)
|
||
|
||
|
||
def _as_positive_number(value, label):
|
||
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
||
raise ConfigError(f"{label}: must be a positive number (got {value!r})")
|
||
return float(value)
|
||
|
||
|
||
def _as_color(value, label):
|
||
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
||
raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})")
|
||
for channel in value:
|
||
if not isinstance(channel, (int, float)) or isinstance(channel, bool):
|
||
raise ConfigError(f"{label}: every channel must be a number (got {value!r})")
|
||
if not 0.0 <= float(channel) <= 1.0:
|
||
raise ConfigError(f"{label}: every channel must be in [0, 1] (got {value!r})")
|
||
rgba = [float(channel) for channel in value]
|
||
if len(rgba) == 3:
|
||
rgba.append(1.0)
|
||
return tuple(rgba)
|
||
|
||
|
||
_ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||
|
||
|
||
def _as_output_path(raw, format_name, label):
|
||
if raw is None or raw == "":
|
||
raise ConfigError(f"{label}: a {format_name.upper()} path is required")
|
||
if not isinstance(raw, (str, Path)):
|
||
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
||
path = Path(raw).expanduser()
|
||
if not path.is_absolute():
|
||
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
||
expected_suffix = f".{format_name}"
|
||
if path.suffix.lower() != expected_suffix:
|
||
raise ConfigError(
|
||
f"{label} must be a {expected_suffix} file (got {raw!r})"
|
||
)
|
||
return path
|
||
|
||
|
||
def _resolve_targets(args, job):
|
||
"""Return ordered ``[(uv_map, {png, svg, pdf})]`` output targets."""
|
||
if args.job is not None:
|
||
if args.output is not None or args.uv_map is not None:
|
||
raise ConfigError("--output/--uv-map cannot be combined with --job")
|
||
entries = job.get("outputs")
|
||
if not isinstance(entries, list) or not entries:
|
||
raise ConfigError(
|
||
"job 'outputs' must be a non-empty list of {uv_map, artifacts}"
|
||
)
|
||
targets = []
|
||
for index, entry in enumerate(entries):
|
||
if not isinstance(entry, dict):
|
||
raise ConfigError(f"job outputs[{index}] must be a mapping")
|
||
uv_map = entry.get("uv_map")
|
||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||
raise ConfigError(
|
||
f"job outputs[{index}]: 'uv_map' must be a non-empty string "
|
||
f"(got {uv_map!r})"
|
||
)
|
||
raw_artifacts = entry.get("artifacts")
|
||
if not isinstance(raw_artifacts, dict):
|
||
raise ConfigError(f"job outputs[{index}].artifacts must be a mapping")
|
||
unknown = sorted(set(raw_artifacts) - set(_ARTIFACT_FORMATS))
|
||
missing = [name for name in _ARTIFACT_FORMATS if name not in raw_artifacts]
|
||
if unknown:
|
||
raise ConfigError(
|
||
f"job outputs[{index}].artifacts has unknown format(s): "
|
||
f"{', '.join(unknown)}"
|
||
)
|
||
if missing:
|
||
raise ConfigError(
|
||
f"job outputs[{index}].artifacts is missing: {', '.join(missing)}"
|
||
)
|
||
artifacts = {
|
||
format_name: _as_output_path(
|
||
raw_artifacts[format_name], format_name,
|
||
f"job outputs[{index}].artifacts.{format_name}",
|
||
)
|
||
for format_name in _ARTIFACT_FORMATS
|
||
}
|
||
targets.append((uv_map, artifacts))
|
||
else:
|
||
if args.uv_map is None or args.output is None:
|
||
raise ConfigError(
|
||
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
|
||
)
|
||
png_path = _as_output_path(args.output, "png", "output")
|
||
targets = [
|
||
(
|
||
args.uv_map,
|
||
{
|
||
format_name: png_path.with_suffix(f".{format_name}")
|
||
for format_name in _ARTIFACT_FORMATS
|
||
},
|
||
)
|
||
]
|
||
|
||
seen_names = set()
|
||
seen_paths = {}
|
||
for uv_map, artifacts in targets:
|
||
if uv_map in seen_names:
|
||
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
||
seen_names.add(uv_map)
|
||
for format_name, path in artifacts.items():
|
||
key = str(path).lower()
|
||
if key in seen_paths:
|
||
previous_uv, previous_format = seen_paths[key]
|
||
raise ConfigError(
|
||
f"duplicate output path for {previous_uv!r}/{previous_format} and "
|
||
f"{uv_map!r}/{format_name}: {path}"
|
||
)
|
||
seen_paths[key] = (uv_map, format_name)
|
||
return targets
|
||
|
||
|
||
def _resolve_config(args):
|
||
"""Merge --job JSON with explicit flags. Explicit flags win over job values."""
|
||
job = {}
|
||
if args.job is not None:
|
||
job = _load_job(args.job.resolve())
|
||
|
||
targets = _resolve_targets(args, job)
|
||
|
||
objects = job.get("objects") or {}
|
||
if not isinstance(objects, dict):
|
||
raise ConfigError("job 'objects' must be a mapping")
|
||
|
||
settings = job.get("settings") or {}
|
||
if not isinstance(settings, dict):
|
||
raise ConfigError("job 'settings' must be a mapping")
|
||
job_cross = settings.get("cross") or {}
|
||
if not isinstance(job_cross, dict):
|
||
raise ConfigError("job 'settings.cross' must be a mapping")
|
||
job_vector = settings.get("vector") or {}
|
||
if not isinstance(job_vector, dict):
|
||
raise ConfigError("job 'settings.vector' must be a mapping")
|
||
|
||
def pick(flag, key, default):
|
||
if flag is not None:
|
||
return flag
|
||
value = settings.get(key, default)
|
||
return default if value is None else value
|
||
|
||
def pick_cross(flag, key, default):
|
||
if flag is not None:
|
||
return flag
|
||
value = job_cross.get(key, default)
|
||
return default if value is None else value
|
||
|
||
def pick_vector(flag, key, default):
|
||
if flag is not None:
|
||
return flag
|
||
value = job_vector.get(key, default)
|
||
return default if value is None else value
|
||
|
||
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
|
||
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
|
||
|
||
width = _as_int(pick(args.width, "width", 2048), "width")
|
||
height = _as_int(pick(args.height, "height", 2048), "height")
|
||
vector_width_mm = _as_positive_number(
|
||
pick_vector(args.vector_width_mm, "width_mm", 164.0),
|
||
"vector.width_mm",
|
||
)
|
||
vector_height_mm = _as_positive_number(
|
||
pick_vector(args.vector_height_mm, "height_mm", 160.0),
|
||
"vector.height_mm",
|
||
)
|
||
nx = _as_int(pick(args.nx, "nx", 80), "nx")
|
||
ny = _as_int(pick(args.ny, "ny", 45), "ny")
|
||
edge_sample_grid = _as_int(
|
||
pick(args.edge_sample_grid, "edge_sample_grid", 5), "edge_sample_grid", minimum=2
|
||
)
|
||
|
||
color_r = pick(args.color_r, "color_r", 0.05)
|
||
if not isinstance(color_r, (int, float)) or isinstance(color_r, bool):
|
||
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
|
||
if not 0.0 <= float(color_r) <= 1.0:
|
||
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
|
||
|
||
seed = pick(args.seed, "seed", None)
|
||
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
||
raise ConfigError(f"seed: must be an integer or null (got {seed!r})")
|
||
|
||
cross_enabled = bool(pick_cross(args.no_cross, "enabled", True))
|
||
if args.no_cross is not None:
|
||
cross_enabled = not args.no_cross
|
||
|
||
cross_length = _as_int(pick_cross(args.cross_length, "length_px", 40), "cross.length_px")
|
||
cross_thickness = _as_int(pick_cross(args.cross_thickness, "thickness_px", 3), "cross.thickness_px")
|
||
cross_color = _as_color(
|
||
args.cross_color if args.cross_color is not None else job_cross.get("color", [1.0, 0.0, 0.0, 1.0]),
|
||
"cross.color",
|
||
)
|
||
|
||
return {
|
||
"outputs": targets,
|
||
"mesh": str(mesh),
|
||
"camera": str(camera),
|
||
"width": width,
|
||
"height": height,
|
||
"vector_width_mm": vector_width_mm,
|
||
"vector_height_mm": vector_height_mm,
|
||
"nx": nx,
|
||
"ny": ny,
|
||
"color_r": float(color_r),
|
||
"edge_sample_grid": edge_sample_grid,
|
||
"seed": None if seed is None else int(seed),
|
||
"cross_enabled": cross_enabled,
|
||
"cross_length": cross_length,
|
||
"cross_thickness": cross_thickness,
|
||
"cross_color": cross_color,
|
||
}
|
||
|
||
|
||
def _require_object(name, expected_type, label):
|
||
obj = bpy.data.objects.get(name)
|
||
if obj is None:
|
||
raise SceneError(f"{label} object not found: {name!r}")
|
||
if obj.type != expected_type:
|
||
raise SceneError(f"{label} {name!r} has type {obj.type!r}, expected {expected_type!r}")
|
||
return obj
|
||
|
||
|
||
def _prepare_mesh_object(name):
|
||
"""Return the named mesh object in Object mode so its UV data is readable.
|
||
|
||
source_blend may be saved with the object in Edit mode, where UV layer data
|
||
reads as empty. The switch is in memory only — this script never saves the
|
||
.blend file.
|
||
"""
|
||
obj = _require_object(name, "MESH", "Mesh")
|
||
if obj.mode != "OBJECT":
|
||
print(f"Switching {obj.name!r} from {obj.mode} to OBJECT (in memory only)")
|
||
try:
|
||
for other in bpy.context.view_layer.objects:
|
||
other.select_set(False)
|
||
bpy.context.view_layer.objects.active = obj
|
||
obj.select_set(True)
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
except RuntimeError as exc:
|
||
raise SceneError(f"Could not leave Edit mode for {obj.name!r}: {exc}") from exc
|
||
return obj
|
||
|
||
|
||
def _require_uv_layer(obj, name):
|
||
"""Return the named UV layer, or fail listing the available maps."""
|
||
mesh = obj.data
|
||
for layer in mesh.uv_layers:
|
||
if layer.name == name:
|
||
if len(layer.data) == 0:
|
||
raise SceneError(
|
||
f"UV map {name!r} on mesh {obj.name!r} has no loop data. UV unwrapping "
|
||
"is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||
".blend, then re-run orisys-design texture."
|
||
)
|
||
return layer
|
||
available = ", ".join(sorted(layer.name for layer in mesh.uv_layers)) or "none"
|
||
raise SceneError(
|
||
f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available}). UV "
|
||
"unwrapping is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||
".blend, then re-run orisys-design texture."
|
||
)
|
||
|
||
|
||
def _uv_centre(uv_layer):
|
||
"""UV bounding-box centre for one UV map."""
|
||
uv = np.array(
|
||
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
|
||
dtype=np.float64,
|
||
)
|
||
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
|
||
print(f"UV centre [{uv_layer.name}]: ({centre[0]:.4f}, {centre[1]:.4f})")
|
||
return centre
|
||
|
||
|
||
def main():
|
||
args = parse_args(_argv_after_double_dash())
|
||
try:
|
||
config = _resolve_config(args)
|
||
except (ConfigError, json.JSONDecodeError, OSError) as exc:
|
||
print(f"ERROR: {exc}", file=sys.stderr)
|
||
return 2
|
||
|
||
# Validate every target before rendering anything so a partial pair is never
|
||
# produced: the mesh mode, both UV maps and both output paths must be usable.
|
||
try:
|
||
obj = _prepare_mesh_object(config["mesh"])
|
||
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
||
targets = [
|
||
(uv_map, artifacts, _require_uv_layer(obj, uv_map))
|
||
for uv_map, artifacts in config["outputs"]
|
||
]
|
||
except SceneError as exc:
|
||
print(f"ERROR: {exc}", file=sys.stderr)
|
||
return 1
|
||
|
||
for uv_map, artifacts, _layer in targets:
|
||
for format_name, output in artifacts.items():
|
||
try:
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
except OSError as exc:
|
||
print(
|
||
f"ERROR: cannot create {format_name.upper()} output directory for "
|
||
f"UV map {uv_map!r} ({output.parent}): {exc}",
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
|
||
scene = bpy.context.scene
|
||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||
|
||
if config["seed"] is not None:
|
||
np.random.seed(config["seed"])
|
||
print(f"Using colour seed {config['seed']}")
|
||
# Both targets share the very same cell table and visibility-clipped fragments.
|
||
patch_colors = precompute_cell_colors(config['nx'], config['ny'], config['color_r'])
|
||
start_time = time.time()
|
||
try:
|
||
geometry = build_patch_geometry(
|
||
obj, cam, scene, depsgraph, [name for name, _, _ in targets],
|
||
config['nx'], config['ny'], config['edge_sample_grid'])
|
||
except Exception as exc:
|
||
print(f'ERROR: patch geometry failed: {exc}', file=sys.stderr)
|
||
return 1
|
||
for uv_map, artifacts, uv_layer in targets:
|
||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||
print(f"--- UV map {uv_map!r}")
|
||
try:
|
||
generate_patch_texture(
|
||
obj, cam, scene, depsgraph,
|
||
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
||
texture_width=config["width"], texture_height=config["height"],
|
||
output_path=str(artifacts["png"]),
|
||
svg_path=str(artifacts["svg"]),
|
||
pdf_path=str(artifacts["pdf"]),
|
||
vector_width_mm=config["vector_width_mm"],
|
||
vector_height_mm=config["vector_height_mm"],
|
||
uv_centre=uv_centre,
|
||
cross_length=config["cross_length"],
|
||
cross_thickness=config["cross_thickness"],
|
||
cross_color=config["cross_color"],
|
||
edge_sample_grid=config["edge_sample_grid"],
|
||
uv_layer=uv_layer,
|
||
image_name=f"PatchTexture_{uv_map}",
|
||
patch_colors=patch_colors, geometry=geometry[uv_map],
|
||
)
|
||
except Exception as exc:
|
||
print(
|
||
f"ERROR: texture generation failed for UV map {uv_map!r}: {exc}",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
for format_name, output in artifacts.items():
|
||
if not output.is_file() or output.stat().st_size == 0:
|
||
print(
|
||
f"ERROR: {format_name.upper()} texture was not written for "
|
||
f"UV map {uv_map!r}: {output}",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
artifact_count = sum(len(artifacts) for _, artifacts, _ in targets)
|
||
print(f"Texture complete: {artifact_count} file(s)")
|
||
for uv_map, artifacts, _layer in targets:
|
||
print(f" [{uv_map}]")
|
||
for format_name, output in artifacts.items():
|
||
print(f" {format_name.upper()}: {output} ({output.stat().st_size} bytes)")
|
||
print(
|
||
f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']} "
|
||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g}mm"
|
||
)
|
||
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|