diff --git a/.gitignore b/.gitignore index d0d4567..f4902f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ *.egg-info/ dist/ build/export/ +# Generated textures are tracked (Git LFS via *.png) so they can be synced to +# hardware production. Only the per-run job file is ignored: it records absolute +# machine paths and is rewritten by every run. +build/textures/**/_texture_job.json .pytest_cache/ # Blender diff --git a/README.md b/README.md index 4e4b595..60dafde 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ cd D:\orisys_ws\orisys-sensor-design python -m pip install -e . ``` -Three commands for v1: +Four commands for v1: ```powershell # 1) Check revision metadata and (optionally) Blender scene conventions @@ -75,7 +75,10 @@ orisys-design validate sensors/finger/r01 # 2) Export nominal geometry for a profile (compute → SDK layout) orisys-design export sensors/finger/r01 --profile compute -# 3) Write release manifest with file hashes +# 3) Generate the colored patch texture headlessly +orisys-design texture sensors/finger/r01 + +# 4) Write release manifest with file hashes orisys-design manifest sensors/finger/r01 ``` @@ -108,6 +111,53 @@ build/export/finger/r01/ └── world_to_cam.npy ``` +## Texture patches (headless) + +`orisys-design texture` generates the colored patch texture headlessly. UV +unwrapping is **still an in-app Blender step**: this command consumes the UV map +already stored in `source_blend` and never creates or unwraps one. Unwrap the +mesh in the Blender GUI, save the `.blend`, then run the command. + +```powershell +orisys-design texture sensors/finger/r01 +# writes build/textures/finger/r01/_texture_job.json then runs Blender +``` + +Output is revision-scoped, so nothing has to be copied by hand: + +```text +build/textures/finger/r01/ +├── _texture_job.json # Blender input, regenerated by texture (gitignored) +└── patch_texture.png # colored patch texture (tracked, Git LFS) +``` + +Generated PNGs are **committed** so a revision's texture can be synced to +hardware production; `.gitattributes` already routes `*.png` through Git LFS +(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is +ignored, because it records absolute machine paths and is rewritten every run. + +Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, +texture size, patch grid, colour separation, edge sampling, reference cross, +file name, and an optional seed. CLI flags override every value: + +```powershell +orisys-design texture sensors/finger/r01 ` + --width 1024 --height 1024 --nx 16 --ny 12 --seed 7 --no-cross +``` + +Manual Blender invocation with the same job: + +```powershell +blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- ` + --job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json +``` + +Only the colored "patches" mode is reachable headlessly; the checker and dot +helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The +default 3280×3200 grid matches the in-app script and is deliberately slow +(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick +check runs, and the full default grid for the texture you actually ship. + Collection check: ```powershell diff --git a/blender/scripts/random_patch_dd02c.py b/blender/scripts/random_patch_dd02c.py index c3a282b..511a922 100644 --- a/blender/scripts/random_patch_dd02c.py +++ b/blender/scripts/random_patch_dd02c.py @@ -1,15 +1,33 @@ +"""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 overrides (optional, win over job values): + + --output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \ + --nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --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 have an active UV map: UV unwrapping is still an in-app Blender step and +is never performed here. + +- Mesh UVs are filled white so that non-visible parts become white. +- Texture background (no-mesh areas) stays black. """ -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 argparse +import json +import sys +import time +from pathlib import Path import bpy import numpy as np -import os -import time from mathutils import Vector # ---------------------------------------------------------------------- @@ -489,9 +507,11 @@ 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, + output_path=None, uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5 ): + 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 @@ -569,11 +589,12 @@ def generate_patch_texture( image.pixels[:] = pixels image.update() - abs_path = bpy.path.abspath(output_path) - image.filepath_raw = abs_path + 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"Patch texture saved to {abs_path}") + print(f"Patch texture saved to {out_path}") return image # ---------------------------------------------------------------------- @@ -583,10 +604,12 @@ 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, + 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 @@ -668,11 +691,12 @@ def generate_checker_texture( image.pixels[:] = pixels image.update() - abs_path = bpy.path.abspath(output_path) - image.filepath_raw = abs_path + 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 {abs_path}") + print(f"Checker texture saved to {out_path}") return image # ---------------------------------------------------------------------- @@ -684,8 +708,10 @@ def generate_dot_texture( 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) + 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 @@ -797,121 +823,267 @@ def generate_dot_texture( image.pixels[:] = pixels image.update() - abs_path = bpy.path.abspath(output_path) - image.filepath_raw = abs_path + 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 {abs_path}") + print(f"Dot texture saved to {out_path}") return image # ---------------------------------------------------------------------- -# Main +# Headless configuration (job file from `orisys-design texture`) # ---------------------------------------------------------------------- -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", +_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 (output + objects + settings)", + ) + parser.add_argument("--output", type=Path, default=None, help="Absolute output PNG (overrides job)") + 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("--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_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) + + +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()) + + output_raw = args.output or job.get("output") + if not output_raw: + raise ConfigError("Provide --job and/or --output ") + output = Path(output_raw).expanduser() + if not output.is_absolute(): + raise ConfigError(f"output must be an absolute path (got {str(output_raw)!r})") + if output.suffix.lower() != ".png": + raise ConfigError(f"output must be a .png file (got {str(output_raw)!r})") + + 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") + + 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 + + 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") + 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 { + "output": output, + "mesh": str(mesh), + "camera": str(camera), + "width": width, + "height": height, + "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, } - 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() +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 _uv_centre(obj): + """UV bounding-box centre; fails if the mesh was never unwrapped.""" + uv_layer = obj.data.uv_layers.active + if uv_layer is None or len(uv_layer.data) == 0: + raise SceneError( + f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an " + "in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run " + "orisys-design texture." + ) + 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: ({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 + + output = config["output"] + try: + output.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print(f"ERROR: cannot create output directory {output.parent}: {exc}", file=sys.stderr) + return 2 + 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.") + try: + obj = _require_object(config["mesh"], "MESH", "Mesh") + cam = _require_object(config["camera"], "CAMERA", "Camera") + uv_centre = _uv_centre(obj) + except SceneError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 - 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 config["seed"] is not None: + np.random.seed(config["seed"]) + print(f"Using colour seed {config['seed']}") - 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": + start_time = time.time() + try: 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 + nx=config["nx"], ny=config["ny"], r=config["color_r"], + texture_width=config["width"], texture_height=config["height"], + output_path=str(output), + uv_centre=uv_centre if config["cross_enabled"] else None, + cross_length=config["cross_length"], + cross_thickness=config["cross_thickness"], + cross_color=config["cross_color"], + edge_sample_grid=config["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'.") + except Exception as exc: + print(f"ERROR: texture generation failed: {exc}", file=sys.stderr) + return 1 + + if not output.is_file() or output.stat().st_size == 0: + print(f"ERROR: texture was not written: {output}", file=sys.stderr) + return 1 + + print(f"Texture complete: {output}") + print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}") + print(f"Completed in {time.time() - start_time:.2f} seconds.") + return 0 - print(f"Completed in {time.time()-start_time:.2f} seconds.") if __name__ == "__main__": - main() \ No newline at end of file + raise SystemExit(main()) diff --git a/sensors/finger-xynova/current.yaml b/sensors/finger-xynova/current.yaml index b9ea9fd..6bdf9cf 100644 --- a/sensors/finger-xynova/current.yaml +++ b/sensors/finger-xynova/current.yaml @@ -1,3 +1,3 @@ -sensor: finger +sensor: finger-xynova revision: r01 status: draft diff --git a/sensors/finger-xynova/r01/design.yaml b/sensors/finger-xynova/r01/design.yaml index ff7753e..18943ce 100644 --- a/sensors/finger-xynova/r01/design.yaml +++ b/sensors/finger-xynova/r01/design.yaml @@ -1,4 +1,4 @@ -sensor: finger +sensor: finger-xynova revision: r01 status: draft diff --git a/sensors/finger-xynova/r01/profiles/compute.yaml b/sensors/finger-xynova/r01/profiles/compute.yaml index 5ecc875..8b1ed88 100644 --- a/sensors/finger-xynova/r01/profiles/compute.yaml +++ b/sensors/finger-xynova/r01/profiles/compute.yaml @@ -1,7 +1,7 @@ profile: compute description: Runtime sensing bundle consumed by orisys-sdk assets_dir -output_dir: build/export/finger/r01/compute +output_dir: build/export/finger-xynova/r01/compute # Files must match orisys-sdk / Studio runtime layout. artifacts: diff --git a/sensors/finger-xynova/r01/profiles/studio.yaml b/sensors/finger-xynova/r01/profiles/studio.yaml index a175401..3f3362f 100644 --- a/sensors/finger-xynova/r01/profiles/studio.yaml +++ b/sensors/finger-xynova/r01/profiles/studio.yaml @@ -2,7 +2,7 @@ profile: studio description: Compile a Studio .oryscene from compute export + SDK unroll map # Always compile from compute export, never from visualization/ pose mesh. -compute_export_dir: build/export/finger/r01/compute +compute_export_dir: build/export/finger-xynova/r01/compute # Measured field domain — lives in orisys-sdk, not this repo. unroll_map: orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz diff --git a/sensors/finger/r01/design.yaml b/sensors/finger/r01/design.yaml index f840847..499bd88 100644 --- a/sensors/finger/r01/design.yaml +++ b/sensors/finger/r01/design.yaml @@ -22,6 +22,27 @@ objects: cam_sensor: cam_sensor origin_sensor: origin_sensor +# Headless colored-patch texture generation (`orisys-design texture`). +# UV unwrapping is still done in the Blender GUI: the headless run consumes the +# UV map already stored in source_blend and never creates or unwraps one. +# These are the revision defaults; CLI flags override every value. +texture_patch: + mesh: sensing_surface + camera: cam_sensor + filename: patch_texture.png + width: 3280 # 1640*2 + height: 3200 # 1600*2 + nx: 80 + ny: 45 + color_r: 0.05 + edge_sample_grid: 5 + seed: null # set an integer for reproducible colours + cross: + enabled: true + length_px: 40 + thickness_px: 3 + color: [1.0, 0.0, 0.0, 1.0] # red reference cross + collections: frames: FRAMES export_compute: EXPORT_COMPUTE diff --git a/sensors/finger/r01/source/geometry.blend b/sensors/finger/r01/source/geometry.blend index 055e73c..015db0c 100644 --- a/sensors/finger/r01/source/geometry.blend +++ b/sensors/finger/r01/source/geometry.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdceb6bb091a7f8e1f820063c18b84ea9fa686949381f6d7b1cee74d63c27c18 -size 677092 +oid sha256:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39 +size 673299 diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 2fca6cb..1125aa2 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -1,4 +1,4 @@ -"""orisys-design CLI — validate, export, manifest.""" +"""orisys-design CLI — validate, export, texture, manifest.""" from __future__ import annotations @@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import ( write_export_job, write_manifest, ) +from orisys_sensor_design.texture import ( + TextureConfigError, + build_texture_job, + resolve_texture_output_dir, + resolve_texture_settings, + texture_overrides_from_args, + write_texture_job, +) def _blender_from_lock(root: Path) -> Path | None: @@ -215,6 +223,53 @@ def cmd_export(revision_dir: Path, profile: str) -> int: return 0 +def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: + """Generate the colored patch texture headlessly for one revision.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + overrides = texture_overrides_from_args(args) + try: + settings = resolve_texture_settings(paths, overrides) + output_dir = resolve_texture_output_dir(root, paths, args.output_dir) + except TextureConfigError as exc: + for message in str(exc).splitlines(): + print(f"ERROR: {message}", file=sys.stderr) + return 2 + + output_path = output_dir / settings.filename + job = build_texture_job(paths, settings, output_path) + job_path = write_texture_job(job, output_dir) + print(f"Wrote texture job: {job_path}") + + code = _run_blender_script( + "random_patch_dd02c.py", + paths.source_blend, + ["--job", str(job_path)], + ) + if code != 0: + return code + + if not output_path.is_file() or output_path.stat().st_size == 0: + print( + f"ERROR: Blender finished but the texture is missing or empty: {output_path}", + file=sys.stderr, + ) + return 3 + + print(f"OK: colored patch texture written to {output_path}") + print( + f" mode=patches {settings.width}x{settings.height} " + f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}" + ) + return 0 + + def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int: root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) @@ -253,6 +308,74 @@ def main(argv: list[str] | None = None) -> None: export_p.add_argument("revision_dir", type=Path) export_p.add_argument("--profile", default="compute", choices=["compute"]) + texture_p = sub.add_parser( + "texture", + help="Generate the colored patch texture headlessly (UV unwrap stays in-app)", + ) + texture_p.add_argument("revision_dir", type=Path) + texture_p.add_argument( + "--output-dir", + type=Path, + default=None, + help="Output directory (default: build/textures//)", + ) + texture_p.add_argument( + "--filename", + default=None, + help="PNG file name (default: design.yaml texture_patch.filename)", + ) + texture_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name") + texture_p.add_argument("--cam", default=None, help="Sensor camera object name") + texture_p.add_argument("--width", type=int, default=None, help="Texture width in pixels") + texture_p.add_argument("--height", type=int, default=None, help="Texture height in pixels") + texture_p.add_argument("--nx", type=int, default=None, help="Patch columns across the frame") + texture_p.add_argument("--ny", type=int, default=None, help="Patch rows across the frame") + texture_p.add_argument( + "--color-r", + dest="color_r", + type=float, + default=None, + help="Colour separation between neighbouring patches", + ) + texture_p.add_argument( + "--edge-sample-grid", + dest="edge_sample_grid", + type=int, + default=None, + help="Edge sampling grid for partially covered patches", + ) + texture_p.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours") + texture_p.add_argument( + "--no-cross", + dest="no_cross", + action="store_true", + default=None, + help="Do not draw the red reference cross at the UV centre", + ) + texture_p.add_argument( + "--cross-length", + dest="cross_length", + type=int, + default=None, + help="Reference cross arm length in pixels", + ) + texture_p.add_argument( + "--cross-thickness", + dest="cross_thickness", + type=int, + default=None, + help="Reference cross line thickness in pixels", + ) + texture_p.add_argument( + "--cross-color", + dest="cross_color", + type=float, + nargs=4, + metavar=("R", "G", "B", "A"), + default=None, + help="Reference cross RGBA colour in 0-1", + ) + manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes") manifest_p.add_argument("revision_dir", type=Path) manifest_p.add_argument( @@ -268,6 +391,8 @@ def main(argv: list[str] | None = None) -> None: code = cmd_validate(args.revision_dir, args.blender) elif args.command == "export": code = cmd_export(args.revision_dir, args.profile) + elif args.command == "texture": + code = cmd_texture(args.revision_dir, args) elif args.command == "manifest": code = cmd_manifest(args.revision_dir, args.out, args.profile) else: diff --git a/src/orisys_sensor_design/texture.py b/src/orisys_sensor_design/texture.py new file mode 100644 index 0000000..8c9daaf --- /dev/null +++ b/src/orisys_sensor_design/texture.py @@ -0,0 +1,399 @@ +"""Headless colored-patch texture settings, output paths, and job I/O. + +The host CLI (``orisys-design texture``) reads the ``texture_patch`` mapping in +``design.yaml``, merges explicit CLI overrides, writes a stdlib-JSON job file, +and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path +handed to Blender is absolute, so no manual copying between directories is +required. + +UV unwrapping is deliberately outside this module: the headless run consumes the +UV map already authored in the Blender GUI. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from orisys_sensor_design.manifest import ( + RevisionPaths, + export_object_names, + load_yaml, +) + +TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1" +TEXTURE_JOB_FILENAME = "_texture_job.json" +TEXTURE_RELATIVE_ROOT = Path("build") / "textures" +TEXTURE_MODE = "patches" + +# Built-in fallbacks. sensors///design.yaml is the source of +# truth for a revision; these only apply when a key is absent there. +DEFAULT_TEXTURE_PATCH: dict[str, Any] = { + "filename": "patch_texture.png", + "width": 2048, + "height": 2048, + "nx": 80, + "ny": 45, + "color_r": 0.05, + "edge_sample_grid": 5, + "seed": None, + "cross": { + "enabled": True, + "length_px": 40, + "thickness_px": 3, + "color": [1.0, 0.0, 0.0, 1.0], + }, +} + +_TEXTURE_PATCH_KEYS = frozenset( + { + "mesh", + "camera", + "filename", + "width", + "height", + "nx", + "ny", + "color_r", + "edge_sample_grid", + "seed", + "cross", + } +) +_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"}) + +_MAX_TEXTURE_EDGE = 32768 + + +class TextureConfigError(ValueError): + """Raised when texture_patch metadata or CLI overrides are invalid. + + The message may span several lines; each line is one problem. + """ + + +@dataclass(frozen=True) +class TexturePatchSettings: + """Resolved colored-patch settings for one headless run.""" + + mesh: str + camera: str + filename: str + width: int + height: int + nx: int + ny: int + color_r: float + edge_sample_grid: int + seed: int | None + cross_enabled: bool + cross_length_px: int + cross_thickness_px: int + cross_color: tuple[float, float, float, float] + + def to_job_settings(self) -> dict[str, Any]: + """Return the JSON-serialisable settings block consumed by Blender.""" + return { + "width": self.width, + "height": self.height, + "nx": self.nx, + "ny": self.ny, + "color_r": self.color_r, + "edge_sample_grid": self.edge_sample_grid, + "seed": self.seed, + "cross": { + "enabled": self.cross_enabled, + "length_px": self.cross_length_px, + "thickness_px": self.cross_thickness_px, + "color": list(self.cross_color), + }, + } + + +def _is_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def load_texture_patch_block(paths: RevisionPaths) -> dict[str, Any]: + """Return the raw ``texture_patch`` mapping from design.yaml ({} when absent).""" + design = load_yaml(paths.design_yaml) + raw = design.get("texture_patch") + if raw is None: + return {} + if not isinstance(raw, dict): + raise TextureConfigError( + f"{paths.design_yaml}: 'texture_patch' must be a mapping, got {type(raw).__name__}" + ) + return raw + + +def _check_known_keys(block: dict[str, Any], where: str, errors: list[str]) -> None: + unknown = sorted(set(block) - _TEXTURE_PATCH_KEYS) + if unknown: + allowed = ", ".join(sorted(_TEXTURE_PATCH_KEYS)) + errors.append(f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})") + + +def merge_texture_patch( + paths: RevisionPaths, + overrides: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Merge built-in defaults ← design.yaml ``texture_patch`` ← CLI overrides. + + Overrides with a value of ``None`` are ignored so an unset CLI flag never + erases a configured value. + """ + merged: dict[str, Any] = { + key: (dict(value) if isinstance(value, dict) else value) + for key, value in DEFAULT_TEXTURE_PATCH.items() + } + + errors: list[str] = [] + for block, where in ( + (load_texture_patch_block(paths), f"{paths.design_yaml} texture_patch"), + ( + {key: value for key, value in (overrides or {}).items() if value is not None}, + "CLI overrides", + ), + ): + if not block: + continue + _check_known_keys(block, where, errors) + for key, value in block.items(): + if key not in _TEXTURE_PATCH_KEYS: + continue + if key == "cross": + if not isinstance(value, dict): + errors.append(f"{where}.cross: must be a mapping") + continue + unknown_cross = sorted(set(value) - _CROSS_KEYS) + if unknown_cross: + errors.append( + f"{where}.cross: unknown key(s) {', '.join(unknown_cross)} " + f"(allowed: {', '.join(sorted(_CROSS_KEYS))})" + ) + for cross_key, cross_value in value.items(): + if cross_key in _CROSS_KEYS: + merged["cross"][cross_key] = cross_value + continue + merged[key] = value + + if errors: + raise TextureConfigError("\n".join(errors)) + return merged + + +def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]: + """Return one message per invalid value in a merged texture_patch mapping.""" + errors: list[str] = [] + + mesh = block.get("mesh") + if mesh is None: + block["mesh"] = objects["sensing_surface"] + elif not isinstance(mesh, str) or not mesh.strip(): + errors.append("mesh: must be a non-empty object name") + + camera = block.get("camera") + if camera is None: + block["camera"] = objects["cam_sensor"] + elif not isinstance(camera, str) or not camera.strip(): + errors.append("camera: must be a non-empty object name") + + filename = block.get("filename") + if not isinstance(filename, str) or not filename.strip(): + errors.append("filename: must be a non-empty file name") + elif ( + "/" in filename + or "\\" in filename + or Path(filename).name != filename + or filename in {".", ".."} + ): + errors.append(f"filename: must be a bare file name without directories ({filename!r})") + elif Path(filename).suffix.lower() != ".png": + errors.append(f"filename: must end with .png ({filename!r})") + + for key in ("width", "height"): + value = block.get(key) + if not _is_int(value) or value <= 0: + errors.append(f"{key}: must be a positive integer (got {value!r})") + elif value > _MAX_TEXTURE_EDGE: + errors.append(f"{key}: must be <= {_MAX_TEXTURE_EDGE} (got {value})") + + for key in ("nx", "ny"): + value = block.get(key) + if not _is_int(value) or value < 1: + errors.append(f"{key}: must be an integer >= 1 (got {value!r})") + + color_r = block.get("color_r") + if not _is_number(color_r) or not 0.0 <= float(color_r) <= 1.0: + errors.append(f"color_r: must be a number in [0, 1] (got {color_r!r})") + + edge_sample_grid = block.get("edge_sample_grid") + if not _is_int(edge_sample_grid) or edge_sample_grid < 2: + errors.append(f"edge_sample_grid: must be an integer >= 2 (got {edge_sample_grid!r})") + + seed = block.get("seed") + if seed is not None and not _is_int(seed): + errors.append(f"seed: must be an integer or null (got {seed!r})") + + cross = block.get("cross") + if not isinstance(cross, dict): + errors.append("cross: must be a mapping") + return errors + + if not isinstance(cross.get("enabled"), bool): + errors.append(f"cross.enabled: must be true or false (got {cross.get('enabled')!r})") + for key in ("length_px", "thickness_px"): + value = cross.get(key) + if not _is_int(value) or value < 1: + errors.append(f"cross.{key}: must be an integer >= 1 (got {value!r})") + + color = cross.get("color") + if not isinstance(color, (list, tuple)) or len(color) not in (3, 4): + errors.append("cross.color: must be a list of 3 or 4 numbers in [0, 1]") + elif any(not _is_number(channel) or not 0.0 <= float(channel) <= 1.0 for channel in color): + errors.append(f"cross.color: every channel must be a number in [0, 1] (got {color!r})") + + return errors + + +def resolve_texture_settings( + paths: RevisionPaths, + overrides: dict[str, Any] | None = None, +) -> TexturePatchSettings: + """Merge and validate settings for a headless colored-patch run.""" + block = merge_texture_patch(paths, overrides) + objects = export_object_names(paths) + errors = validate_texture_patch(block, objects) + if errors: + raise TextureConfigError("\n".join(errors)) + + cross = block["cross"] + color = [float(channel) for channel in cross["color"]] + if len(color) == 3: + color.append(1.0) + + return TexturePatchSettings( + mesh=str(block["mesh"]), + camera=str(block["camera"]), + filename=str(block["filename"]), + width=int(block["width"]), + height=int(block["height"]), + nx=int(block["nx"]), + ny=int(block["ny"]), + color_r=float(block["color_r"]), + edge_sample_grid=int(block["edge_sample_grid"]), + seed=None if block["seed"] is None else int(block["seed"]), + cross_enabled=bool(cross["enabled"]), + cross_length_px=int(cross["length_px"]), + cross_thickness_px=int(cross["thickness_px"]), + cross_color=(color[0], color[1], color[2], color[3]), + ) + + +def texture_revision_rel(root: Path, paths: RevisionPaths) -> Path: + """Return ``/`` for the revision below the repo's sensors/. + + Directory names are used rather than design.yaml's ``sensor``/``revision`` + fields because those are not reliable across sensor folders (finger-xynova + still declares ``sensor: finger``). + """ + sensors_root = (root / "sensors").resolve() + try: + rel = paths.root.relative_to(sensors_root) + except ValueError: + design = load_yaml(paths.design_yaml) + sensor = str(design.get("sensor") or paths.root.parent.name) + revision = str(design.get("revision") or paths.root.name) + return Path(sensor) / revision + return rel + + +def texture_output_dir(root: Path, paths: RevisionPaths) -> Path: + """Default output directory: build/textures//.""" + return (root / TEXTURE_RELATIVE_ROOT / texture_revision_rel(root, paths)).resolve() + + +def resolve_texture_output_dir( + root: Path, + paths: RevisionPaths, + output_dir: Path | None = None, +) -> Path: + """Resolve the texture output directory (explicit --output-dir wins).""" + if output_dir is None: + return texture_output_dir(root, paths) + return (Path(output_dir).expanduser()).resolve() + + +def texture_job_path(output_dir: Path) -> Path: + """Return the job file path written beside the generated PNG.""" + return Path(output_dir) / TEXTURE_JOB_FILENAME + + +def build_texture_job( + paths: RevisionPaths, + settings: TexturePatchSettings, + output_path: Path, +) -> dict[str, Any]: + """Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON).""" + return { + "schema": TEXTURE_JOB_SCHEMA, + "mode": TEXTURE_MODE, + "revision_dir": str(paths.root.resolve()), + "source_blend": str(paths.source_blend.resolve()), + "output": str(Path(output_path).resolve()), + "objects": { + "mesh": settings.mesh, + "camera": settings.camera, + }, + "settings": settings.to_job_settings(), + } + + +def write_texture_job(job: dict[str, Any], output_dir: Path) -> Path: + """Write _texture_job.json into the output directory; return its path.""" + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + job_path = texture_job_path(output_dir) + job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8") + return job_path + + +def texture_overrides_from_args(args: Any) -> dict[str, Any]: + """Collect CLI override values from an argparse namespace (None = unset).""" + overrides: dict[str, Any] = { + "mesh": getattr(args, "mesh", None), + "camera": getattr(args, "cam", None), + "filename": getattr(args, "filename", None), + "width": getattr(args, "width", None), + "height": getattr(args, "height", None), + "nx": getattr(args, "nx", None), + "ny": getattr(args, "ny", None), + "color_r": getattr(args, "color_r", None), + "edge_sample_grid": getattr(args, "edge_sample_grid", None), + "seed": getattr(args, "seed", None), + } + + cross: dict[str, Any] = {} + length = getattr(args, "cross_length", None) + thickness = getattr(args, "cross_thickness", None) + color = getattr(args, "cross_color", None) + if length is not None: + cross["length_px"] = length + if thickness is not None: + cross["thickness_px"] = thickness + if color is not None: + cross["color"] = list(color) + no_cross = getattr(args, "no_cross", None) + if no_cross is not None: + cross["enabled"] = not bool(no_cross) + if cross: + overrides["cross"] = cross + + return overrides diff --git a/tests/test_texture.py b/tests/test_texture.py new file mode 100644 index 0000000..39760c4 --- /dev/null +++ b/tests/test_texture.py @@ -0,0 +1,397 @@ +"""Tests for headless colored-patch texture settings, job I/O, and CLI wiring.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pytest + +from orisys_sensor_design import cli +from orisys_sensor_design.manifest import RevisionPaths, repo_root +from orisys_sensor_design.texture import ( + DEFAULT_TEXTURE_PATCH, + TEXTURE_JOB_FILENAME, + TEXTURE_JOB_SCHEMA, + TextureConfigError, + build_texture_job, + load_texture_patch_block, + merge_texture_patch, + resolve_texture_output_dir, + resolve_texture_settings, + texture_job_path, + texture_output_dir, + texture_revision_rel, + write_texture_job, +) + + +@pytest.fixture() +def revision_dir() -> Path: + return repo_root() / "sensors" / "finger" / "r01" + + +def _texture_namespace( + revision_dir: Path, output_dir: Path | None = None, **overrides: object +) -> argparse.Namespace: + namespace = argparse.Namespace( + revision_dir=revision_dir, + output_dir=output_dir, + filename=None, + mesh=None, + cam=None, + width=None, + height=None, + nx=None, + ny=None, + color_r=None, + edge_sample_grid=None, + seed=None, + no_cross=None, + cross_length=None, + cross_thickness=None, + cross_color=None, + ) + for key, value in overrides.items(): + setattr(namespace, key, value) + return namespace + + +def _write_revision( + tmp_path: Path, + texture_patch_yaml: str = "", + *, + sensor_dir: str = "finger", + revision_name: str = "r99", +) -> Path: + """Create a minimal sensors// revision under tmp_path.""" + revision = tmp_path / "sensors" / sensor_dir / revision_name + (revision / "profiles").mkdir(parents=True) + (revision / "source").mkdir(parents=True) + (revision / "source" / "assembly.blend").write_bytes(b"") + design = ( + "sensor: finger\n" + f"revision: {revision_name}\n" + "meters_per_source_unit: 0.001\n" + "source_blend: source/assembly.blend\n" + ) + design += texture_patch_yaml + (revision / "design.yaml").write_text(design, encoding="utf-8") + for name in ("compute.yaml", "studio.yaml"): + (revision / "profiles" / name).write_text( + f"profile: {name.split('.')[0]}\n", encoding="utf-8" + ) + return revision + + +# ---------------------------------------------------------------- paths + + +def test_output_dir_is_revision_scoped(revision_dir: Path) -> None: + root = repo_root() + paths = RevisionPaths.from_revision_dir(revision_dir) + assert texture_revision_rel(root, paths).as_posix() == "finger/r01" + assert texture_output_dir(root, paths) == ( + root / "build" / "textures" / "finger" / "r01" + ).resolve() + + +def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -> None: + root = repo_root() + paths = RevisionPaths.from_revision_dir(revision_dir) + assert resolve_texture_output_dir(root, paths, tmp_path / "elsewhere") == ( + tmp_path / "elsewhere" + ).resolve() + + +def test_output_dir_uses_directory_names_not_design_sensor_field( + tmp_path: Path, +) -> None: + """finger-xynova declares `sensor: finger`; the folder name must win.""" + revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01") + paths = RevisionPaths.from_revision_dir(revision) + assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01" + + +def test_job_path_sits_beside_texture(tmp_path: Path) -> None: + assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME + + +# ------------------------------------------------------- settings merge + + +def test_defaults_come_from_design_yaml(revision_dir: Path) -> None: + settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir)) + assert settings.mesh == "sensing_surface" + assert settings.camera == "cam_sensor" + assert settings.filename == "patch_texture.png" + assert (settings.width, settings.height) == (3280, 3200) + assert (settings.nx, settings.ny) == (80, 45) + assert settings.color_r == pytest.approx(0.05) + assert settings.edge_sample_grid == 5 + assert settings.seed is None + assert settings.cross_enabled is True + assert settings.cross_length_px == 40 + assert settings.cross_thickness_px == 3 + assert settings.cross_color == (1.0, 0.0, 0.0, 1.0) + + +def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None: + revision = _write_revision(tmp_path) + paths = RevisionPaths.from_revision_dir(revision) + assert load_texture_patch_block(paths) == {} + settings = resolve_texture_settings(paths) + assert settings.width == DEFAULT_TEXTURE_PATCH["width"] + assert settings.height == DEFAULT_TEXTURE_PATCH["height"] + # Object names fall back to design.yaml `objects` defaults. + assert settings.mesh == "sensing_surface" + assert settings.camera == "cam_sensor" + + +def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + settings = resolve_texture_settings( + paths, + { + "width": 640, + "height": 512, + "nx": 4, + "ny": 3, + "mesh": "other_mesh", + "camera": "other_cam", + "color_r": 0.25, + "edge_sample_grid": 3, + "seed": 11, + "cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.0]}, + }, + ) + assert (settings.width, settings.height) == (640, 512) + assert (settings.nx, settings.ny) == (4, 3) + assert settings.mesh == "other_mesh" + assert settings.camera == "other_cam" + assert settings.color_r == pytest.approx(0.25) + assert settings.edge_sample_grid == 3 + assert settings.seed == 11 + assert settings.cross_enabled is False + assert settings.cross_length_px == 8 + # A 3-channel cross colour is widened to RGBA. + assert settings.cross_color == (0.0, 1.0, 0.0, 1.0) + + +def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + merged = merge_texture_patch(paths, {"width": None, "mesh": None, "cross": None}) + assert merged["width"] == 3280 + assert merged["mesh"] == "sensing_surface" + assert merged["cross"]["length_px"] == 40 + + +def test_unknown_design_key_is_rejected(tmp_path: Path) -> None: + revision = _write_revision(tmp_path, "texture_patch:\n widht: 100\n") + with pytest.raises(TextureConfigError) as excinfo: + resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) + assert "widht" in str(excinfo.value) + + +def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None: + revision = _write_revision( + tmp_path, "texture_patch:\n cross:\n thickness: 3\n" + ) + with pytest.raises(TextureConfigError) as excinfo: + resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) + assert "thickness" in str(excinfo.value) + + +def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None: + revision = _write_revision(tmp_path, "texture_patch: patches\n") + with pytest.raises(TextureConfigError) as excinfo: + resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) + assert "must be a mapping" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"width": 0}, "width"), + ({"height": -4}, "height"), + ({"nx": 0}, "nx"), + ({"ny": "many"}, "ny"), + ({"color_r": 2.0}, "color_r"), + ({"edge_sample_grid": 1}, "edge_sample_grid"), + ({"seed": "abc"}, "seed"), + ({"filename": "tex.jpg"}, "filename"), + ({"filename": "nested/tex.png"}, "filename"), + ({"filename": "..\\tex.png"}, "filename"), + ({"cross": {"enabled": "yes"}}, "cross.enabled"), + ({"cross": {"length_px": 0}}, "cross.length_px"), + ({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"), + ({"cross": {"color": [0.0, 0.0]}}, "cross.color"), + ], +) +def test_invalid_overrides_are_rejected( + revision_dir: Path, overrides: dict, expected: str +) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + with pytest.raises(TextureConfigError) as excinfo: + resolve_texture_settings(paths, overrides) + assert expected in str(excinfo.value) + + +# ------------------------------------------------------------- job I/O + + +def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3}) + output_dir = tmp_path / "textures" + output_path = output_dir / settings.filename + + job = build_texture_job(paths, settings, output_path) + assert job["schema"] == TEXTURE_JOB_SCHEMA + assert job["mode"] == "patches" + assert set(job) == { + "schema", + "mode", + "revision_dir", + "source_blend", + "output", + "objects", + "settings", + } + assert set(job["objects"]) == {"mesh", "camera"} + assert set(job["settings"]) == { + "width", + "height", + "nx", + "ny", + "color_r", + "edge_sample_grid", + "seed", + "cross", + } + assert set(job["settings"]["cross"]) == { + "enabled", + "length_px", + "thickness_px", + "color", + } + + job_path = write_texture_job(job, output_dir) + assert job_path == output_dir / TEXTURE_JOB_FILENAME + loaded = json.loads(job_path.read_text(encoding="utf-8")) + assert loaded["output"] == str(output_path.resolve()) + assert loaded["source_blend"] == str(paths.source_blend.resolve()) + assert Path(loaded["output"]).is_absolute() + assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"} + assert loaded["settings"]["seed"] == 3 + assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0] + + +def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + settings = resolve_texture_settings(paths) + nested = tmp_path / "a" / "b" + write_texture_job(build_texture_job(paths, settings, nested / settings.filename), nested) + assert (nested / TEXTURE_JOB_FILENAME).is_file() + + +# ----------------------------------------------------------- CLI wiring + + +def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool): + calls: list[tuple[str, Path, list[str]]] = [] + + def fake_run(script: str, blend: Path, extra_args: list[str]): + calls.append((script, blend, list(extra_args))) + if write_png: + job_path = Path(extra_args[extra_args.index("--job") + 1]) + job = json.loads(job_path.read_text(encoding="utf-8")) + Path(job["output"]).write_bytes(b"\x89PNG\r\n\x1a\n") + return exit_code + + monkeypatch.setattr(cli, "_run_blender_script", fake_run) + return calls + + +def test_cmd_texture_forwards_job_and_verifies_artifact( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + calls = _fake_blender(monkeypatch, 0, write_png=True) + out_dir = tmp_path / "out" + assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0 + + script, blend, extra = calls[0] + assert script == "random_patch_dd02c.py" + assert blend == revision_dir.resolve() / "source" / "geometry.blend" + assert extra[0] == "--job" + job_path = Path(extra[1]) + assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME + assert (out_dir / "patch_texture.png").is_file() + + +def test_cmd_texture_propagates_blender_failure( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + _fake_blender(monkeypatch, 9, write_png=False) + namespace = _texture_namespace(revision_dir, tmp_path / "out") + assert cli.cmd_texture(revision_dir, namespace) == 9 + + +def test_cmd_texture_detects_missing_texture( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + _fake_blender(monkeypatch, 0, write_png=False) + namespace = _texture_namespace(revision_dir, tmp_path / "out") + assert cli.cmd_texture(revision_dir, namespace) == 3 + + +def test_main_texture_parses_flags_into_job( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + calls = _fake_blender(monkeypatch, 0, write_png=True) + out_dir = tmp_path / "flagged" + with pytest.raises(SystemExit) as excinfo: + cli.main( + [ + "texture", + str(revision_dir), + "--output-dir", + str(out_dir), + "--filename", + "custom.png", + "--mesh", + "sensing_surface", + "--width", + "256", + "--height", + "256", + "--nx", + "4", + "--ny", + "3", + "--seed", + "7", + "--no-cross", + ] + ) + assert excinfo.value.code == 0 + + job = json.loads( + Path(calls[0][2][1]).read_text(encoding="utf-8") + ) + assert job["settings"]["width"] == 256 + assert job["settings"]["nx"] == 4 + assert job["settings"]["ny"] == 3 + assert job["settings"]["seed"] == 7 + assert job["settings"]["cross"]["enabled"] is False + assert (out_dir / "custom.png").is_file() + + +def test_main_texture_rejects_invalid_flag_with_exit_2( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + _fake_blender(monkeypatch, 0, write_png=True) + with pytest.raises(SystemExit) as excinfo: + cli.main(["texture", str(revision_dir), "--width", "0"]) + assert excinfo.value.code == 2