""" Texture Generator for Arbitrary 3D Surfaces (Blender Script) - Mode: "patches" (colored patches), "checker" (black/white patches), or "dots" (circular markers) - Mesh UVs are filled white so that non‑visible parts become white. - Texture background (no‑mesh areas) stays black. """ import bpy import numpy as np import os import time from mathutils import Vector # ---------------------------------------------------------------------- # Common Utilities # ---------------------------------------------------------------------- def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color): """ Draw a cross (plus sign) in pixel space. cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left. length_px: total length of each arm (pixels). thickness_px: line thickness (odd number gives symmetric result). """ # Convert UV centre to pixel coordinates (y from bottom) 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 arm x0 = max(0, cx - half_len) x1 = min(width - 1, cx + half_len) y0 = max(0, cy - half_thick) y1 = min(height - 1, cy + half_thick) for y in range(y0, y1 + 1): for x in range(x0, x1 + 1): idx = (y * width + x) * 4 pixels[idx:idx+4] = color # Vertical arm x0 = max(0, cx - half_thick) x1 = min(width - 1, cx + half_thick) y0 = max(0, cy - half_len) y1 = min(height - 1, cy + half_len) for y in range(y0, y1 + 1): for x in range(x0, x1 + 1): 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): """World‑space location → UV coordinate on the mesh face.""" me = obj.data 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_layer = me.uv_layers.active.data uv0 = uv_layer[loop_indices[0]].uv uv1 = uv_layer[loop_indices[1]].uv uv2 = uv_layer[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): """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) 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 ): """ 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. """ 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 ) 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 ) if uv is not None: sampled_uvs.append(uv) return convex_hull_uv(sampled_uvs) def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)): """ Fill all triangles of the active UV map with 'color' (default white). This marks every part of the mesh, visible or not, as white. """ me = obj.data uv_layer = me.uv_layers.active if not uv_layer: print("Warning: No active UV layer – mesh rasterization skipped.") return uv_data = uv_layer.data for poly in me.polygons: # Get loop indices for this polygon loops = poly.loop_indices # Build UV polygon uv_poly = [] for loop_idx in loops: uv = uv_data[loop_idx].uv uv_poly.append((uv.x, uv.y)) # Convert UV (0‑1) to pixel coordinates for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height): 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 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) # ---------------------------------------------------------------------- # 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="//patch_texture.png", uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5 ): 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} 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) neighbors = [] if j > 0: neighbors.append(patch_colors[j-1, i]) if i > 0: neighbors.append(patch_colors[j, i-1]) color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r) patch_colors[j, i] = color print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.") # Create image – start with black background img_name = "PatchTexture" 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) rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1)) # Now draw the visible patches on top 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) 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) # (after drawing all patches) 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() abs_path = bpy.path.abspath(output_path) image.filepath_raw = abs_path image.file_format = 'PNG' image.save() print(f"Patch texture saved to {abs_path}") 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="//checker_texture.png", uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5, gap_fill_px=6 ): 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() abs_path = bpy.path.abspath(output_path) image.filepath_raw = abs_path image.file_format = 'PNG' image.save() print(f"Checker texture saved to {abs_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="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1) ): 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() abs_path = bpy.path.abspath(output_path) image.filepath_raw = abs_path image.file_format = 'PNG' image.save() print(f"Dot texture saved to {abs_path}") return image # ---------------------------------------------------------------------- # Main # ---------------------------------------------------------------------- def main(): # ==================== CONFIGURATION ==================== MODE = "patches" # "patches", "checker", or "dots" OBJ_NAME = "FingerMesh0525.001" CAM_NAME = "Camera0607" TEXTURE_WIDTH = 1640*2 TEXTURE_HEIGHT = 1600*2 OUTPUT_NAMES = { "patches": "patch_texture.png", "checker": "checker_texture.png", "dots": "dot_texture.png", } OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png") # Patch mode PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05 EDGE_SAMPLE_GRID = 5 # Checker mode CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6 # Dot mode DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1) # Cross CROSS_LENGTH_PX = 40 CROSS_THICKNESS_PX = 3 CROSS_COLOR = (1, 0, 0, 1) # red cross # ====================================================== start_time = time.time() scene = bpy.context.scene depsgraph = bpy.context.evaluated_depsgraph_get() if OBJ_NAME: obj = bpy.data.objects.get(OBJ_NAME) else: obj = bpy.context.active_object if obj is None or obj.type != 'MESH': raise ValueError("No valid mesh object found.") cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera if cam is None or cam.type != 'CAMERA': raise ValueError("No camera found.") if not obj.data.uv_layers.active: obj.data.uv_layers.new(name="UVMap") # Compute UV centre uv_layer = obj.data.uv_layers.active uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))]) uv_min = uv_coords.min(axis=0) uv_max = uv_coords.max(axis=0) uv_centre = (uv_min + uv_max) / 2.0 print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})") # Compute UV bounding box centre me = obj.data uv_layer = me.uv_layers.active uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))]) uv_min = uv_coords.min(axis=0) uv_max = uv_coords.max(axis=0) uv_centre = (uv_min + uv_max) / 2.0 print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}") if MODE == "patches": generate_patch_texture( obj, cam, scene, depsgraph, nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R, texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT, output_path=OUTPUT_PATH, uv_centre=uv_centre, cross_length=CROSS_LENGTH_PX, cross_thickness=CROSS_THICKNESS_PX, cross_color=CROSS_COLOR, edge_sample_grid=EDGE_SAMPLE_GRID ) elif MODE == "checker": generate_checker_texture( obj, cam, scene, depsgraph, nx=CHECKER_NX, ny=CHECKER_NY, texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT, output_path=OUTPUT_PATH, uv_centre=uv_centre, cross_length=CROSS_LENGTH_PX, cross_thickness=CROSS_THICKNESS_PX, cross_color=CROSS_COLOR, edge_sample_grid=EDGE_SAMPLE_GRID, gap_fill_px=CHECKER_GAP_FILL_PX ) elif MODE == "dots": generate_dot_texture( obj, cam, scene, depsgraph, dot_diameter_pixels=DOT_DIAMETER_PIX, spacing_pixels=DOT_SPACING_PIX, dot_color=DOT_COLOR, texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT, output_path=OUTPUT_PATH, uv_centre=uv_centre, cross_length=CROSS_LENGTH_PX, cross_thickness=CROSS_THICKNESS_PX, cross_color=CROSS_COLOR ) else: raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.") print(f"Completed in {time.time()-start_time:.2f} seconds.") if __name__ == "__main__": main()