"""Convert camera-projected 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 import sys from pathlib import Path import bpy sys.path.insert(0, str(Path(__file__).resolve().parent)) from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv from camera_grid_stroke_optimizer import optimize_angles 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, ) MODE = "camera_grid_capsules" DEFAULT_PARAMS = { "seed": 7, "width_ratio": 0.42, "length_ratio": 0.84, "orientation_mode": "random", "proposals": 100000, } 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("--params", type=Path, default=None) parser.add_argument("--artifact-prefix", default="camera_grid_stroke") parser.add_argument("--seed", type=int, default=None) parser.add_argument("--width-ratio", type=float, default=None) parser.add_argument("--length-ratio", type=float, default=None) parser.add_argument("--orientation-mode", choices=("random", "optimized"), default=None, help="Use seeded random or experimental optimized angles") parser.add_argument("--proposals", type=int, default=None, help="Angle changes attempted in optimized mode") 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, angle_map=None, artifact_prefix="camera_grid_stroke"): 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, angle=None if angle_map is None else float(angle_map[j, i])) 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() label = artifact_prefix if angle_map is not None and artifact_prefix == "camera_grid_stroke": label += "_optimized" base = output_dir / f"{label}_{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() params = dict(DEFAULT_PARAMS) if args.params is not None: loaded = json.loads(args.params.read_text(encoding="utf-8")) if not isinstance(loaded, dict) or set(loaded) - set(DEFAULT_PARAMS): raise ValueError("invalid stroke params") params.update(loaded) for key in DEFAULT_PARAMS: value = getattr(args, key) if value is not None: params[key] = value if not 0 < params["width_ratio"] <= params["length_ratio"] < 1: raise ValueError("require 0 < width-ratio <= length-ratio < 1") if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1: raise ValueError("invalid orientation mode or proposal count") job = json.loads(args.job.read_text(encoding="utf-8")) if job.get("schema") != "orisys-sensor-design/texture-job-v3": raise ValueError("camera-grid strokes require a single-view texture-job-v3") settings = dict(job["settings"]) settings["seed"] = params["seed"] 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() angle_map = None if params["orientation_mode"] == "optimized": angle_map, report = optimize_angles( settings["nx"], settings["ny"], settings.get("seed") or 0, params["width_ratio"], params["length_ratio"], params["proposals"], ) report_path = args.output_dir / f"{args.artifact_prefix}_optimization.json" report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(f"Camera-grid optimization: {report}") 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, params["width_ratio"], params["length_ratio"], angle_map, args.artifact_prefix) if __name__ == "__main__": main()