diff --git a/README.md b/README.md index 60dafde..fe58061 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,12 @@ build/export/finger/r01/ ## 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. +`orisys-design texture` generates one colored patch texture per UV map on the +sensing surface. UV unwrapping is **still an in-app Blender step**: this command +consumes the UV maps 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. A `.blend` saved in Edit mode is fine — the headless run enters Object +mode in memory and never writes the file. ```powershell orisys-design texture sensors/finger/r01 @@ -127,18 +129,26 @@ 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) +├── _texture_job.json # Blender input, regenerated by texture (gitignored) +├── patch_texture_produce.png # from the `produce` UV map (tracked, Git LFS) +└── patch_texture_compute.png # from the `compute` UV map (tracked, Git LFS) ``` -Generated PNGs are **committed** so a revision's texture can be synced to +`produce` is the hardware-production layout and `compute` the compute/runtime +layout; they are the two UV maps authored on `sensing_surface`. Both outputs +share one set of settings and one seed, and both read the same camera-space +patch grid, so a given physical patch carries the same colour in either file — +the images differ only in how the unwrap arranges the patches. + +Generated PNGs are **committed** so a revision's textures 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: +Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, the +`uv_outputs` map (UV map → PNG name), texture size, patch grid, colour +separation, edge sampling, reference cross, and an optional seed. CLI flags +override the shared values for every output: ```powershell orisys-design texture sensors/finger/r01 ` @@ -152,11 +162,18 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa --job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json ``` +A single manual target is also possible (this cannot be combined with `--job`): + +```powershell +blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- ` + --uv-map produce --output D:\tmp\produce.png --width 1024 --height 1024 +``` + 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. +check runs, and the full default grid for the textures you actually ship. Collection check: diff --git a/blender/scripts/random_patch_dd02c.py b/blender/scripts/random_patch_dd02c.py index 511a922..55b10c7 100644 --- a/blender/scripts/random_patch_dd02c.py +++ b/blender/scripts/random_patch_dd02c.py @@ -6,15 +6,16 @@ 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): +Manual single-target mode (cannot be combined with --job): - --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 + --uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \ + --width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross Scope: the colored "patches" mode only. checker/dots helpers remain available to callers but are not reachable from the headless entry point. The mesh must -already have an active UV map: UV unwrapping is still an in-app Blender step and -is never performed here. +already carry the requested UV maps: UV unwrapping is still an in-app Blender +step and is never performed here. A scene saved in Edit mode is handled by +switching to Object mode in memory only — the .blend is never written. - Mesh UVs are filled white so that non-visible parts become white. - Texture background (no-mesh areas) stays black. @@ -220,9 +221,16 @@ def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color): 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.""" +def get_uv_from_face(obj, face_index, location, uv_layer=None): + """World‑space location → UV coordinate on the mesh face. + + ``uv_layer`` selects the mesh UV map explicitly. ``None`` keeps the previous + active-layer behaviour, which the non-headless checker/dot helpers rely on. + """ me = obj.data + layer = uv_layer if uv_layer is not None else me.uv_layers.active + if layer is None: + return None local_loc = obj.matrix_world.inverted() @ location poly = me.polygons[face_index] vert_indices = poly.vertices @@ -232,10 +240,10 @@ def get_uv_from_face(obj, face_index, location): 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 + uv_data = layer.data + uv0 = uv_data[loop_indices[0]].uv + uv1 = uv_data[loop_indices[1]].uv + uv2 = uv_data[loop_indices[2]].uv def tri_area(a, b, c): return 0.5 * ((b - a).cross(c - a)).length total_area = tri_area(v0, v1, v2) @@ -261,14 +269,16 @@ def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, f return location, face_index return None, None -def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height): +def raycast_uv_from_camera( + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer=None +): """Camera raycast through normalized sensor coordinates and return UV.""" loc, face_idx = raycast_from_camera( scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height ) if loc is None: return None - return get_uv_from_face(obj, face_idx, loc) + return get_uv_from_face(obj, face_idx, loc, uv_layer) def convex_hull_uv(points): """Return UV points ordered around their 2D convex hull.""" @@ -301,7 +311,7 @@ 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 + edge_sample_grid=5, uv_layer=None ): """ Build a UV polygon for one camera-space patch. @@ -309,6 +319,8 @@ def sample_patch_uv_polygon( Full interior cells use the four corners. Edge cells that only partially hit the mesh are sampled more densely and converted to a convex hull so they are not dropped just because some corners miss the silhouette. + + ``uv_layer`` selects which mesh UV map the raycasts are resolved against. """ corners_ndc = [ (u_start, v_start), @@ -320,7 +332,7 @@ def sample_patch_uv_polygon( 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 + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer ) if uv is not None: corner_uvs.append(uv) @@ -334,24 +346,27 @@ def sample_patch_uv_polygon( 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 + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer ) if uv is not None: sampled_uvs.append(uv) return convex_hull_uv(sampled_uvs) -def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)): +def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None): """ - Fill all triangles of the active UV map with 'color' (default white). + Fill all triangles of one UV map with 'color' (default white). This marks every part of the mesh, visible or not, as white. + + ``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer + behaviour used by the checker/dot helpers. """ me = obj.data - uv_layer = me.uv_layers.active - if not uv_layer: - print("Warning: No active UV layer – mesh rasterization skipped.") + layer = uv_layer if uv_layer is not None else me.uv_layers.active + if not layer: + print("Warning: no UV layer selected – mesh rasterization skipped.") return - uv_data = uv_layer.data + uv_data = layer.data for poly in me.polygons: # Get loop indices for this polygon loops = poly.loop_indices @@ -508,7 +523,8 @@ def generate_patch_texture( nx=40, ny=30, r=0.05, texture_width=2048, texture_height=2048, output_path=None, uv_centre=None, cross_length=40, - cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5 + cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5, + uv_layer=None, image_name="PatchTexture" ): if not output_path: raise ValueError("output_path is required (absolute PNG path)") @@ -542,7 +558,7 @@ def generate_patch_texture( scene, depsgraph, cam, obj, u_start, u_end, v_start, v_end, frame_width, frame_height, - edge_sample_grid=edge_sample_grid + edge_sample_grid=edge_sample_grid, uv_layer=uv_layer ) if uv_poly is None: patch_uv_polygons.append(None) @@ -558,7 +574,7 @@ def generate_patch_texture( print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.") # Create image – start with black background - img_name = "PatchTexture" + img_name = image_name if img_name in bpy.data.images: bpy.data.images.remove(bpy.data.images[img_name]) image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True) @@ -568,7 +584,9 @@ def generate_patch_texture( 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)) + rasterize_mesh_uvs( + obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer + ) # Now draw the visible patches on top idx = 0 @@ -858,7 +876,14 @@ def parse_args(argv): "--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( + "--output", type=Path, default=None, + help="Absolute output PNG for a manual single-target run (needs --uv-map)", + ) + parser.add_argument( + "--uv-map", dest="uv_map", default=None, + help="Mesh UV map name for a manual single-target run (with --output)", + ) parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)") parser.add_argument("--cam", default=None, help="Camera object name (overrides job)") parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)") @@ -913,20 +938,68 @@ def _as_color(value, label): return tuple(rgba) +def _as_output_path(raw, label="output"): + if raw is None or raw == "": + raise ConfigError(f"{label}: a PNG path is required") + if not isinstance(raw, (str, Path)): + raise ConfigError(f"{label}: must be a path (got {raw!r})") + path = Path(raw).expanduser() + if not path.is_absolute(): + raise ConfigError(f"{label} must be an absolute path (got {raw!r})") + if path.suffix.lower() != ".png": + raise ConfigError(f"{label} must be a .png file (got {raw!r})") + return path + + +def _resolve_targets(args, job): + """Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags.""" + if args.job is not None: + if args.output is not None or args.uv_map is not None: + raise ConfigError("--output/--uv-map cannot be combined with --job") + entries = job.get("outputs") + if not isinstance(entries, list) or not entries: + raise ConfigError("job 'outputs' must be a non-empty list of {uv_map, output}") + targets = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ConfigError(f"job outputs[{index}] must be a mapping") + uv_map = entry.get("uv_map") + if not isinstance(uv_map, str) or not uv_map.strip(): + raise ConfigError( + f"job outputs[{index}]: 'uv_map' must be a non-empty string (got {uv_map!r})" + ) + targets.append( + (uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output")) + ) + else: + if args.uv_map is None or args.output is None: + raise ConfigError( + "Provide --job , or both --uv-map and --output " + ) + targets = [(args.uv_map, _as_output_path(args.output))] + + seen_names = set() + seen_paths = {} + for uv_map, path in targets: + if uv_map in seen_names: + raise ConfigError(f"duplicate UV map {uv_map!r} in outputs") + seen_names.add(uv_map) + key = str(path).lower() + if key in seen_paths: + raise ConfigError( + f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}" + ) + seen_paths[key] = uv_map + return targets + + def _resolve_config(args): """Merge --job JSON with explicit flags. Explicit flags win over job values.""" job = {} if args.job is not None: job = _load_job(args.job.resolve()) - 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})") + targets = _resolve_targets(args, job) objects = job.get("objects") or {} if not isinstance(objects, dict): @@ -984,7 +1057,7 @@ def _resolve_config(args): ) return { - "output": output, + "outputs": targets, "mesh": str(mesh), "camera": str(camera), "width": width, @@ -1010,21 +1083,55 @@ def _require_object(name, expected_type, label): 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." - ) +def _prepare_mesh_object(name): + """Return the named mesh object in Object mode so its UV data is readable. + + source_blend may be saved with the object in Edit mode, where UV layer data + reads as empty. The switch is in memory only — this script never saves the + .blend file. + """ + obj = _require_object(name, "MESH", "Mesh") + if obj.mode != "OBJECT": + print(f"Switching {obj.name!r} from {obj.mode} to OBJECT (in memory only)") + try: + for other in bpy.context.view_layer.objects: + other.select_set(False) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.mode_set(mode="OBJECT") + except RuntimeError as exc: + raise SceneError(f"Could not leave Edit mode for {obj.name!r}: {exc}") from exc + return obj + + +def _require_uv_layer(obj, name): + """Return the named UV layer, or fail listing the available maps.""" + mesh = obj.data + for layer in mesh.uv_layers: + if layer.name == name: + if len(layer.data) == 0: + raise SceneError( + f"UV map {name!r} on mesh {obj.name!r} has no loop data. UV unwrapping " + "is still an in-app Blender step: unwrap the mesh in the GUI, save the " + ".blend, then re-run orisys-design texture." + ) + return layer + available = ", ".join(sorted(layer.name for layer in mesh.uv_layers)) or "none" + raise SceneError( + f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available}). UV " + "unwrapping is still an in-app Blender step: unwrap the mesh in the GUI, save the " + ".blend, then re-run orisys-design texture." + ) + + +def _uv_centre(uv_layer): + """UV bounding-box centre for one UV map.""" uv = np.array( [uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))], dtype=np.float64, ) centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0 - print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})") + print(f"UV centre [{uv_layer.name}]: ({centre[0]:.4f}, {centre[1]:.4f})") return centre @@ -1036,50 +1143,76 @@ def main(): print(f"ERROR: {exc}", file=sys.stderr) return 2 - output = config["output"] + # Validate every target before rendering anything so a partial pair is never + # produced: the mesh mode, both UV maps and both output paths must be usable. try: - 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() - - try: - obj = _require_object(config["mesh"], "MESH", "Mesh") + obj = _prepare_mesh_object(config["mesh"]) cam = _require_object(config["camera"], "CAMERA", "Camera") - uv_centre = _uv_centre(obj) + targets = [ + (uv_map, output, _require_uv_layer(obj, uv_map)) + for uv_map, output in config["outputs"] + ] except SceneError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 + for uv_map, output, _layer in targets: + try: + output.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print( + f"ERROR: cannot create output directory for UV map {uv_map!r} " + f"({output.parent}): {exc}", + file=sys.stderr, + ) + return 2 + + scene = bpy.context.scene + depsgraph = bpy.context.evaluated_depsgraph_get() + if config["seed"] is not None: np.random.seed(config["seed"]) print(f"Using colour seed {config['seed']}") + # One RNG snapshot is replayed for every target, so the same physical patch + # keeps the same colour in each UV layout (and reruns reproduce when seeded). + rng_state = np.random.get_state() start_time = time.time() - try: - generate_patch_texture( - obj, cam, scene, depsgraph, - nx=config["nx"], ny=config["ny"], r=config["color_r"], - texture_width=config["width"], texture_height=config["height"], - output_path=str(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"], - ) - except Exception as exc: - print(f"ERROR: texture generation failed: {exc}", file=sys.stderr) - return 1 + for uv_map, output, uv_layer in targets: + np.random.set_state(rng_state) + uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None + print(f"--- UV map {uv_map!r} -> {output}") + try: + generate_patch_texture( + obj, cam, scene, depsgraph, + nx=config["nx"], ny=config["ny"], r=config["color_r"], + texture_width=config["width"], texture_height=config["height"], + output_path=str(output), + uv_centre=uv_centre, + cross_length=config["cross_length"], + cross_thickness=config["cross_thickness"], + cross_color=config["cross_color"], + edge_sample_grid=config["edge_sample_grid"], + uv_layer=uv_layer, + image_name=f"PatchTexture_{uv_map}", + ) + except Exception as exc: + print( + f"ERROR: texture generation failed for UV map {uv_map!r}: {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 + if not output.is_file() or output.stat().st_size == 0: + print( + f"ERROR: texture was not written for UV map {uv_map!r}: {output}", + file=sys.stderr, + ) + return 1 - print(f"Texture complete: {output}") + print(f"Texture complete: {len(targets)} file(s)") + for uv_map, output, _layer in targets: + print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)") 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 diff --git a/build/textures/finger/r01/patch_texture_compute.png b/build/textures/finger/r01/patch_texture_compute.png new file mode 100644 index 0000000..e4e6685 --- /dev/null +++ b/build/textures/finger/r01/patch_texture_compute.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0977139a3df265d2abbb5225ffe262da24b2cb4f8c9268cca93b92ae4a061f3c +size 628658 diff --git a/build/textures/finger/r01/patch_texture_produce.png b/build/textures/finger/r01/patch_texture_produce.png new file mode 100644 index 0000000..eac0fe2 --- /dev/null +++ b/build/textures/finger/r01/patch_texture_produce.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdd83c4138fd42308a262b9f2ea7d421e24cd0ef742262de1b02167698c5cc3b +size 693526 diff --git a/sensors/finger/r01/design.yaml b/sensors/finger/r01/design.yaml index 499bd88..d44c88c 100644 --- a/sensors/finger/r01/design.yaml +++ b/sensors/finger/r01/design.yaml @@ -24,12 +24,16 @@ objects: # 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. +# UV maps already stored in source_blend and never creates or unwraps one. +# One PNG is written per UV map below. All maps share the settings that follow +# and the same patch colours, so they differ only by UV layout. # These are the revision defaults; CLI flags override every value. texture_patch: mesh: sensing_surface camera: cam_sensor - filename: patch_texture.png + uv_outputs: + produce: patch_texture_produce.png # hardware production layout + compute: patch_texture_compute.png # compute/runtime layout width: 3280 # 1640*2 height: 3200 # 1600*2 nx: 80 diff --git a/sensors/finger/r01/source/geometry.blend b/sensors/finger/r01/source/geometry.blend index 015db0c..1d85ed2 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:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39 -size 673299 +oid sha256:2e21136b063a6694882db3d24ac331cc6d66683ade570b8266b6ad4baba6ba61 +size 688142 diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 1125aa2..c059722 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -29,6 +29,7 @@ from orisys_sensor_design.texture import ( build_texture_job, resolve_texture_output_dir, resolve_texture_settings, + texture_output_paths, texture_overrides_from_args, write_texture_job, ) @@ -224,7 +225,7 @@ def cmd_export(revision_dir: Path, profile: str) -> int: def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: - """Generate the colored patch texture headlessly for one revision.""" + """Generate one colored patch texture per configured UV map, headlessly.""" root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) errors = validate_revision_metadata(paths) @@ -242,8 +243,8 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: print(f"ERROR: {message}", file=sys.stderr) return 2 - output_path = output_dir / settings.filename - job = build_texture_job(paths, settings, output_path) + targets = texture_output_paths(output_dir, settings) + job = build_texture_job(paths, settings, output_dir) job_path = write_texture_job(job, output_dir) print(f"Wrote texture job: {job_path}") @@ -255,14 +256,23 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: 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, - ) + missing = [ + (target, path) + for target, path in targets + if not path.is_file() or path.stat().st_size == 0 + ] + if missing: + for target, path in missing: + print( + f"ERROR: Blender finished but the texture for UV map " + f"{target.uv_map!r} is missing or empty: {path}", + file=sys.stderr, + ) return 3 - print(f"OK: colored patch texture written to {output_path}") + print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}") + for target, path in targets: + print(f" [{target.uv_map}] {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}" @@ -310,7 +320,7 @@ def main(argv: list[str] | None = None) -> None: texture_p = sub.add_parser( "texture", - help="Generate the colored patch texture headlessly (UV unwrap stays in-app)", + help="Generate one colored patch texture per UV map (UV unwrap stays in-app)", ) texture_p.add_argument("revision_dir", type=Path) texture_p.add_argument( @@ -319,11 +329,6 @@ def main(argv: list[str] | None = None) -> None: 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") diff --git a/src/orisys_sensor_design/texture.py b/src/orisys_sensor_design/texture.py index 8c9daaf..cb7e573 100644 --- a/src/orisys_sensor_design/texture.py +++ b/src/orisys_sensor_design/texture.py @@ -6,8 +6,13 @@ and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path handed to Blender is absolute, so no manual copying between directories is required. +A revision may carry several UV maps on the sensing surface (for example +``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one output PNG +per UV map; a single run renders all of them with the same camera-space patch +colours, so the files differ only in how the unwrap arranges the patches. + UV unwrapping is deliberately outside this module: the headless run consumes the -UV map already authored in the Blender GUI. +UV maps already authored in the Blender GUI. """ from __future__ import annotations @@ -23,15 +28,22 @@ from orisys_sensor_design.manifest import ( load_yaml, ) -TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1" +TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v2" 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. +# +# `uv_outputs` maps a UV map name on the mesh to the PNG written for it. Each run +# renders every entry with the same camera-space patch colours, so the maps +# differ only by how the unwrap arranges the patches. DEFAULT_TEXTURE_PATCH: dict[str, Any] = { - "filename": "patch_texture.png", + "uv_outputs": { + "produce": "patch_texture_produce.png", + "compute": "patch_texture_compute.png", + }, "width": 2048, "height": 2048, "nx": 80, @@ -51,7 +63,7 @@ _TEXTURE_PATCH_KEYS = frozenset( { "mesh", "camera", - "filename", + "uv_outputs", "width", "height", "nx", @@ -74,13 +86,35 @@ class TextureConfigError(ValueError): """ +@dataclass(frozen=True) +class TextureOutputTarget: + """One generated PNG: the mesh UV map it reads and the file it writes.""" + + uv_map: str + filename: str + + def output_path(self, output_dir: Path) -> Path: + return Path(output_dir) / self.filename + + def to_job_entry(self, output_dir: Path) -> dict[str, Any]: + """Return the job entry Blender consumes (absolute path, ordered).""" + return { + "uv_map": self.uv_map, + "output": str(self.output_path(output_dir).resolve()), + } + + @dataclass(frozen=True) class TexturePatchSettings: - """Resolved colored-patch settings for one headless run.""" + """Resolved colored-patch settings for one headless run. + + All rendering settings are shared by every target in ``outputs``; the targets + differ only in which UV map they read and the file they write. + """ mesh: str camera: str - filename: str + outputs: tuple[TextureOutputTarget, ...] width: int height: int nx: int @@ -189,6 +223,22 @@ def merge_texture_patch( return merged +def _bare_png_name_problems(value: Any, label: str) -> list[str]: + """Return messages for a value that must be a bare ``.png`` file name.""" + if not isinstance(value, str) or not value.strip(): + return [f"{label}: must be a non-empty file name"] + if ( + "/" in value + or "\\" in value + or Path(value).name != value + or value in {".", ".."} + ): + return [f"{label}: must be a bare file name without directories ({value!r})"] + if Path(value).suffix.lower() != ".png": + return [f"{label}: must end with .png ({value!r})"] + return [] + + 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] = [] @@ -205,18 +255,30 @@ def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> li 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})") + uv_outputs = block.get("uv_outputs") + if not isinstance(uv_outputs, dict) or not uv_outputs: + errors.append( + "uv_outputs: must be a non-empty mapping of UV map name to PNG file name" + ) + else: + seen_outputs: dict[str, str] = {} + for uv_name, out_name in uv_outputs.items(): + if not isinstance(uv_name, str) or not uv_name.strip(): + errors.append( + f"uv_outputs: UV map names must be non-empty strings (got {uv_name!r})" + ) + continue + problems = _bare_png_name_problems(out_name, f"uv_outputs.{uv_name}") + if problems: + errors.extend(problems) + continue + if out_name in seen_outputs: + errors.append( + f"uv_outputs: duplicate output file name {out_name!r} " + f"for {seen_outputs[out_name]!r} and {uv_name!r}" + ) + else: + seen_outputs[out_name] = uv_name for key in ("width", "height"): value = block.get(key) @@ -279,10 +341,15 @@ def resolve_texture_settings( if len(color) == 3: color.append(1.0) + outputs = tuple( + TextureOutputTarget(uv_map=str(uv_name), filename=str(out_name)) + for uv_name, out_name in block["uv_outputs"].items() + ) + return TexturePatchSettings( mesh=str(block["mesh"]), camera=str(block["camera"]), - filename=str(block["filename"]), + outputs=outputs, width=int(block["width"]), height=int(block["height"]), nx=int(block["nx"]), @@ -301,8 +368,8 @@ 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``). + fields so the output layout follows the on-disk revision tree regardless of + what those metadata fields say. """ sensors_root = (root / "sensors").resolve() try: @@ -336,10 +403,19 @@ def texture_job_path(output_dir: Path) -> Path: return Path(output_dir) / TEXTURE_JOB_FILENAME +def texture_output_paths( + output_dir: Path, + settings: TexturePatchSettings, +) -> list[tuple[TextureOutputTarget, Path]]: + """Pair every configured target with its absolute output path, in order.""" + resolved_dir = Path(output_dir).resolve() + return [(target, target.output_path(resolved_dir)) for target in settings.outputs] + + def build_texture_job( paths: RevisionPaths, settings: TexturePatchSettings, - output_path: Path, + output_dir: Path, ) -> dict[str, Any]: """Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON).""" return { @@ -347,7 +423,9 @@ def build_texture_job( "mode": TEXTURE_MODE, "revision_dir": str(paths.root.resolve()), "source_blend": str(paths.source_blend.resolve()), - "output": str(Path(output_path).resolve()), + "outputs": [ + target.to_job_entry(Path(output_dir)) for target in settings.outputs + ], "objects": { "mesh": settings.mesh, "camera": settings.camera, @@ -370,7 +448,6 @@ def texture_overrides_from_args(args: Any) -> dict[str, Any]: 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), diff --git a/tests/test_texture.py b/tests/test_texture.py index 39760c4..cdcb4ff 100644 --- a/tests/test_texture.py +++ b/tests/test_texture.py @@ -15,6 +15,7 @@ from orisys_sensor_design.texture import ( TEXTURE_JOB_FILENAME, TEXTURE_JOB_SCHEMA, TextureConfigError, + TextureOutputTarget, build_texture_job, load_texture_patch_block, merge_texture_patch, @@ -22,10 +23,16 @@ from orisys_sensor_design.texture import ( resolve_texture_settings, texture_job_path, texture_output_dir, + texture_output_paths, texture_revision_rel, write_texture_job, ) +DEFAULT_OUTPUTS = [ + ("produce", "patch_texture_produce.png"), + ("compute", "patch_texture_compute.png"), +] + @pytest.fixture() def revision_dir() -> Path: @@ -38,7 +45,6 @@ def _texture_namespace( namespace = argparse.Namespace( revision_dir=revision_dir, output_dir=output_dir, - filename=None, mesh=None, cam=None, width=None, @@ -108,16 +114,34 @@ def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) - 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.""" + """The on-disk sensor folder must win over design.yaml's sensor field.""" 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_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + settings = resolve_texture_settings(paths) + + targets = texture_output_paths(tmp_path, settings) + + assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS + assert all(path.parent == tmp_path.resolve() for _, path in targets) + + def test_job_path_sits_beside_texture(tmp_path: Path) -> None: assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME +def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) -> None: + target = TextureOutputTarget(uv_map="produce", filename="a.png") + entry = target.to_job_entry(tmp_path) + assert entry["uv_map"] == "produce" + assert Path(entry["output"]).is_absolute() + assert Path(entry["output"]).name == "a.png" + + # ------------------------------------------------------- settings merge @@ -125,7 +149,7 @@ 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 [(target.uv_map, target.filename) for target in settings.outputs] == DEFAULT_OUTPUTS assert (settings.width, settings.height) == (3280, 3200) assert (settings.nx, settings.ny) == (80, 45) assert settings.color_r == pytest.approx(0.05) @@ -144,11 +168,23 @@ def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None: settings = resolve_texture_settings(paths) assert settings.width == DEFAULT_TEXTURE_PATCH["width"] assert settings.height == DEFAULT_TEXTURE_PATCH["height"] + assert [target.uv_map for target in settings.outputs] == ["produce", "compute"] # Object names fall back to design.yaml `objects` defaults. assert settings.mesh == "sensing_surface" assert settings.camera == "cam_sensor" +def test_design_yaml_uv_outputs_replace_builtin_defaults(tmp_path: Path) -> None: + revision = _write_revision( + tmp_path, + "texture_patch:\n uv_outputs:\n custom: my_custom_layout.png\n", + ) + settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) + assert [(target.uv_map, target.filename) for target in settings.outputs] == [ + ("custom", "my_custom_layout.png") + ] + + def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None: paths = RevisionPaths.from_revision_dir(revision_dir) settings = resolve_texture_settings( @@ -177,6 +213,8 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None: 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) + # Shared rendering settings leave both targets intact. + assert [target.uv_map for target in settings.outputs] == ["produce", "compute"] def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None: @@ -194,6 +232,14 @@ def test_unknown_design_key_is_rejected(tmp_path: Path) -> None: assert "widht" in str(excinfo.value) +def test_legacy_filename_key_is_rejected(tmp_path: Path) -> None: + """The singular filename key was replaced by uv_outputs.""" + revision = _write_revision(tmp_path, "texture_patch:\n filename: patch_texture.png\n") + with pytest.raises(TextureConfigError) as excinfo: + resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) + assert "filename" 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" @@ -220,9 +266,6 @@ def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None: ({"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"), @@ -238,6 +281,31 @@ def test_invalid_overrides_are_rejected( assert expected in str(excinfo.value) +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"uv_outputs": {}}, "uv_outputs"), + ({"uv_outputs": "produce.png"}, "uv_outputs"), + ({"uv_outputs": {"": "produce.png"}}, "uv_outputs"), + ({"uv_outputs": {"produce": ""}}, "uv_outputs.produce"), + ({"uv_outputs": {"produce": "texture.jpg"}}, "uv_outputs.produce"), + ({"uv_outputs": {"produce": "nested/a.png"}}, "uv_outputs.produce"), + ({"uv_outputs": {"produce": "..\\a.png"}}, "uv_outputs.produce"), + ( + {"uv_outputs": {"produce": "same.png", "compute": "same.png"}}, + "duplicate output file name", + ), + ], +) +def test_invalid_uv_outputs_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 @@ -245,9 +313,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) 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) + job = build_texture_job(paths, settings, output_dir) assert job["schema"] == TEXTURE_JOB_SCHEMA assert job["mode"] == "patches" assert set(job) == { @@ -255,10 +322,16 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) "mode", "revision_dir", "source_blend", - "output", + "outputs", "objects", "settings", } + assert "output" not in job + assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"] + assert [Path(entry["output"]).name for entry in job["outputs"]] == [ + filename for _, filename in DEFAULT_OUTPUTS + ] + assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"]) assert set(job["objects"]) == {"mesh", "camera"} assert set(job["settings"]) == { "width", @@ -280,9 +353,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) 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["outputs"] == job["outputs"] 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] @@ -292,56 +364,76 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path) 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) + write_texture_job(build_texture_job(paths, settings, nested), nested) assert (nested / TEXTURE_JOB_FILENAME).is_file() # ----------------------------------------------------------- CLI wiring -def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool): +def _fake_blender( + monkeypatch: pytest.MonkeyPatch, + exit_code: int, + write_pngs: bool = True, + skip_uv: str | None = None, +): 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: + if write_pngs: 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") + for entry in job["outputs"]: + if skip_uv is not None and entry["uv_map"] == skip_uv: + continue + Path(entry["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( +def test_cmd_texture_forwards_job_and_verifies_both_artifacts( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: - calls = _fake_blender(monkeypatch, 0, write_png=True) + calls = _fake_blender(monkeypatch, 0) out_dir = tmp_path / "out" assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0 + assert len(calls) == 1, "one Blender launch must render every target" 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() + + for _, filename in DEFAULT_OUTPUTS: + assert (out_dir / filename).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) + _fake_blender(monkeypatch, 9, write_pngs=False) namespace = _texture_namespace(revision_dir, tmp_path / "out") assert cli.cmd_texture(revision_dir, namespace) == 9 -def test_cmd_texture_detects_missing_texture( +def test_cmd_texture_detects_both_textures_missing( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: - _fake_blender(monkeypatch, 0, write_png=False) + _fake_blender(monkeypatch, 0, write_pngs=False) + namespace = _texture_namespace(revision_dir, tmp_path / "out") + assert cli.cmd_texture(revision_dir, namespace) == 3 + + +def test_cmd_texture_detects_partial_pair( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path +) -> None: + """One UV map written, the other missing, must not look like success.""" + _fake_blender(monkeypatch, 0, skip_uv="compute") namespace = _texture_namespace(revision_dir, tmp_path / "out") assert cli.cmd_texture(revision_dir, namespace) == 3 @@ -349,7 +441,7 @@ def test_cmd_texture_detects_missing_texture( 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) + calls = _fake_blender(monkeypatch, 0) out_dir = tmp_path / "flagged" with pytest.raises(SystemExit) as excinfo: cli.main( @@ -358,8 +450,6 @@ def test_main_texture_parses_flags_into_job( str(revision_dir), "--output-dir", str(out_dir), - "--filename", - "custom.png", "--mesh", "sensing_surface", "--width", @@ -377,21 +467,31 @@ def test_main_texture_parses_flags_into_job( ) assert excinfo.value.code == 0 - job = json.loads( - Path(calls[0][2][1]).read_text(encoding="utf-8") - ) + job = json.loads(Path(calls[0][2][1]).read_text(encoding="utf-8")) + assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"] 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() + for _, filename in DEFAULT_OUTPUTS: + assert (out_dir / filename).is_file() + + +def test_main_texture_rejects_filename_flag( + monkeypatch: pytest.MonkeyPatch, revision_dir: Path +) -> None: + """The singular --filename flag is gone; per-UV names come from design.yaml.""" + _fake_blender(monkeypatch, 0) + with pytest.raises(SystemExit) as excinfo: + cli.main(["texture", str(revision_dir), "--filename", "custom.png"]) + assert excinfo.value.code == 2 def test_main_texture_rejects_invalid_flag_with_exit_2( - monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, revision_dir: Path ) -> None: - _fake_blender(monkeypatch, 0, write_png=True) + _fake_blender(monkeypatch, 0) with pytest.raises(SystemExit) as excinfo: cli.main(["texture", str(revision_dir), "--width", "0"]) assert excinfo.value.code == 2