diff --git a/blender/scripts/camera_grid_stroke.py b/blender/scripts/camera_grid_stroke.py new file mode 100644 index 0000000..115d25d --- /dev/null +++ b/blender/scripts/camera_grid_stroke.py @@ -0,0 +1,113 @@ +"""Convert camera-projected colour-patch cells to black capsule strokes. + +Run with Blender's source geometry and the existing texture job. This first +camera-space prototype preserves the colour pattern's grid and visibility +projection. It does not enforce a physical minimum stroke width. +""" + +import argparse +import json +from pathlib import Path +import sys + +import bpy + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv +from patch_geometry import clip_rect +from patch_projection import build_patch_geometry +from random_patch_dd02c import ( + _clipped_polygons, _clipped_shapes, _prepare_mesh_object, _require_object, + _require_uv_layer, fill_background, fill_polygon, save_png_image, + write_pdf_vector, write_svg_vector, +) + + +def _args(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + parser = argparse.ArgumentParser() + parser.add_argument("--job", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--width-ratio", type=float, default=0.42) + parser.add_argument("--length-ratio", type=float, default=0.84) + return parser.parse_args(argv) + + +def _pixel_polygon(uv_polygon, width, height): + return [(u * (width - 1), v * (height - 1)) + for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)] + + +def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio): + width, height = settings["width"], settings["height"] + nx, ny = settings["nx"], settings["ny"] + seed = settings.get("seed") or 0 + image_name = f"CameraGridStroke_{uv_name}" + if image_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[image_name]) + image = bpy.data.images.new(image_name, width=width, height=height, alpha=True) + pixels = list(image.pixels) + fill_background(pixels, width, height, (0, 0, 0, 1)) + + mesh_polygons = [_pixel_polygon(poly, width, height) for poly in geometry["mesh"]] + mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3] + for poly in mesh_polygons: + fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1)) + + shapes = [] + outline_cache = {} + for i, j, fragment, loop_uvs in geometry["camera_fragments"]: + key = (i, j) + if key not in outline_cache: + outline_cache[key] = capsule_in_cell( + i, j, nx, ny, seed, width_ratio, length_ratio) + uv_poly = projected_stroke_uv(fragment, loop_uvs, outline_cache[key]) + pixel_poly = _pixel_polygon(uv_poly, width, height) + if len(pixel_poly) < 3: + continue + shapes.append((pixel_poly, (0, 0, 0, 1))) + fill_polygon(None, pixels, width, height, pixel_poly, (0, 0, 0, 1)) + + image.pixels[:] = pixels + image.update() + base = output_dir / f"camera_grid_stroke_{uv_name}" + save_png_image(image, base.with_suffix(".png")) + page_mesh = _clipped_polygons(mesh_polygons, width, height) + page_shapes = _clipped_shapes(shapes, width, height) + vector = settings["vector"] + vector_args = (width, height, vector["width_mm"], vector["height_mm"]) + write_svg_vector(base.with_suffix(".svg"), *vector_args, + page_mesh, page_shapes, [], (0, 0, 0, 1), uv_name, + artwork_label="stroke") + write_pdf_vector(base.with_suffix(".pdf"), *vector_args, + page_mesh, page_shapes, [], (0, 0, 0, 1), + artwork_label="stroke") + print(f"{uv_name}: {len(outline_cache)} cells, {len(shapes)} projected fragments") + + +def main(): + args = _args() + if not 0 < args.width_ratio <= args.length_ratio < 1: + raise ValueError("require 0 < width-ratio <= length-ratio < 1") + job = json.loads(args.job.read_text(encoding="utf-8")) + settings = job["settings"] + obj = _prepare_mesh_object(job["objects"]["mesh"]) + cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera") + names = [entry["uv_map"] for entry in job["outputs"]] + for name in names: + _require_uv_layer(obj, name) + args.output_dir.mkdir(parents=True, exist_ok=True) + args.output_dir = args.output_dir.resolve() + geometry = build_patch_geometry( + obj, cam, bpy.context.scene, bpy.context.evaluated_depsgraph_get(), + names, settings["nx"], settings["ny"], settings["edge_sample_grid"], + capture_camera_fragments=True, + ) + for name in names: + _render(name, geometry[name], settings, args.output_dir, + args.width_ratio, args.length_ratio) + + +if __name__ == "__main__": + main() diff --git a/blender/scripts/camera_grid_stroke_geometry.py b/blender/scripts/camera_grid_stroke_geometry.py new file mode 100644 index 0000000..d357109 --- /dev/null +++ b/blender/scripts/camera_grid_stroke_geometry.py @@ -0,0 +1,46 @@ +"""Convex camera-cell stroke shapes and perspective-preserving clipping.""" + +import math +import random + +from patch_geometry import clip_halfspace, interpolate_uv + + +def capsule_in_cell(i, j, nx, ny, seed, width_ratio=0.42, length_ratio=0.84): + """Return a round-ended stroke wholly inside one normalized camera cell.""" + if not 0 < width_ratio <= length_ratio < 1: + raise ValueError("require 0 < width_ratio <= length_ratio < 1") + rng = random.Random((seed * 1000003 + i * 9176 + j * 6113) & 0xFFFFFFFF) + angle = rng.uniform(0, math.pi) + radius = width_ratio / 2 + half_body = (length_ratio - width_ratio) / 2 + outline = [] + for centre, start in ((half_body, -math.pi / 2), (-half_body, math.pi / 2)): + for step in range(9): + theta = start + math.pi * step / 8 + x = centre + radius * math.cos(theta) + y = radius * math.sin(theta) + xr = x * math.cos(angle) - y * math.sin(angle) + yr = x * math.sin(angle) + y * math.cos(angle) + outline.append(((i + 0.5 + xr) / nx, (j + 0.5 + yr) / ny)) + return outline + + +def clip_to_stroke(fragment, outline): + """Clip a projected triangle while retaining its perspective attributes.""" + points = fragment + for a, b in zip(outline, outline[1:] + outline[:1]): + dx, dy = b[0] - a[0], b[1] - a[1] + points = clip_halfspace( + points, + lambda p, a=a, dx=dx, dy=dy: dx * (p[1] - a[1]) - dy * (p[0] - a[0]), + ) + if len(points) < 3: + return [] + return points + + +def projected_stroke_uv(fragment, loop_uvs, outline): + """Resolve a clipped camera stroke through the same UV interpolation as patches.""" + return [interpolate_uv(point, loop_uvs) + for point in clip_to_stroke(fragment, outline)] diff --git a/blender/scripts/patch_projection.py b/blender/scripts/patch_projection.py index d911e19..e110440 100644 --- a/blender/scripts/patch_projection.py +++ b/blender/scripts/patch_projection.py @@ -37,7 +37,7 @@ def _min_seam_distance(point, segments): def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid, fill_unresolved_leaves=False, seam_segments=None, - seam_search_mm=0.0): + seam_search_mm=0.0, capture_camera_fragments=False): """Evaluated loop-triangle fragments for one camera, per UV target. ``fill_unresolved_leaves`` accepts fragments whose visibility could not be resolved at @@ -72,6 +72,9 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp origin = camera.matrix_world.translation forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) output = {name: {'mesh': [], 'patches': []} for name in uv_names} + if capture_camera_fragments: + for name in uv_names: + output[name]['camera_fragments'] = [] ray_count = 0 # Triangle-level and fragment-level records, shared by every UV target: the @@ -184,6 +187,10 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp for name in uv_names: polygon = [interpolate_uv(p, uvs[name]) for p in fragment] output[name]['patches'].append((i, j, polygon)) + if capture_camera_fragments: + output[name]['camera_fragments'].append( + (i, j, fragment, uvs[name]) + ) output['_meta'] = { 'triangle_count': len(mesh.loop_triangles), diff --git a/blender/scripts/test_camera_grid_stroke_geometry.py b/blender/scripts/test_camera_grid_stroke_geometry.py new file mode 100644 index 0000000..b26addd --- /dev/null +++ b/blender/scripts/test_camera_grid_stroke_geometry.py @@ -0,0 +1,28 @@ +"""Run with python -m unittest discover -s blender/scripts -p test_camera_grid_stroke_geometry.py.""" + +import unittest + +from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv +from patch_geometry import area, project_triangle + + +class CameraGridStrokeGeometryTests(unittest.TestCase): + def test_each_stroke_stays_inside_its_camera_cell(self): + for i, j in ((0, 0), (10, 20), (79, 79)): + outline = capsule_in_cell(i, j, 80, 80, 7) + self.assertGreater(area(outline), 0) + self.assertTrue(all(i / 80 < u < (i + 1) / 80 and + j / 80 < v < (j + 1) / 80 + for u, v in outline)) + + def test_camera_clipping_interpolates_uv_at_new_vertices(self): + triangle = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (0, 1, 0, 1)]) + outline = capsule_in_cell(0, 0, 1, 1, 7) + uv = projected_stroke_uv(triangle, [(0, 0), (1, 0), (0.5, 1)], outline) + self.assertGreater(len(uv), 3) + self.assertTrue(all(0 <= u <= 1 and 0 <= v <= 1 for u, v in uv)) + self.assertGreater(area(uv), 0) + + +if __name__ == "__main__": + unittest.main()