Files
orisys-sensor-design/blender/scripts/random_patch_dd02c.py
T

1687 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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,
background=None,
):
"""Write editable grouped SVG geometry; no raster data is embedded.
``background`` is the page fill. Left as ``None`` it is the black production ground; the
laser-engraving variant passes white, because a laser cuts what is dark and an
unengraved sheet must be light.
"""
if background is None:
background_fill = "#000000"
else:
background_fill = "#" + "".join(
f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3]
)
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="{background_fill}" 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,
background=None,
):
"""Write native vector PDF paths at the configured physical page size.
``background`` as for `write_svg_vector`. The default emits the original literal command
so existing documents stay byte-identical.
"""
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)
if background is None:
background_command = "0 0 0 rg"
else:
background_command = _pdf_set_fill(
(background[0], background[1], background[2], 1.0)
)
content = ["q", "% background", background_command]
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())