Add stroke texture PNG, SVG, and PDF export
Reuse colour artwork serializers and record source hashes. Verified single and multiview exports plus targeted tests.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
(
|
||||
@@ -705,10 +713,10 @@ def write_svg_vector(
|
||||
f'viewBox="0 0 {texture_width} {texture_height}" '
|
||||
f'preserveAspectRatio="none">'
|
||||
),
|
||||
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>",
|
||||
f" <title>Orisys {artwork_label} texture — {_xml_escape(uv_map)} UV map</title>",
|
||||
(
|
||||
" <desc>Native vector geometry grouped as background, mesh, "
|
||||
"camera-space patches, and reference cross.</desc>"
|
||||
f"camera-space {plural}, and reference cross.</desc>"
|
||||
),
|
||||
(
|
||||
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
|
||||
@@ -728,10 +736,10 @@ def write_svg_vector(
|
||||
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
)
|
||||
lines.extend([" </g>", ' <g id="patches">'])
|
||||
lines.extend([" </g>", f' <g id="{plural}">'])
|
||||
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([" </g>", ' <g id="reference-cross">'])
|
||||
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"
|
||||
|
||||
@@ -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 <prefix>_<source-id>.<format>",
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
+141
-18
@@ -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,26 +471,13 @@ 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."""
|
||||
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
|
||||
|
||||
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
|
||||
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():
|
||||
print(f"ERROR: colour texture job missing: {job_path}", file=sys.stderr)
|
||||
return 3
|
||||
try:
|
||||
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"
|
||||
@@ -508,9 +496,37 @@ def cmd_binary_preview(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
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 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 None, 2
|
||||
try:
|
||||
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":
|
||||
|
||||
@@ -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"<svg>colour geometry</svg>")
|
||||
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
|
||||
Reference in New Issue
Block a user