diff --git a/README.md b/README.md index 992d0b2..dd4346c 100644 --- a/README.md +++ b/README.md @@ -227,11 +227,12 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa --vector-width-mm 164 --vector-height-mm 160 ``` -`binary-preview` makes Random Stroke Pattern (RSP) PNG trials from a color job -in a separate directory; `--views` selects the multiview job. Stroke width -follows camera-cell print size, bounded to 0.11–0.28 mm. These image-space -trials are for probe comparisons, not print masters; UV views may select -different cells. See `binary-preview --help` for paths and sizing controls. +`stroke-texture` exports Random Stroke Pattern (RSP) PNG/SVG/PDF and a hash +manifest from existing color artwork, for example: +`orisys-design stroke-texture sensors/finger/r01 --views`. Outputs go to a +`strokes` subdirectory; `--output-dir` selects another. `binary-preview` +remains PNG-only. Nominal stroke width is 0.11–0.28 mm; verify vectors and +printed dimensions before manufacturing. ## Inspect printability (advisory) diff --git a/blender/scripts/random_patch_dd02c.py b/blender/scripts/random_patch_dd02c.py index 7380d36..c12fc43 100644 --- a/blender/scripts/random_patch_dd02c.py +++ b/blender/scripts/random_patch_dd02c.py @@ -93,8 +93,6 @@ def fill_polygon(image, pixels, width, height, polygon, color): poly = [(int(round(x)), int(round(y))) for x, y in polygon] if len(poly) < 3: return - min_x = max(0, min(p[0] for p in poly)) - max_x = min(width-1, max(p[0] for p in poly)) min_y = max(0, min(p[1] for p in poly)) max_y = min(height-1, max(p[1] for p in poly)) for y in range(min_y, max_y + 1): @@ -125,8 +123,6 @@ def fill_polygon_value(grid, width, height, polygon, value): poly = [(int(round(x)), int(round(y))) for x, y in polygon] if len(poly) < 3: return - min_x = max(0, min(p[0] for p in poly)) - max_x = min(width - 1, max(p[0] for p in poly)) min_y = max(0, min(p[1] for p in poly)) max_y = min(height - 1, max(p[1] for p in poly)) for y in range(min_y, max_y + 1): @@ -679,10 +675,19 @@ def _clipped_shapes(shapes, width, height): return clipped +def save_png_image(image, output_path): + """Serialize an already-rasterized Blender image as PNG.""" + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + image.filepath_raw = str(path) + image.file_format = 'PNG' + image.save() + + def write_svg_vector( output_path, texture_width, texture_height, width_mm, height_mm, mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map, - background=None, + background=None, artwork_label="patch", ): """Write editable grouped SVG geometry; no raster data is embedded. @@ -696,6 +701,9 @@ def write_svg_vector( background_fill = "#" + "".join( f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3] ) + if artwork_label not in ("patch", "stroke"): + raise ValueError(f"unsupported artwork label: {artwork_label!r}") + plural = "patches" if artwork_label == "patch" else "strokes" lines = [ '', ( @@ -705,10 +713,10 @@ def write_svg_vector( f'viewBox="0 0 {texture_width} {texture_height}" ' f'preserveAspectRatio="none">' ), - f" Orisys patch texture — {_xml_escape(uv_map)} UV map", + f" Orisys {artwork_label} texture — {_xml_escape(uv_map)} UV map", ( " Native vector geometry grouped as background, mesh, " - "camera-space patches, and reference cross." + f"camera-space {plural}, and reference cross." ), ( f' ' @@ -728,10 +736,10 @@ def write_svg_vector( f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0) ) ) - lines.extend([" ", ' ']) + lines.extend([" ", f' ']) for index, (polygon, color) in enumerate(patch_shapes, start=1): lines.append( - _svg_polygon_element(f"patch-{index:04d}", polygon, color) + _svg_polygon_element(f"{artwork_label}-{index:04d}", polygon, color) ) lines.extend([" ", ' ']) cross_attributes = _svg_fill_attributes(cross_color) @@ -798,7 +806,7 @@ def _build_pdf(objects): def write_pdf_vector( output_path, texture_width, texture_height, width_mm, height_mm, mesh_polygons, patch_shapes, cross_rects, cross_color, - background=None, + background=None, artwork_label="patch", ): """Write native vector PDF paths at the configured physical page size. @@ -824,7 +832,9 @@ def write_pdf_vector( for polygon in mesh_polygons: content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y)) - content.append("% camera-space patches") + if artwork_label not in ("patch", "stroke"): + raise ValueError(f"unsupported artwork label: {artwork_label!r}") + content.append(f"% camera-space {'patches' if artwork_label == 'patch' else 'strokes'}") for polygon, color in patch_shapes: content.append(_pdf_set_fill(color)) content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y)) @@ -961,10 +971,7 @@ def generate_patch_texture( image.pixels[:] = pixels image.update() 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() + save_png_image(image, out_path) print(f"PNG raster texture saved to {out_path}") uv_map_name = uv_layer.name if uv_layer is not None else "active" diff --git a/blender/scripts/random_stroke_pattern.py b/blender/scripts/random_stroke_pattern.py index da164b4..c2d4b40 100644 --- a/blender/scripts/random_stroke_pattern.py +++ b/blender/scripts/random_stroke_pattern.py @@ -1,4 +1,4 @@ -"""Generate Random Stroke Pattern (RSP) trials from colour texture jobs. +"""Generate Random Stroke Pattern (RSP) artwork from colour texture jobs. Run after the colour texture command has produced its PNGs:: @@ -23,7 +23,8 @@ gradient: 0.11–0.16 mm width and 0.24–0.38 mm length, edge to centre. These nominal print-page sizes; clipped/merged ink still needs physical validation. The camera-compensated trial scales width from each camera cell's print-page footprint, bounded to 0.11–0.28 mm; larger far-field cells therefore receive -larger marks. It remains a preview because image-space regions can be distorted. +larger marks. The PNG and native SVG/PDF share accepted capsule geometry, but +image-space regions can be distorted. Verify dimensions before manufacturing. """ from __future__ import annotations @@ -33,11 +34,17 @@ import hashlib import json import math import sys +import xml.etree.ElementTree as ET from pathlib import Path import bpy import numpy as np +script_dir = str(Path(__file__).resolve().parent) +if script_dir not in sys.path: + sys.path.insert(0, script_dir) +from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path + PHYSICAL_WIDTH_MM = {"width_0p1mm": 0.1, "width_0p12mm": 0.12, "width_0p2mm": 0.2} MODES = ( "dots", @@ -72,6 +79,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=0.5, help="Compensated stroke width as a fraction of the local print-cell span", ) + parser.add_argument( + "--formats", nargs="+", choices=("png", "svg", "pdf"), default=("png",) + ) + parser.add_argument( + "--artifact-prefix", + default=None, + help="Name single-mode outputs _.", + ) return parser.parse_args(argv) @@ -189,6 +204,26 @@ def camera_compensated_size( return width_mm, 2.4 * width_mm +def capsule_polygon( + centre_x: float, centre_y: float, radius: float, half_length: float, angle: float +) -> list[tuple[float, float]]: + """Approximate the raster capsule boundary for native vector output.""" + points = [] + cosine, sine = math.cos(angle), math.sin(angle) + for side, start in ((1, -math.pi / 2), (-1, math.pi / 2)): + for index in range(25): + theta = start + index * math.pi / 24 + local_x = side * half_length + radius * math.cos(theta) + local_y = radius * math.sin(theta) + points.append( + ( + centre_x + local_x * cosine - local_y * sine, + centre_y + local_x * sine + local_y * cosine, + ) + ) + return points + + def draw_mark( canvas: np.ndarray, region, @@ -199,6 +234,7 @@ def draw_mark( camera_cell: tuple[int, int] | None = None, grid_shape: tuple[int, int] | None = None, camera_width_fraction: float = 0.5, + vector_shapes: list | None = None, ) -> float: colour, cell_x, cell_y, width, height = region rgb = ((colour >> 16) & 255, (colour >> 8) & 255, colour & 255) @@ -293,6 +329,13 @@ def draw_mark( if (existing + added) / local_area > 0.45: return 0.0 canvas[y0:y1, x0:x1] |= footprint + if vector_shapes is not None: + vector_shapes.append( + ( + capsule_polygon(centre_x, centre_y, radius, half_length, angle), + (0.0, 0.0, 0.0, 1.0), + ) + ) return 2 * radius @@ -305,14 +348,14 @@ def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None try: image.pixels.foreach_set(rgba.ravel()) image.update() - image.filepath_raw = str(path) - image.file_format = "PNG" - image.save() + save_png_image(image, path) finally: bpy.data.images.remove(image) -def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]: +def job_sources( + job: dict, +) -> tuple[list[tuple[str, Path, Path | None, int | None]], float]: """Resolve source PNGs and physical scale from a colour texture job.""" settings = job.get("settings") or {} schema = job.get("schema") @@ -325,7 +368,14 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]: raise ValueError("multiview preview requires auto-sized pages") px_per_mm = float(page.get("px_per_mm", 0)) sources = [ - (entry["id"], Path(entry["artifacts"]["png"]), entry.get("seed")) + ( + entry["id"], + Path(entry["artifacts"]["png"]), + Path(entry["artifacts"]["svg"]) + if entry["artifacts"].get("svg") + else None, + entry.get("seed"), + ) for entry in entries ] elif schema == "orisys-sensor-design/texture-job-v3": @@ -344,19 +394,75 @@ def job_sources(job: dict) -> tuple[list[tuple[str, Path, int | None]], float]: "single-view page has unequal horizontal and vertical scales" ) sources = [ - (entry["uv_map"], Path(entry["artifacts"]["png"]), settings.get("seed")) + ( + entry["uv_map"], + Path(entry["artifacts"]["png"]), + Path(entry["artifacts"]["svg"]) + if entry["artifacts"].get("svg") + else None, + settings.get("seed"), + ) for entry in entries ] else: raise ValueError(f"unsupported colour texture job schema: {schema!r}") if not sources or not math.isfinite(px_per_mm) or px_per_mm <= 0: raise ValueError("colour texture job needs source PNGs and positive px_per_mm") - for _, source, _ in sources: + for source_id, source, _svg, _seed in sources: + if not source_id or not all( + char.isalnum() or char in "_-" for char in source_id + ): + raise ValueError(f"unsafe source ID: {source_id!r}") if not source.is_file(): raise FileNotFoundError(f"run the colour generator first: {source}") return sources, px_per_mm +def source_mesh_polygons( + svg_path: Path | None, width: int, height: int, px_per_mm: float +) -> tuple[list[list[tuple[float, float]]], float, float]: + """Read the source artwork's owned white ground and physical page size.""" + if svg_path is None or not svg_path.is_file(): + raise FileNotFoundError( + f"source colour SVG is required for vectors: {svg_path}" + ) + root = ET.parse(svg_path).getroot() + width_mm = float(root.attrib["width"].removesuffix("mm")) + height_mm = float(root.attrib["height"].removesuffix("mm")) + view_box = [float(value) for value in root.attrib["viewBox"].split()] + if ( + len(view_box) != 4 + or view_box[:2] != [0.0, 0.0] + or view_box[2:] != [float(width), float(height)] + or not math.isfinite(width_mm) + or not math.isfinite(height_mm) + or width_mm <= 0 + or height_mm <= 0 + or not math.isclose(width / width_mm, px_per_mm, rel_tol=0.002) + or not math.isclose(height / height_mm, px_per_mm, rel_tol=0.002) + ): + raise ValueError(f"source SVG page does not match colour PNG/job: {svg_path}") + namespace = {"svg": "http://www.w3.org/2000/svg"} + mesh = root.find(".//svg:g[@id='mesh']", namespace) + if mesh is None: + raise ValueError(f"source SVG has no owned mesh group: {svg_path}") + polygons = [] + for element in mesh.findall("svg:polygon", namespace): + polygon = [ + tuple(float(value) for value in point.split(",")) + for point in element.attrib["points"].split() + ] + if len(polygon) >= 3: + polygons.append(polygon) + if not polygons: + raise ValueError(f"source SVG has no owned mesh polygons: {svg_path}") + return polygons, width_mm, height_mm + + +def artifact_stem(source_id: str, mode: str, prefix: str | None) -> str: + return f"{prefix}_{source_id}" if prefix else f"{source_id}_{mode}" + + def run( job_path: Path, output_dir: Path, @@ -364,21 +470,37 @@ def run( minimum: int, modes: tuple[str, ...], camera_width_fraction: float, + formats: tuple[str, ...] = ("png",), + artifact_prefix: str | None = None, ) -> None: job = json.loads(job_path.read_text(encoding="utf-8")) sources, px_per_mm = job_sources(job) + if artifact_prefix is not None and ( + len(modes) != 1 + or not artifact_prefix + or not all(char.isalnum() or char in "_-" for char in artifact_prefix) + ): + raise ValueError("artifact prefix requires one mode and a filename-safe prefix") + if not formats or len(formats) != len(set(formats)): + raise ValueError("formats must be non-empty and distinct") targets = [ - output_dir / f"{source_id}_{mode}.png" - for source_id, _, _ in sources + output_dir / f"{artifact_stem(source_id, mode, artifact_prefix)}.{fmt}" + for source_id, _, _, _ in sources for mode in modes + for fmt in formats ] for target in targets: if target.exists(): raise FileExistsError(f"refusing to overwrite {target}") output_dir.mkdir(parents=True, exist_ok=True) settings = job.get("settings", {}) - for source_id, source, source_seed in sources: + for source_id, source, source_svg, source_seed in sources: rgb, width, height = read_rgb(source) + vector_page = ( + source_mesh_polygons(source_svg, width, height, px_per_mm) + if "svg" in formats or "pdf" in formats + else None + ) surface = np.any(rgb != 0, axis=2) patterned = surface & np.any(rgb != 255, axis=2) regions = list(cell_regions(rgb, minimum)) @@ -426,6 +548,7 @@ def run( ) for mode in modes: marks = np.zeros((height, width), dtype=bool) + vector_shapes = [] if vector_page is not None else None limit = ( patterned if mode @@ -456,11 +579,42 @@ def run( else None, grid_shape=grid_shape, camera_width_fraction=camera_width_fraction, + vector_shapes=vector_shapes, ) for region in mode_regions ] - target = output_dir / f"{source_id}_{mode}.png" - write_binary_png(target, marks, surface) + stem = artifact_stem(source_id, mode, artifact_prefix) + if "png" in formats: + write_binary_png(output_dir / f"{stem}.png", marks, surface) + if vector_page is not None: + mesh_polygons, width_mm, height_mm = vector_page + if "svg" in formats: + write_svg_vector( + output_dir / f"{stem}.svg", + width, + height, + width_mm, + height_mm, + mesh_polygons, + vector_shapes, + [], + (0.0, 0.0, 0.0, 1.0), + source_id, + artwork_label="stroke", + ) + if "pdf" in formats: + write_pdf_vector( + output_dir / f"{stem}.pdf", + width, + height, + width_mm, + height_mm, + mesh_polygons, + vector_shapes, + [], + (0.0, 0.0, 0.0, 1.0), + artwork_label="stroke", + ) accepted = [diameter for diameter in diameters if diameter > 0] median_width = float(np.median(accepted)) if accepted else 0.0 physical_width = ( @@ -480,7 +634,7 @@ def run( print( f"{source_id}: {mode}, {len(accepted)}/{len(mode_regions)} marks, " f"black coverage {coverage:.1%}, median mark width " - f"{median_width:.1f} px{physical_width}{width_range} -> {target}", + f"{median_width:.1f} px{physical_width}{width_range} -> {stem}", flush=True, ) @@ -499,6 +653,8 @@ def main() -> int: args.min_cell_pixels, tuple(args.modes), args.camera_width_fraction, + tuple(args.formats), + args.artifact_prefix, ) return 0 diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index f99fbf5..560d559 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -22,6 +22,7 @@ from orisys_sensor_design.manifest import ( export_revision_dir, load_profile, repo_root, + sha256_file, validate_revision_metadata, write_export_job, write_manifest, @@ -470,47 +471,62 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: return 0 -def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int: - """Convert existing colour textures to a bounded binary camera-footprint trial.""" +def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace): + """Validate the existing colour job shared by stroke preview and export.""" + texture_dir = resolve_texture_output_dir(root, paths, None) + job_name = "_texture_views_job.json" if args.views else "_texture_job.json" + job_path = (args.source_job or texture_dir / job_name).resolve() + if not job_path.is_file(): + raise FileNotFoundError(f"colour texture job missing: {job_path}") + job = json.loads(job_path.read_text(encoding="utf-8")) + expected_schema = ( + "orisys-sensor-design/texture-job-multiview-v1" + if args.views else "orisys-sensor-design/texture-job-v3" + ) + if job.get("schema") != expected_schema: + raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}") + if Path(job["revision_dir"]).resolve() != paths.root.resolve(): + raise ValueError("source job belongs to a different revision") + if Path(job["source_blend"]).resolve() != paths.source_blend.resolve(): + raise ValueError("source job was built from a different Blender scene") + entries = job["pairs" if args.views else "outputs"] + source_ids = [str(entry["id" if args.views else "uv_map"]) for entry in entries] + if (not source_ids or len(source_ids) != len(set(source_ids)) + or any(not source_id or not all( + char.isalnum() or char in "_-" for char in source_id + ) for source_id in source_ids)): + raise ValueError("source job needs distinct, filename-safe output IDs") + return job_path, job, entries, source_ids + + +def _stroke_cli_context(revision_dir: Path, args: argparse.Namespace): 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 - + return None, 1 if not math.isfinite(args.camera_width_fraction) or args.camera_width_fraction <= 0: print("ERROR: --camera-width-fraction must be finite and positive", file=sys.stderr) - return 2 - texture_dir = resolve_texture_output_dir(root, paths, None) - job_name = "_texture_views_job.json" if args.views else "_texture_job.json" - job_path = (args.source_job or texture_dir / job_name).resolve() - if not job_path.is_file(): - print(f"ERROR: colour texture job missing: {job_path}", file=sys.stderr) - return 3 + return None, 2 try: - job = json.loads(job_path.read_text(encoding="utf-8")) - expected_schema = ( - "orisys-sensor-design/texture-job-multiview-v1" - if args.views else "orisys-sensor-design/texture-job-v3" - ) - if job.get("schema") != expected_schema: - raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}") - if Path(job["revision_dir"]).resolve() != paths.root.resolve(): - raise ValueError("source job belongs to a different revision") - if Path(job["source_blend"]).resolve() != paths.source_blend.resolve(): - raise ValueError("source job was built from a different Blender scene") - entries = job["pairs" if args.views else "outputs"] - source_ids = [str(entry["id" if args.views else "uv_map"]) for entry in entries] - if (not source_ids or len(source_ids) != len(set(source_ids)) - or any(not source_id or not all( - char.isalnum() or char in "_-" for char in source_id - ) for source_id in source_ids)): - raise ValueError("source job needs distinct, filename-safe output IDs") + source = _stroke_source_job(paths, root, args) + except FileNotFoundError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return None, 3 except (OSError, ValueError, KeyError, TypeError) as exc: - print(f"ERROR: invalid colour texture job {job_path}: {exc}", file=sys.stderr) - return 2 + print(f"ERROR: invalid colour texture job: {exc}", file=sys.stderr) + return None, 2 + return (paths, *source), 0 + + +def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int: + """Convert existing colour textures to a bounded binary camera-footprint trial.""" + context, code = _stroke_cli_context(revision_dir, args) + if context is None: + return code + paths, job_path, _job, _entries, source_ids = context output_dir = (args.output_dir or job_path.parent / "binary-preview").resolve() targets = [output_dir / f"{source_id}_camera_compensated_width.png" @@ -541,6 +557,85 @@ def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int: return 0 +def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int: + """Export one PNG/SVG/PDF stroke set per colour source and record provenance.""" + context, code = _stroke_cli_context(revision_dir, args) + if context is None: + return code + paths, job_path, _job, entries, source_ids = context + for entry in entries: + for fmt in ("png", "svg"): + try: + source = Path(entry["artifacts"][fmt]) + except (KeyError, TypeError) as exc: + print(f"ERROR: colour job has no {fmt.upper()} artifact: {exc}", + file=sys.stderr) + return 2 + if not source.is_file() or source.stat().st_size == 0: + print(f"ERROR: source colour {fmt.upper()} missing: {source}", file=sys.stderr) + return 3 + output_dir = (args.output_dir or job_path.parent / "strokes").resolve() + outputs = { + source_id: { + fmt: output_dir / f"stroke_texture_{source_id}.{fmt}" + for fmt in ("png", "svg", "pdf") + } + for source_id in source_ids + } + manifest_path = output_dir / "stroke_texture_manifest.json" + temporary = manifest_path.with_suffix(".json.tmp") + if manifest_path.exists() or temporary.exists() or any(path.exists() for artifacts in outputs.values() + for path in artifacts.values()): + print("ERROR: stroke artifacts already exist; choose a fresh --output-dir", + file=sys.stderr) + return 2 + code = _run_blender_script( + "random_stroke_pattern.py", paths.source_blend, + ["--job", str(job_path), "--output-dir", str(output_dir), + "--modes", "camera_compensated_width", "--formats", "png", "svg", "pdf", + "--artifact-prefix", "stroke_texture", "--seed", str(args.seed), + "--camera-width-fraction", str(args.camera_width_fraction)], + ) + if code != 0: + return code + missing = [path for artifacts in outputs.values() for path in artifacts.values() + if not path.is_file() or path.stat().st_size == 0] + if missing: + for path in missing: + print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr) + return 3 + manifest = { + "schema": "orisys-sensor-design/stroke-texture-manifest-v1", + "revision_dir": str(paths.root), + "source_job": {"path": str(job_path), "sha256": sha256_file(job_path)}, + "source_blend": {"path": str(paths.source_blend), + "sha256": sha256_file(paths.source_blend)}, + "settings": {"mode": "camera_compensated_width", "seed": args.seed, + "camera_width_fraction": args.camera_width_fraction}, + "outputs": [ + {"id": source_id, + "sources": {fmt: {"path": str(Path(entry["artifacts"][fmt])), + "sha256": sha256_file(Path(entry["artifacts"][fmt]))} + for fmt in ("png", "svg")}, + "artifacts": {fmt: {"path": str(outputs[source_id][fmt]), + "sha256": sha256_file(outputs[source_id][fmt])} + for fmt in ("png", "svg", "pdf")}} + for source_id, entry in zip(source_ids, entries) + ], + } + temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + temporary.replace(manifest_path) + artifacts = [path for entry in outputs.values() for path in entry.values()] + if console.get_level() == console.QUIET: + console.artifact_paths([*artifacts, manifest_path]) + else: + console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}") + for path in artifacts: + console.report(f" {path}") + console.report(f" manifest: {manifest_path}") + return 0 + + def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int: """Generate one owned texture per production view. @@ -2202,6 +2297,32 @@ def main(argv: list[str] | None = None) -> None: help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)", ) + stroke_p = sub.add_parser( + "stroke-texture", + help="Export Random Stroke Pattern PNG/SVG/PDF from colour artwork", + ) + stroke_p.add_argument("revision_dir", type=Path) + stroke_p.add_argument( + "--views", action="store_true", + help="Use the existing two-view colour job and preserve its print ownership", + ) + stroke_p.add_argument( + "--source-job", type=Path, default=None, + help="Existing colour job (default: revision build/textures job)", + ) + stroke_p.add_argument( + "--output-dir", type=Path, default=None, + help="Fresh output directory (default: strokes beside the colour job)", + ) + stroke_p.add_argument( + "--seed", type=int, default=7, + help="Reproducible stroke offsets and angles (default: 7)", + ) + stroke_p.add_argument( + "--camera-width-fraction", type=float, default=0.5, + help="Stroke width / projected camera-cell span (default: 0.5; width 0.11–0.28 mm)", + ) + 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( @@ -2597,6 +2718,8 @@ def main(argv: list[str] | None = None) -> None: code = cmd_texture_views(args.revision_dir, args) elif args.command == "binary-preview": code = cmd_binary_preview(args.revision_dir, args) + elif args.command == "stroke-texture": + code = cmd_stroke_texture(args.revision_dir, args) elif args.command == "inspect": code = cmd_inspect(args.revision_dir, args) elif args.command == "preview": diff --git a/tests/test_stroke_texture_cli.py b/tests/test_stroke_texture_cli.py new file mode 100644 index 0000000..c9d322b --- /dev/null +++ b/tests/test_stroke_texture_cli.py @@ -0,0 +1,99 @@ +"""The stroke CLI must preserve colour sources and verify all exported formats.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from orisys_sensor_design import cli +from orisys_sensor_design.manifest import RevisionPaths, sha256_file + +REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01" + + +def _args(job: Path, output_dir: Path) -> argparse.Namespace: + return argparse.Namespace( + views=False, + source_job=job, + output_dir=output_dir, + camera_width_fraction=0.5, + seed=13, + ) + + +def _job(tmp_path: Path) -> tuple[Path, Path, Path]: + paths = RevisionPaths.from_revision_dir(REVISION) + png = tmp_path / "colour.png" + svg = tmp_path / "colour.svg" + png.write_bytes(b"colour pixels") + svg.write_bytes(b"colour geometry") + job_path = tmp_path / "_texture_job.json" + job_path.write_text( + json.dumps( + { + "schema": "orisys-sensor-design/texture-job-v3", + "revision_dir": str(paths.root), + "source_blend": str(paths.source_blend), + "outputs": [ + { + "uv_map": "compute", + "artifacts": {"png": str(png), "svg": str(svg)}, + } + ], + } + ), + encoding="utf-8", + ) + return job_path, png, svg + + +def test_stroke_cli_exports_three_formats_and_manifest(tmp_path, monkeypatch): + job, png, svg = _job(tmp_path) + output_dir = tmp_path / "strokes" + calls = [] + + def run_blender(script, blend, args): + calls.append((script, blend, args)) + output_dir.mkdir() + for fmt in ("png", "svg", "pdf"): + (output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode()) + return 0 + + monkeypatch.setattr(cli, "_run_blender_script", run_blender) + assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 0 + assert calls[0][0] == "random_stroke_pattern.py" + assert calls[0][2][-4:] == ["--seed", "13", "--camera-width-fraction", "0.5"] + manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text()) + assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v1" + assert manifest["source_job"]["sha256"] == sha256_file(job) + assert manifest["outputs"][0]["sources"]["png"]["sha256"] == sha256_file(png) + assert manifest["outputs"][0]["sources"]["svg"]["sha256"] == sha256_file(svg) + assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"} + assert png.read_bytes() == b"colour pixels" + assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2 + assert len(calls) == 1 + + +def test_stroke_cli_refuses_missing_source_svg(tmp_path, monkeypatch): + job, _png, svg = _job(tmp_path) + svg.unlink() + monkeypatch.setattr( + cli, + "_run_blender_script", + lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")), + ) + assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 3 + + +def test_stroke_cli_rejects_incomplete_source_job(tmp_path, monkeypatch): + job, _png, _svg = _job(tmp_path) + data = json.loads(job.read_text(encoding="utf-8")) + del data["outputs"][0]["artifacts"]["svg"] + job.write_text(json.dumps(data), encoding="utf-8") + monkeypatch.setattr( + cli, + "_run_blender_script", + lambda *_: (_ for _ in ()).throw(AssertionError("Blender must not run")), + ) + assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "strokes")) == 2