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:
+154
-31
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user