initial commit

This commit is contained in:
ykcheungab
2026-09-16 21:04:28 +08:00
commit a29ca6846c
31 changed files with 2537 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Orisys nominal sensor design tooling (minimal v1)."""
__version__ = "0.1.0"
+281
View File
@@ -0,0 +1,281 @@
"""orisys-design CLI — validate, export, manifest."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from orisys_sensor_design import __version__
from orisys_sensor_design.manifest import (
RevisionPaths,
build_export_job,
build_manifest,
default_manifest_path,
export_revision_dir,
export_output_dir,
load_profile,
repo_root,
validate_revision_metadata,
write_export_job,
write_manifest,
)
def _blender_from_lock(root: Path) -> Path | None:
lock_path = root / "blender.lock.json"
if not lock_path.is_file():
return None
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
raw = data.get("executable")
if not raw:
return None
path = Path(str(raw)).expanduser()
if path.is_file():
return path.resolve()
return None
def _lock_preferred_versions(root: Path | None) -> list[str]:
"""Return preferred version tokens from blender.lock.json (lts then full)."""
if root is None:
return []
lock_path = root / "blender.lock.json"
if not lock_path.is_file():
return []
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
preferred: list[str] = []
for key in ("blender_lts", "blender_version"):
value = data.get(key)
if isinstance(value, str) and value.strip():
preferred.append(value.strip())
return preferred
def _blender_install_candidates() -> list[Path]:
"""Collect blender.exe under Program Files / Blender Foundation."""
roots: list[Path] = []
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
raw = os.environ.get(env_name)
if raw:
roots.append(Path(raw))
# Fallbacks when env is missing (some sandboxed shells).
for fallback in (r"C:\Program Files", r"C:\Program Files (x86)"):
path = Path(fallback)
if path not in roots:
roots.append(path)
found: list[Path] = []
seen: set[Path] = set()
for root in roots:
base = root / "Blender Foundation"
if not base.is_dir():
continue
for child in sorted(base.glob("Blender *"), key=lambda p: p.name, reverse=True):
exe = child / "blender.exe"
if not exe.is_file():
continue
resolved = exe.resolve()
if resolved not in seen:
seen.add(resolved)
found.append(resolved)
return found
def _blender_from_program_files(root: Path | None = None) -> Path | None:
"""Pick a Blender install from Program Files; prefer lock version, else newest."""
candidates = _blender_install_candidates()
if not candidates:
return None
for preferred in _lock_preferred_versions(root):
for exe in candidates:
# Folder names look like "Blender 4.2" or "Blender 5.0".
if preferred in exe.parent.name:
return exe
# Newest by folder name (already sorted descending in candidates).
return candidates[0]
def resolve_blender_executable(root: Path | None = None) -> Path | None:
"""Find Blender without hard-coding a machine path.
Order: ORISYS_BLENDER / BLENDER_EXE → PATH → Program Files scan →
optional blender.lock.json executable.
"""
for env_name in ("ORISYS_BLENDER", "BLENDER_EXE"):
raw = os.environ.get(env_name)
if raw:
path = Path(raw).expanduser()
if path.is_file():
return path.resolve()
for name in ("blender", "blender.exe"):
found = shutil.which(name)
if found:
return Path(found).resolve()
from_pf = _blender_from_program_files(root)
if from_pf is not None:
return from_pf
if root is not None:
return _blender_from_lock(root)
return None
def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
root = repo_root()
blender = resolve_blender_executable(root)
script_path = root / "blender" / "scripts" / script
if blender is None:
manual = (
f"blender -b {blend} -P {script_path} -- {' '.join(extra_args)}"
)
print(
"Blender not found. Set ORISYS_BLENDER (or BLENDER_EXE) to blender.exe, "
"add Blender to PATH, install under Program Files\\Blender Foundation, "
"or set optional 'executable' in blender.lock.json.\n"
f"Manual command:\n {manual}",
file=sys.stderr,
)
return 2
cmd = [
str(blender),
"-b",
str(blend),
"-P",
str(script_path),
"--",
*extra_args,
]
print("Running:", " ".join(cmd))
return subprocess.call(cmd)
def cmd_validate(revision_dir: Path, blender: bool) -> int:
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
print(f"OK: metadata for {paths.root}")
if not blender:
print("Tip: pass --blender to run in-scene collection checks.")
return 0
return _run_blender_script("validate.py", paths.source_blend, [str(revision_dir.resolve())])
def cmd_export(revision_dir: Path, profile: str) -> int:
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
load_profile(paths, profile)
output_dir = export_output_dir(root, paths, profile)
output_dir.mkdir(parents=True, exist_ok=True)
job = build_export_job(paths, output_dir, profile)
job_path = write_export_job(job, export_revision_dir(root, paths, profile))
print(f"Wrote export job: {job_path}")
code = _run_blender_script(
"export.py",
paths.source_blend,
["--job", str(job_path)],
)
if code != 0:
return code
print(f"Export target: {output_dir}")
missing = []
for name in load_profile(paths, profile).get("artifacts", []):
if not (output_dir / name).is_file():
missing.append(name)
if missing:
print(
f"ERROR: export finished but artifacts are missing: {', '.join(missing)}",
file=sys.stderr,
)
return 3
print("OK: all profile artifacts present")
return 0
def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths, require_blend=False)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return 1
manifest = build_manifest(root=root, revision_dir=revision_dir, profile=profile)
if out is None:
out = default_manifest_path(root, paths, profile)
write_manifest(out, manifest)
print(f"Wrote {out}")
if "bundle_sha256" in manifest:
print(f"bundle_sha256: {manifest['bundle_sha256']}")
else:
print("WARN: export directory empty — run export before promoting", file=sys.stderr)
return 0
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(prog="orisys-design")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene")
validate_p.add_argument("revision_dir", type=Path)
validate_p.add_argument(
"--blender",
action="store_true",
help="Run blender/scripts/validate.py headless",
)
export_p = sub.add_parser("export", help="Export nominal geometry for a profile")
export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"])
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(
"--out",
type=Path,
default=None,
help="Manifest path (default: build/export/<sensor>/<revision>/manifest.json)",
)
manifest_p.add_argument("--profile", default="compute")
args = parser.parse_args(argv)
if args.command == "validate":
code = cmd_validate(args.revision_dir, args.blender)
elif args.command == "export":
code = cmd_export(args.revision_dir, args.profile)
elif args.command == "manifest":
code = cmd_manifest(args.revision_dir, args.out, args.profile)
else:
parser.error(f"Unknown command: {args.command}")
return
raise SystemExit(code)
if __name__ == "__main__":
main()
+229
View File
@@ -0,0 +1,229 @@
"""Release manifest and revision metadata helpers."""
from __future__ import annotations
import hashlib
import json
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class RevisionPaths:
root: Path
design_yaml: Path
source_blend: Path
profiles_dir: Path
@classmethod
def from_revision_dir(cls, revision_dir: Path) -> RevisionPaths:
revision_dir = revision_dir.resolve()
design_yaml = revision_dir / "design.yaml"
if not design_yaml.is_file():
raise FileNotFoundError(f"Missing design.yaml: {design_yaml}")
design = load_yaml(design_yaml)
blend_rel = design.get("source_blend", "source/assembly.blend")
return cls(
root=revision_dir,
design_yaml=design_yaml,
source_blend=(revision_dir / blend_rel).resolve(),
profiles_dir=revision_dir / "profiles",
)
def repo_root(start: Path | None = None) -> Path:
here = (start or Path.cwd()).resolve()
for candidate in [here, *here.parents]:
if (candidate / "pyproject.toml").is_file() and (candidate / "sensors").is_dir():
return candidate
raise FileNotFoundError("Could not locate orisys-sensor-design repository root")
def load_yaml(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if not isinstance(data, dict):
raise ValueError(f"Expected mapping in {path}")
return data
def git_head_commit(root: Path) -> str | None:
try:
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
return result.stdout.strip() or None
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
GENERATED_EXPORT_METADATA = frozenset({"_export_job.json", "manifest.json"})
def _is_generated_metadata(path: Path) -> bool:
return path.name in GENERATED_EXPORT_METADATA
def sha256_directory(directory: Path) -> str:
if not directory.is_dir():
raise FileNotFoundError(f"Export directory not found: {directory}")
parts: list[str] = []
for path in sorted(directory.rglob("*")):
if path.is_file() and not _is_generated_metadata(path):
rel = path.relative_to(directory).as_posix()
parts.append(f"{rel}\0{sha256_file(path)}")
digest = hashlib.sha256()
digest.update("\n".join(parts).encode("utf-8"))
return digest.hexdigest()
def validate_revision_metadata(paths: RevisionPaths, *, require_blend: bool = True) -> list[str]:
errors: list[str] = []
design = load_yaml(paths.design_yaml)
for key in ("sensor", "revision", "meters_per_source_unit"):
if key not in design:
errors.append(f"design.yaml missing required key: {key}")
if require_blend and not paths.source_blend.is_file():
errors.append(f"Source blend not found: {paths.source_blend}")
if not paths.profiles_dir.is_dir():
errors.append(f"Profiles directory not found: {paths.profiles_dir}")
else:
for name in ("compute.yaml", "studio.yaml"):
if not (paths.profiles_dir / name).is_file():
errors.append(f"Missing profile: {paths.profiles_dir / name}")
return errors
def load_profile(paths: RevisionPaths, profile: str) -> dict[str, Any]:
profile_path = paths.profiles_dir / f"{profile}.yaml"
if not profile_path.is_file():
raise FileNotFoundError(f"Unknown profile: {profile_path}")
data = load_yaml(profile_path)
if data.get("profile") != profile:
raise ValueError(f"Profile name mismatch in {profile_path}")
return data
DEFAULT_EXPORT_OBJECTS = {
"sensing_surface": "sensing_surface",
"cam_sensor": "cam_sensor",
"origin_sensor": "origin_sensor",
}
def export_object_names(paths: RevisionPaths) -> dict[str, str]:
"""Resolve Blender object names for headless export (design.yaml overrides)."""
design = load_yaml(paths.design_yaml)
objects = design.get("objects") or {}
if not isinstance(objects, dict):
raise ValueError("design.yaml 'objects' must be a mapping")
return {
key: str(objects.get(key, default))
for key, default in DEFAULT_EXPORT_OBJECTS.items()
}
EXPORT_JOB_SCHEMA = "orisys-sensor-design/export-job-v1"
EXPORT_JOB_FILENAME = "_export_job.json"
MANIFEST_FILENAME = "manifest.json"
def build_export_job(
paths: RevisionPaths,
output_dir: Path,
profile: str,
) -> dict[str, Any]:
"""Build the JSON job dict that Blender's export.py consumes (stdlib JSON)."""
return {
"schema": EXPORT_JOB_SCHEMA,
"output": str(output_dir.resolve()),
"profile": profile,
"objects": export_object_names(paths),
}
def export_revision_dir(root: Path, paths: RevisionPaths, profile: str) -> Path:
"""Return the revision export folder that contains profile payload dirs."""
return export_output_dir(root, paths, profile).parent
def default_manifest_path(root: Path, paths: RevisionPaths, profile: str) -> Path:
"""Default release manifest path beside profile payload directories."""
return export_revision_dir(root, paths, profile) / MANIFEST_FILENAME
def write_export_job(job: dict[str, Any], export_root: Path) -> Path:
"""Write _export_job.json beside profile output dirs; return its path."""
export_root.mkdir(parents=True, exist_ok=True)
job_path = export_root / EXPORT_JOB_FILENAME
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
return job_path
def export_output_dir(root: Path, paths: RevisionPaths, profile: str) -> Path:
profile_data = load_profile(paths, profile)
output_rel = profile_data.get("output_dir")
if not output_rel:
raise ValueError(f"Profile {profile} missing output_dir")
return (root / output_rel).resolve()
def build_manifest(
*,
root: Path,
revision_dir: Path,
profile: str = "compute",
) -> dict[str, Any]:
paths = RevisionPaths.from_revision_dir(revision_dir)
design = load_yaml(paths.design_yaml)
profile_data = load_profile(paths, profile)
export_dir = export_output_dir(root, paths, profile)
files: dict[str, str] = {}
if export_dir.is_dir():
for path in sorted(export_dir.rglob("*")):
if path.is_file() and not _is_generated_metadata(path):
rel = path.relative_to(export_dir).as_posix()
files[rel] = sha256_file(path)
manifest: dict[str, Any] = {
"schema": "orisys-sensor-design/manifest-v1",
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"design_repo_commit": git_head_commit(root),
"sensor": design.get("sensor"),
"revision": design.get("revision"),
"profile": profile,
"meters_per_source_unit": design.get("meters_per_source_unit"),
"source_blend": str(paths.source_blend.relative_to(root)).replace("\\", "/"),
"export_dir": str(export_dir.relative_to(root)).replace("\\", "/"),
"expected_artifacts": profile_data.get("artifacts", []),
"files": files,
}
if export_dir.is_dir():
manifest["bundle_sha256"] = sha256_directory(export_dir)
return manifest
def write_manifest(path: Path, manifest: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")