"""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 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_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_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}' ) 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 save_png_image(image, output_path): """Serialize an already-rasterized Blender image as PNG.""" path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) image.filepath_raw = str(path) image.file_format = 'PNG' image.save() 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, artwork_label="patch", ): """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] ) if artwork_label not in ("patch", "stroke"): raise ValueError(f"unsupported artwork label: {artwork_label!r}") plural = "patches" if artwork_label == "patch" else "strokes" lines = [ '', ( f'' ), f" Orisys {artwork_label} texture — {_xml_escape(uv_map)} UV map", ( " Native vector geometry grouped as background, mesh, " f"camera-space {plural}, and reference cross." ), ( f' ' ), ' ', ( f' ' ), " ", ' ', ] 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([" ", f' ']) for index, (polygon, color) in enumerate(patch_shapes, start=1): lines.append( _svg_polygon_element(f"{artwork_label}-{index:04d}", polygon, color) ) lines.extend([" ", ' ']) cross_attributes = _svg_fill_attributes(cross_color) for index, (x, y, rect_width, rect_height) in enumerate(cross_rects, start=1): lines.append( f' ' ) lines.extend([" ", " ", "", ""]) 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, artwork_label="patch", ): """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)) if artwork_label not in ("patch", "stroke"): raise ValueError(f"unsupported artwork label: {artwork_label!r}") content.append(f"% camera-space {'patches' if artwork_label == 'patch' else 'strokes'}") 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) save_png_image(image, out_path) 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 , or both --uv-map and --output " ) 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())