initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Git LFS — enable after Gitea LFS_START_SERVER=true on the NAS
|
||||||
|
*.blend filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.blend1 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.exr filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.hdr filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tif filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tiff filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.npz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.venv-*/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/export/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Blender
|
||||||
|
*.blend1
|
||||||
|
*.blend2
|
||||||
|
|
||||||
|
# Renders and caches
|
||||||
|
**/renders/
|
||||||
|
**/.cache/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# orisys-sensor-design
|
||||||
|
|
||||||
|
Nominal sensor geometry and Blender authoring for Orisys. This repo is the **design**
|
||||||
|
source of truth. Measured calibration, unroll maps, and shipped runtime bundles live in
|
||||||
|
`orisys-sdk` and compiled `.oryscene` packages live in `orisys-app`.
|
||||||
|
|
||||||
|
For the full long-term workflow see `sensor-design-workflow.md` in the workspace root.
|
||||||
|
This repository intentionally implements a **minimal v1** for a small team.
|
||||||
|
|
||||||
|
## Two buckets (daily work)
|
||||||
|
|
||||||
|
| Bucket | Where | Contents |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| **Design** | this repo | `.blend`, nominal export, release manifest |
|
||||||
|
| **Runtime** | `orisys-sdk` / `orisys-app` | measured cal, unroll map, wheel/App assets, `.oryscene` |
|
||||||
|
|
||||||
|
Never copy runtime NPZ files into this repo to “fix” a mismatch. Promote through a
|
||||||
|
manifest + lock file instead.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
sensors/finger/
|
||||||
|
├── current.yaml # active revision pointer
|
||||||
|
└── r01/
|
||||||
|
├── design.yaml # units, frames, object names
|
||||||
|
├── source/geometry.blend # LFS: named collections + objects
|
||||||
|
├── profiles/
|
||||||
|
│ ├── compute.yaml # → SDK bundle layout (Studio runtime)
|
||||||
|
│ └── studio.yaml # inputs for orisys-app compile_scene
|
||||||
|
└── golden/ # optional preview + metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- [Git LFS](https://git-lfs.com/) on every client (`git lfs install`)
|
||||||
|
- Blender version pinned in `blender.lock.json` (install separately)
|
||||||
|
|
||||||
|
### Finding Blender on Windows
|
||||||
|
|
||||||
|
The CLI looks for Blender in this order:
|
||||||
|
|
||||||
|
1. Env `ORISYS_BLENDER` or `BLENDER_EXE` (absolute path to `blender.exe`)
|
||||||
|
2. `blender` / `blender.exe` on `PATH`
|
||||||
|
3. Auto-scan `%ProgramFiles%\Blender Foundation\Blender *\blender.exe`
|
||||||
|
(prefers `blender.lock.json` version when installed, otherwise newest)
|
||||||
|
4. Optional `"executable"` in `blender.lock.json` (local only; do not commit machine paths)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ORISYS_BLENDER = "C:\Program Files\Blender Foundation\Blender 5.0\blender.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
Convenience launcher (auto-finds Blender for this process, then exports):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\export-compute.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Install the CLI (editable):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\orisys_ws\orisys-sensor-design
|
||||||
|
python -m pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Three commands for v1:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) Check revision metadata and (optionally) Blender scene conventions
|
||||||
|
orisys-design validate sensors/finger/r01
|
||||||
|
|
||||||
|
# 2) Export nominal geometry for a profile (compute → SDK layout)
|
||||||
|
orisys-design export sensors/finger/r01 --profile compute
|
||||||
|
|
||||||
|
# 3) Write release manifest with file hashes
|
||||||
|
orisys-design manifest sensors/finger/r01
|
||||||
|
```
|
||||||
|
|
||||||
|
Headless Blender export uses a JSON job file (stdlib `json` inside Blender — no
|
||||||
|
PyYAML required). `orisys-design export` writes it automatically from `design.yaml`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
orisys-design export sensors/finger/r01 --profile compute
|
||||||
|
# writes build/export/finger/r01/_export_job.json then runs Blender
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual Blender invocation with the same job:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- `
|
||||||
|
--job D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\_export_job.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional flag overrides (`--output`, `--mesh`, `--cam`, `--origin`) win over job values.
|
||||||
|
|
||||||
|
Export output is grouped by revision:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build/export/finger/r01/
|
||||||
|
├── _export_job.json # Blender input, regenerated by export
|
||||||
|
├── manifest.json # release manifest from `orisys-design manifest`
|
||||||
|
└── compute/ # clean SDK-shaped payload
|
||||||
|
├── mesh.npz
|
||||||
|
├── camera_intrinsics.npz
|
||||||
|
└── world_to_cam.npy
|
||||||
|
```
|
||||||
|
|
||||||
|
Collection check:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
- **`compute`** — `sensing_surface` + `cam_sensor` relative to `origin_sensor`. Writes
|
||||||
|
`mesh.npz`, `camera_intrinsics.npz`, `world_to_cam.npy` (here “world” means the
|
||||||
|
sensor frame). Studio and the wheel build consume this tree via `orisys-sdk` →
|
||||||
|
`assets_dir`.
|
||||||
|
- **`studio`** — documents how to compile a `.oryscene` from the **compute** export
|
||||||
|
plus the measured unroll map in SDK. Observation cameras in Studio are derived from
|
||||||
|
mesh bounds; no Blender `cam_viz` is required.
|
||||||
|
|
||||||
|
## Promotion (SDK / App)
|
||||||
|
|
||||||
|
After export:
|
||||||
|
|
||||||
|
1. Tag this repo, e.g. `finger-r01-assets-v1.0.0`.
|
||||||
|
2. Copy or attach the `build/export/finger/r01/compute/` folder to the SDK asset path
|
||||||
|
(or publish as a release artifact).
|
||||||
|
3. Keep `build/export/finger/r01/manifest.json` beside the payload, and update
|
||||||
|
optional `design-lock.json` in `orisys-sdk` with commit + bundle SHA256.
|
||||||
|
4. Run `orisys-app` `compile_scene.py` using the locked compute export + SDK unroll map.
|
||||||
|
|
||||||
|
Example lock file: `examples/design-lock.json`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Units**: metres at export boundaries. Author in mm if you like; set
|
||||||
|
`meters_per_source_unit: 0.001` in `design.yaml`.
|
||||||
|
- **Collections**: `FRAMES`, `EXPORT_COMPUTE`, `ASSEMBLY`, `DESIGN_SIM`, `EXCLUDE`.
|
||||||
|
- **Objects**: `origin_sensor`, `sensing_surface`, `cam_sensor`.
|
||||||
|
- **Matrix names** must state direction; exported pose file remains `world_to_cam.npy`
|
||||||
|
for SDK compatibility (sensor frame → camera).
|
||||||
|
- **Git LFS**: `*.blend`, large textures, exported `*.npz` in `build/export/`.
|
||||||
|
|
||||||
|
## Git LFS on Gitea (NAS)
|
||||||
|
|
||||||
|
**Short answer: not difficult.** Gitea has built-in LFS; NAS hosting is common. Plan for
|
||||||
|
extra disk on the NAS volume that holds LFS objects, not just the git bare repos.
|
||||||
|
|
||||||
|
### Server (one-time, ~5 minutes)
|
||||||
|
|
||||||
|
1. **Enable LFS in Gitea** — edit `app.ini` (or Docker env):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[server]
|
||||||
|
LFS_START_SERVER = true
|
||||||
|
|
||||||
|
[lfs]
|
||||||
|
PATH = /data/git/lfs
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose equivalent:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- GITEA__server__LFS_START_SERVER=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Point `PATH` at persistent NAS storage (same volume as `/data` is fine).
|
||||||
|
|
||||||
|
2. **Restart Gitea** after changing config.
|
||||||
|
|
||||||
|
3. **Optional**: set `LFS_JWT_SECRET` in Docker/env for stable LFS auth across restarts
|
||||||
|
(generate once, keep in your NAS secrets).
|
||||||
|
|
||||||
|
4. **Reverse proxy**: LFS uploads use HTTP(S) batch API. Ensure your NAS proxy allows
|
||||||
|
large request bodies (nginx `client_max_body_size`, etc.). SSH git push still works
|
||||||
|
for normal objects; LFS file transfer is usually HTTPS unless you enable pure SSH LFS.
|
||||||
|
|
||||||
|
5. **Backup**: include the LFS directory in NAS backup — it is not inside git history.
|
||||||
|
|
||||||
|
Requirements: Git ≥ 2.1.2 on the server (Gitea’s embedded git is enough).
|
||||||
|
|
||||||
|
### Clients (each developer)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git lfs install
|
||||||
|
git clone git@your-nas:team/orisys-sensor-design.git
|
||||||
|
cd orisys-sensor-design
|
||||||
|
git lfs pull
|
||||||
|
```
|
||||||
|
|
||||||
|
This repo’s `.gitattributes` already tracks `*.blend` and large binaries. After
|
||||||
|
`git lfs track`, always commit `.gitattributes`.
|
||||||
|
|
||||||
|
### Typical pitfalls
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| Push succeeds but `.blend` is a tiny pointer file | Run `git lfs install` before commit; re-add file |
|
||||||
|
| LFS upload 413 / timeout | Increase proxy body size and Gitea `[server] LFS_HTTP_TIMEOUT` if needed |
|
||||||
|
| Wrong permissions on NAS volume | Match container `USER_UID` / `USER_GID` to volume owner |
|
||||||
|
| Repo already has large blobs in history | Do not rewrite history casually; use `git lfs migrate` only with team agreement |
|
||||||
|
|
||||||
|
Pure SSH LFS (`LFS_ALLOW_PURE_SSH`) is optional and still has client quirks; HTTPS LFS
|
||||||
|
behind your NAS reverse proxy is the path of least resistance.
|
||||||
|
|
||||||
|
## Phase plan
|
||||||
|
|
||||||
|
| Phase | Goal |
|
||||||
|
|-------|------|
|
||||||
|
| **A** (now) | Repo + LFS + r01 + headless compute export |
|
||||||
|
| **B** | Lock file in SDK/App + CI reproduces export hashes |
|
||||||
|
| **C** (later) | Markers, camera metrics, simulation datasets |
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"blender_version": "4.2.0",
|
||||||
|
"blender_lts": "4.2",
|
||||||
|
"executable": null,
|
||||||
|
"notes": "Pin the same major.minor across the team. Set ORISYS_BLENDER or optional 'executable' to the blender binary if it is not on PATH. Headless export: blender -b <file.blend> -P blender/scripts/export.py -- --output DIR --mesh sensing_surface --cam cam_sensor --origin origin_sensor"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Deprecated interactive export helpers.
|
||||||
|
|
||||||
|
Use the headless script instead:
|
||||||
|
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- \\
|
||||||
|
--output <abs/output_dir> --mesh sensing_surface --cam cam_sensor --origin origin_sensor
|
||||||
|
|
||||||
|
Or: orisys-design export sensors/finger/r01 --profile compute
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise SystemExit(
|
||||||
|
"blender_export.py is deprecated. Use blender/scripts/export.py (headless) "
|
||||||
|
"or: orisys-design export sensors/finger/r01 --profile compute"
|
||||||
|
)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Blender headless nominal compute export (YAML-free).
|
||||||
|
|
||||||
|
The host CLI (orisys-design export) reads design.yaml, writes a JSON job file,
|
||||||
|
and invokes this script. Blender only needs stdlib json — not PyYAML:
|
||||||
|
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- \\
|
||||||
|
--job <abs/build/export/finger/r01/_export_job.json>
|
||||||
|
|
||||||
|
Manual overrides (optional, win over job values):
|
||||||
|
|
||||||
|
--output DIR --mesh sensing_surface --cam cam_sensor --origin origin_sensor
|
||||||
|
|
||||||
|
Writes only the compute payload (mesh.npz, camera_intrinsics.npz, world_to_cam.npy)
|
||||||
|
under the job's output directory. Metadata such as _export_job.json and
|
||||||
|
manifest.json live one level above the payload directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pyright: reportMissingImports=false
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bmesh
|
||||||
|
import bpy
|
||||||
|
import numpy as np
|
||||||
|
from mathutils import Matrix
|
||||||
|
|
||||||
|
_DEFAULT_OBJECTS = {
|
||||||
|
"sensing_surface": "sensing_surface",
|
||||||
|
"cam_sensor": "cam_sensor",
|
||||||
|
"origin_sensor": "origin_sensor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _argv_after_double_dash() -> list[str]:
|
||||||
|
if "--" not in sys.argv:
|
||||||
|
return []
|
||||||
|
return sys.argv[sys.argv.index("--") + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Object:
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if obj is None:
|
||||||
|
raise RuntimeError(f"Object not found: {name!r}")
|
||||||
|
if expected_type is not None and obj.type != expected_type:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Object {name!r} has type {obj.type!r}, expected {expected_type!r}"
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _load_job(path: Path) -> tuple[str | None, dict]:
|
||||||
|
"""Load output path and objects map from a JSON export job."""
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"Export job must be a JSON object: {path}")
|
||||||
|
objects = data.get("objects") or {}
|
||||||
|
if not isinstance(objects, dict):
|
||||||
|
raise ValueError(f"Export job 'objects' must be a mapping: {path}")
|
||||||
|
output = data.get("output")
|
||||||
|
return (str(output) if output else None), objects
|
||||||
|
|
||||||
|
|
||||||
|
def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene):
|
||||||
|
"""Return OpenCV-style K (3x3) and render width/height from Blender camera."""
|
||||||
|
cam = camera_obj.data
|
||||||
|
render = scene.render
|
||||||
|
res_x = int(render.resolution_x)
|
||||||
|
res_y = int(render.resolution_y)
|
||||||
|
|
||||||
|
if cam.sensor_fit == "HORIZONTAL":
|
||||||
|
sensor_width = float(cam.sensor_width)
|
||||||
|
sensor_height = sensor_width / (res_x / res_y)
|
||||||
|
else:
|
||||||
|
sensor_height = float(cam.sensor_height)
|
||||||
|
sensor_width = sensor_height * (res_x / res_y)
|
||||||
|
focal = float(cam.lens)
|
||||||
|
|
||||||
|
pixel_width = sensor_width / res_x
|
||||||
|
pixel_height = sensor_height / res_y
|
||||||
|
fx = focal / pixel_width
|
||||||
|
fy = focal / pixel_height
|
||||||
|
cx = res_x / 2.0
|
||||||
|
cy = res_y / 2.0
|
||||||
|
|
||||||
|
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=np.float64)
|
||||||
|
return K, res_x, res_y
|
||||||
|
|
||||||
|
|
||||||
|
def _triangulated_mesh(
|
||||||
|
obj: bpy.types.Object,
|
||||||
|
) -> tuple[bpy.types.Mesh, Matrix, object]:
|
||||||
|
"""Return evaluated triangulated mesh, world matrix, and cleanup callback."""
|
||||||
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||||
|
eval_obj = obj.evaluated_get(depsgraph)
|
||||||
|
mesh = eval_obj.to_mesh()
|
||||||
|
world = eval_obj.matrix_world.copy()
|
||||||
|
bm = bmesh.new()
|
||||||
|
try:
|
||||||
|
bm.from_mesh(mesh)
|
||||||
|
bmesh.ops.triangulate(bm, faces=bm.faces[:])
|
||||||
|
bm.to_mesh(mesh)
|
||||||
|
finally:
|
||||||
|
bm.free()
|
||||||
|
mesh.update()
|
||||||
|
|
||||||
|
def _cleanup() -> None:
|
||||||
|
eval_obj.to_mesh_clear()
|
||||||
|
|
||||||
|
return mesh, world, _cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def export_mesh_in_sensor_frame(
|
||||||
|
obj: bpy.types.Object,
|
||||||
|
t_sensor_from_world: Matrix,
|
||||||
|
filepath: Path,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Export triangulated mesh vertices in the sensor frame."""
|
||||||
|
mesh, world, cleanup = _triangulated_mesh(obj)
|
||||||
|
try:
|
||||||
|
if not mesh.uv_layers.active:
|
||||||
|
raise RuntimeError(f"Mesh {obj.name!r} has no active UV layer")
|
||||||
|
|
||||||
|
t_sensor_from_object = t_sensor_from_world @ world
|
||||||
|
|
||||||
|
vertices = np.array(
|
||||||
|
[t_sensor_from_object @ v.co for v in mesh.vertices],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
faces_list = [tuple(poly.vertices) for poly in mesh.polygons]
|
||||||
|
if not faces_list:
|
||||||
|
raise RuntimeError(f"Mesh {obj.name!r} has no faces")
|
||||||
|
if any(len(face) != 3 for face in faces_list):
|
||||||
|
raise RuntimeError(f"Mesh {obj.name!r} is not fully triangulated after export")
|
||||||
|
faces = np.asarray(faces_list, dtype=np.int32)
|
||||||
|
|
||||||
|
uv_layer = mesh.uv_layers.active.data
|
||||||
|
uv_data = np.array([uv_layer[i].uv[:] for i in range(len(uv_layer))], dtype=np.float32)
|
||||||
|
loop_to_vertex = np.array(
|
||||||
|
[mesh.loops[i].vertex_index for i in range(len(mesh.loops))],
|
||||||
|
dtype=np.int32,
|
||||||
|
)
|
||||||
|
|
||||||
|
np.savez_compressed(
|
||||||
|
filepath,
|
||||||
|
vertices=vertices,
|
||||||
|
faces=faces,
|
||||||
|
uv_data=uv_data,
|
||||||
|
loop_to_vertex=loop_to_vertex,
|
||||||
|
)
|
||||||
|
print(f"Mesh exported to {filepath} ({len(vertices)} verts, {len(faces)} tris)")
|
||||||
|
return int(len(vertices)), int(len(faces))
|
||||||
|
finally:
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def export_camera_pose_sensor_frame(
|
||||||
|
camera_obj: bpy.types.Object,
|
||||||
|
t_sensor_from_world: Matrix,
|
||||||
|
filepath: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Save world_to_cam.npy where 'world' means the sensor frame (origin_sensor)."""
|
||||||
|
t_sensor_from_cam = t_sensor_from_world @ camera_obj.matrix_world
|
||||||
|
cam_from_sensor = np.linalg.inv(np.array(t_sensor_from_cam, dtype=np.float64))
|
||||||
|
np.save(filepath, cam_from_sensor.astype(np.float32))
|
||||||
|
print(f"Camera pose exported to {filepath}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(prog="export.py")
|
||||||
|
parser.add_argument(
|
||||||
|
"--job",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="JSON job written by orisys-design (output + object names)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Absolute output directory (overrides job)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mesh",
|
||||||
|
default=None,
|
||||||
|
help="Sensing surface mesh object name (overrides job)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cam",
|
||||||
|
default=None,
|
||||||
|
help="Sensor camera object name (overrides job)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--origin",
|
||||||
|
default=None,
|
||||||
|
help="Sensor-frame empty object name (overrides job)",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]:
|
||||||
|
"""Merge --job JSON with explicit flag overrides. Explicit flags win."""
|
||||||
|
job_output: str | None = None
|
||||||
|
job_objects: dict = {}
|
||||||
|
if args.job is not None:
|
||||||
|
job_path = args.job.resolve()
|
||||||
|
if not job_path.is_file():
|
||||||
|
raise RuntimeError(f"Export job not found: {job_path}")
|
||||||
|
job_output, job_objects = _load_job(job_path)
|
||||||
|
|
||||||
|
output_raw = args.output or job_output
|
||||||
|
if not output_raw:
|
||||||
|
raise RuntimeError("Provide --job <path.json> and/or --output <dir>")
|
||||||
|
|
||||||
|
mesh = (
|
||||||
|
args.mesh
|
||||||
|
or job_objects.get("sensing_surface")
|
||||||
|
or _DEFAULT_OBJECTS["sensing_surface"]
|
||||||
|
)
|
||||||
|
cam = args.cam or job_objects.get("cam_sensor") or _DEFAULT_OBJECTS["cam_sensor"]
|
||||||
|
origin = (
|
||||||
|
args.origin
|
||||||
|
or job_objects.get("origin_sensor")
|
||||||
|
or _DEFAULT_OBJECTS["origin_sensor"]
|
||||||
|
)
|
||||||
|
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args(_argv_after_double_dash())
|
||||||
|
try:
|
||||||
|
output_dir, mesh_name, cam_name, origin_name = _resolve_config(args)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mesh_obj = _require_object(mesh_name, "MESH")
|
||||||
|
cam_obj = _require_object(cam_name, "CAMERA")
|
||||||
|
origin_obj = _require_object(origin_name)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
t_sensor_from_world = origin_obj.matrix_world.inverted()
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
mesh_path = output_dir / "mesh.npz"
|
||||||
|
intrinsics_path = output_dir / "camera_intrinsics.npz"
|
||||||
|
pose_path = output_dir / "world_to_cam.npy"
|
||||||
|
|
||||||
|
try:
|
||||||
|
export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path)
|
||||||
|
K, width, height = export_camera_intrinsics(cam_obj, scene)
|
||||||
|
np.savez(
|
||||||
|
intrinsics_path,
|
||||||
|
K=K,
|
||||||
|
width=np.int32(width),
|
||||||
|
height=np.int32(height),
|
||||||
|
)
|
||||||
|
print(f"Intrinsics exported to {intrinsics_path} ({width}x{height})")
|
||||||
|
export_camera_pose_sensor_frame(cam_obj, t_sensor_from_world, pose_path)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: export failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Export complete: {output_dir}")
|
||||||
|
print(f" {mesh_path.name}")
|
||||||
|
print(f" {intrinsics_path.name}")
|
||||||
|
print(f" {pose_path.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,917 @@
|
|||||||
|
"""
|
||||||
|
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
|
||||||
|
- Mode: "patches" (colored patches), "checker" (black/white patches),
|
||||||
|
or "dots" (circular markers)
|
||||||
|
- Mesh UVs are filled white so that non‑visible parts become white.
|
||||||
|
- Texture background (no‑mesh areas) stays black.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Common Utilities
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||||
|
"""
|
||||||
|
Draw a cross (plus sign) in pixel space.
|
||||||
|
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
|
||||||
|
length_px: total length of each arm (pixels).
|
||||||
|
thickness_px: line thickness (odd number gives symmetric result).
|
||||||
|
"""
|
||||||
|
# Convert UV centre to pixel coordinates (y from bottom)
|
||||||
|
cx = int(round(cx_uv * (width - 1)))
|
||||||
|
cy = int(round(cy_uv * (height - 1)))
|
||||||
|
|
||||||
|
half_len = length_px // 2
|
||||||
|
half_thick = thickness_px // 2
|
||||||
|
|
||||||
|
# Horizontal arm
|
||||||
|
x0 = max(0, cx - half_len)
|
||||||
|
x1 = min(width - 1, cx + half_len)
|
||||||
|
y0 = max(0, cy - half_thick)
|
||||||
|
y1 = min(height - 1, cy + half_thick)
|
||||||
|
for y in range(y0, y1 + 1):
|
||||||
|
for x in range(x0, x1 + 1):
|
||||||
|
idx = (y * width + x) * 4
|
||||||
|
pixels[idx:idx+4] = color
|
||||||
|
|
||||||
|
# Vertical arm
|
||||||
|
x0 = max(0, cx - half_thick)
|
||||||
|
x1 = min(width - 1, cx + half_thick)
|
||||||
|
y0 = max(0, cy - half_len)
|
||||||
|
y1 = min(height - 1, cy + half_len)
|
||||||
|
for y in range(y0, y1 + 1):
|
||||||
|
for x in range(x0, x1 + 1):
|
||||||
|
idx = (y * width + x) * 4
|
||||||
|
pixels[idx:idx+4] = color
|
||||||
|
|
||||||
|
def fill_background(pixels, width, height, color):
|
||||||
|
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
|
||||||
|
r, g, b, a = color
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
idx = (y * width + x) * 4
|
||||||
|
pixels[idx] = r
|
||||||
|
pixels[idx+1] = g
|
||||||
|
pixels[idx+2] = b
|
||||||
|
pixels[idx+3] = a
|
||||||
|
|
||||||
|
def fill_polygon(image, pixels, width, height, polygon, color):
|
||||||
|
"""Fill a polygon in the pixel array (flattened, RGBA)."""
|
||||||
|
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):
|
||||||
|
intersections = []
|
||||||
|
for i in range(len(poly)):
|
||||||
|
x1, y1 = poly[i]
|
||||||
|
x2, y2 = poly[(i+1) % len(poly)]
|
||||||
|
if y1 == y2:
|
||||||
|
continue
|
||||||
|
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||||
|
t = (y - y1) / (y2 - y1)
|
||||||
|
x = x1 + t * (x2 - x1)
|
||||||
|
intersections.append(x)
|
||||||
|
intersections.sort()
|
||||||
|
for k in range(0, len(intersections), 2):
|
||||||
|
if k+1 >= len(intersections):
|
||||||
|
break
|
||||||
|
x_start = int(round(intersections[k]))
|
||||||
|
x_end = int(round(intersections[k+1]))
|
||||||
|
x_start = max(0, min(width-1, x_start))
|
||||||
|
x_end = max(0, min(width-1, x_end))
|
||||||
|
for x in range(x_start, x_end + 1):
|
||||||
|
idx = (y * width + x) * 4
|
||||||
|
pixels[idx:idx+4] = color
|
||||||
|
|
||||||
|
def fill_polygon_value(grid, width, height, polygon, value):
|
||||||
|
"""Fill a polygon into a 2D scalar grid."""
|
||||||
|
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):
|
||||||
|
intersections = []
|
||||||
|
for i in range(len(poly)):
|
||||||
|
x1, y1 = poly[i]
|
||||||
|
x2, y2 = poly[(i + 1) % len(poly)]
|
||||||
|
if y1 == y2:
|
||||||
|
continue
|
||||||
|
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||||
|
t = (y - y1) / (y2 - y1)
|
||||||
|
x = x1 + t * (x2 - x1)
|
||||||
|
intersections.append(x)
|
||||||
|
intersections.sort()
|
||||||
|
for k in range(0, len(intersections), 2):
|
||||||
|
if k + 1 >= len(intersections):
|
||||||
|
break
|
||||||
|
x_start = int(round(intersections[k]))
|
||||||
|
x_end = int(round(intersections[k + 1]))
|
||||||
|
x_start = max(0, min(width - 1, x_start))
|
||||||
|
x_end = max(0, min(width - 1, x_end))
|
||||||
|
grid[y, x_start:x_end + 1] = value
|
||||||
|
|
||||||
|
def unwrap_periodic_values(values):
|
||||||
|
"""
|
||||||
|
Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
|
||||||
|
"""
|
||||||
|
arr = np.asarray(values, dtype=np.float64).copy()
|
||||||
|
if arr.size == 0:
|
||||||
|
return arr
|
||||||
|
if arr.max() - arr.min() > 0.5:
|
||||||
|
arr[arr < 0.5] += 1.0
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def wrap_uv_polygon_for_raster(uv_poly):
|
||||||
|
"""
|
||||||
|
Return a seam-aware version of a UV polygon.
|
||||||
|
|
||||||
|
UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
|
||||||
|
single polygon, Blender's repeated UVs turn into a long diagonal triangle
|
||||||
|
across the texture. We unwrap seam-crossing polygons first, then draw wrapped
|
||||||
|
copies in the texture tile(s) they overlap.
|
||||||
|
"""
|
||||||
|
if len(uv_poly) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
|
||||||
|
uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
|
||||||
|
uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
|
||||||
|
return [tuple(pt) for pt in uv_arr]
|
||||||
|
|
||||||
|
def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||||||
|
"""
|
||||||
|
Convert one UV polygon into one or more pixel polygons, duplicating seam-
|
||||||
|
crossing polygons into adjacent tiles so rasterization stays local.
|
||||||
|
"""
|
||||||
|
unwrapped = wrap_uv_polygon_for_raster(uv_poly)
|
||||||
|
if len(unwrapped) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
u_vals = [u for u, _ in unwrapped]
|
||||||
|
v_vals = [v for _, v in unwrapped]
|
||||||
|
u_shift_start = int(np.floor(min(u_vals)))
|
||||||
|
u_shift_end = int(np.floor(max(u_vals)))
|
||||||
|
v_shift_start = int(np.floor(min(v_vals)))
|
||||||
|
v_shift_end = int(np.floor(max(v_vals)))
|
||||||
|
|
||||||
|
pixel_polys = []
|
||||||
|
for u_shift in range(u_shift_start, u_shift_end + 1):
|
||||||
|
for v_shift in range(v_shift_start, v_shift_end + 1):
|
||||||
|
shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
|
||||||
|
shifted_u = [u for u, _ in shifted]
|
||||||
|
shifted_v = [v for _, v in shifted]
|
||||||
|
if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
|
||||||
|
continue
|
||||||
|
if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
|
||||||
|
continue
|
||||||
|
pixel_polys.append([
|
||||||
|
(u * (tex_width - 1), v * (tex_height - 1))
|
||||||
|
for u, v in shifted
|
||||||
|
])
|
||||||
|
return pixel_polys
|
||||||
|
|
||||||
|
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
||||||
|
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
|
||||||
|
cos_a = np.cos(angle)
|
||||||
|
sin_a = np.sin(angle)
|
||||||
|
rx = abs(a * cos_a) + abs(b * sin_a)
|
||||||
|
ry = abs(a * sin_a) + abs(b * cos_a)
|
||||||
|
x_min = int(max(0, cx - rx))
|
||||||
|
x_max = int(min(width-1, cx + rx))
|
||||||
|
y_min = int(max(0, cy - ry))
|
||||||
|
y_max = int(min(height-1, cy + ry))
|
||||||
|
for y in range(y_min, y_max + 1):
|
||||||
|
for x in range(x_min, x_max + 1):
|
||||||
|
dx = x - cx
|
||||||
|
dy = y - cy
|
||||||
|
u = dx * cos_a + dy * sin_a
|
||||||
|
v = -dx * sin_a + dy * cos_a
|
||||||
|
if (u/a)**2 + (v/b)**2 <= 1.0:
|
||||||
|
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."""
|
||||||
|
me = obj.data
|
||||||
|
local_loc = obj.matrix_world.inverted() @ location
|
||||||
|
poly = me.polygons[face_index]
|
||||||
|
vert_indices = poly.vertices
|
||||||
|
loop_indices = poly.loop_indices
|
||||||
|
if len(vert_indices) < 3:
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
def tri_area(a, b, c):
|
||||||
|
return 0.5 * ((b - a).cross(c - a)).length
|
||||||
|
total_area = tri_area(v0, v1, v2)
|
||||||
|
if total_area == 0:
|
||||||
|
return None
|
||||||
|
w0 = tri_area(local_loc, v1, v2) / total_area
|
||||||
|
w1 = tri_area(local_loc, v2, v0) / total_area
|
||||||
|
w2 = 1.0 - w0 - w1
|
||||||
|
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
|
||||||
|
return Vector((uv.x, uv.y))
|
||||||
|
|
||||||
|
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||||||
|
"""Camera raycast through normalised sensor coordinates (0‑1)."""
|
||||||
|
sx = (u_ndc - 0.5) * frame_width
|
||||||
|
sy = (v_ndc - 0.5) * frame_height
|
||||||
|
direction_cam = Vector((sx, sy, -1.0))
|
||||||
|
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
|
||||||
|
direction_world.normalize()
|
||||||
|
origin = cam.matrix_world.translation
|
||||||
|
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
|
||||||
|
depsgraph, origin, direction_world)
|
||||||
|
if hit and hit_obj == obj:
|
||||||
|
return location, face_index
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
def convex_hull_uv(points):
|
||||||
|
"""Return UV points ordered around their 2D convex hull."""
|
||||||
|
unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
|
||||||
|
unique = sorted(set(unwrapped))
|
||||||
|
if len(unique) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cross(o, a, b):
|
||||||
|
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||||
|
|
||||||
|
lower = []
|
||||||
|
for p in unique:
|
||||||
|
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
|
||||||
|
lower.pop()
|
||||||
|
lower.append(p)
|
||||||
|
|
||||||
|
upper = []
|
||||||
|
for p in reversed(unique):
|
||||||
|
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
|
||||||
|
upper.pop()
|
||||||
|
upper.append(p)
|
||||||
|
|
||||||
|
hull = lower[:-1] + upper[:-1]
|
||||||
|
if len(hull) < 3:
|
||||||
|
return None
|
||||||
|
return [Vector((u, v)) for u, v in hull]
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Build a UV polygon for one camera-space patch.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
corners_ndc = [
|
||||||
|
(u_start, v_start),
|
||||||
|
(u_end, v_start),
|
||||||
|
(u_end, v_end),
|
||||||
|
(u_start, v_end),
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if uv is not None:
|
||||||
|
corner_uvs.append(uv)
|
||||||
|
|
||||||
|
if len(corner_uvs) >= 3:
|
||||||
|
return corner_uvs
|
||||||
|
|
||||||
|
sampled_uvs = list(corner_uvs)
|
||||||
|
for sy in range(edge_sample_grid):
|
||||||
|
v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)):
|
||||||
|
"""
|
||||||
|
Fill all triangles of the active UV map with 'color' (default white).
|
||||||
|
This marks every part of the mesh, visible or not, as white.
|
||||||
|
"""
|
||||||
|
me = obj.data
|
||||||
|
uv_layer = me.uv_layers.active
|
||||||
|
if not uv_layer:
|
||||||
|
print("Warning: No active UV layer – mesh rasterization skipped.")
|
||||||
|
return
|
||||||
|
uv_data = uv_layer.data
|
||||||
|
for poly in me.polygons:
|
||||||
|
# Get loop indices for this polygon
|
||||||
|
loops = poly.loop_indices
|
||||||
|
# Build UV polygon
|
||||||
|
uv_poly = []
|
||||||
|
for loop_idx in loops:
|
||||||
|
uv = uv_data[loop_idx].uv
|
||||||
|
uv_poly.append((uv.x, uv.y))
|
||||||
|
# Convert UV (0‑1) to pixel coordinates
|
||||||
|
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||||||
|
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||||
|
|
||||||
|
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
||||||
|
"""Rasterize the active UV map to a boolean mesh mask."""
|
||||||
|
mask = np.zeros((tex_height, tex_width), dtype=bool)
|
||||||
|
me = obj.data
|
||||||
|
uv_layer = me.uv_layers.active
|
||||||
|
if not uv_layer:
|
||||||
|
return mask
|
||||||
|
uv_data = uv_layer.data
|
||||||
|
for poly in me.polygons:
|
||||||
|
uv_poly = []
|
||||||
|
for loop_idx in poly.loop_indices:
|
||||||
|
uv = uv_data[loop_idx].uv
|
||||||
|
uv_poly.append((uv.x, uv.y))
|
||||||
|
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
|
||||||
|
fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
def paint_mask_color(pixels, width, height, mask, color):
|
||||||
|
"""Paint all True pixels of a boolean mask with an RGBA color."""
|
||||||
|
r, g, b, a = color
|
||||||
|
ys, xs = np.nonzero(mask)
|
||||||
|
for y, x in zip(ys, xs):
|
||||||
|
idx = (y * width + x) * 4
|
||||||
|
pixels[idx:idx+4] = (r, g, b, a)
|
||||||
|
|
||||||
|
def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
|
||||||
|
"""
|
||||||
|
Fill small white slivers inside the mesh by expanding nearby checker colors.
|
||||||
|
|
||||||
|
`patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
|
||||||
|
"""
|
||||||
|
for _ in range(max_gap_px):
|
||||||
|
candidate = mesh_mask & (patch_state < 0)
|
||||||
|
if not np.any(candidate):
|
||||||
|
break
|
||||||
|
|
||||||
|
black_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||||||
|
white_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||||||
|
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
if dx == 0 and dy == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
src_y0 = max(0, -dy)
|
||||||
|
src_y1 = height - max(0, dy)
|
||||||
|
src_x0 = max(0, -dx)
|
||||||
|
src_x1 = width - max(0, dx)
|
||||||
|
dst_y0 = max(0, dy)
|
||||||
|
dst_y1 = height - max(0, -dy)
|
||||||
|
dst_x0 = max(0, dx)
|
||||||
|
dst_x1 = width - max(0, -dx)
|
||||||
|
|
||||||
|
neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
|
||||||
|
black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
|
||||||
|
white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
|
||||||
|
|
||||||
|
fillable = candidate & ((black_counts + white_counts) > 0)
|
||||||
|
if not np.any(fillable):
|
||||||
|
break
|
||||||
|
|
||||||
|
patch_state[fillable] = np.where(
|
||||||
|
black_counts[fillable] > white_counts[fillable], 0, 1
|
||||||
|
).astype(np.int8)
|
||||||
|
|
||||||
|
paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
|
||||||
|
paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Color generation (for patch mode)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def merge_intervals(intervals):
|
||||||
|
if not intervals: return []
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
merged = [intervals[0]]
|
||||||
|
for current in intervals[1:]:
|
||||||
|
last = merged[-1]
|
||||||
|
if current[0] <= last[1]:
|
||||||
|
merged[-1] = (last[0], max(last[1], current[1]))
|
||||||
|
else:
|
||||||
|
merged.append(current)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def get_allowed_intervals(neighbor_values, delta):
|
||||||
|
forbidden = []
|
||||||
|
for c in neighbor_values:
|
||||||
|
low = max(0, c - delta)
|
||||||
|
high = min(1, c + delta)
|
||||||
|
forbidden.append((low, high))
|
||||||
|
merged = merge_intervals(forbidden)
|
||||||
|
allowed = []
|
||||||
|
if merged:
|
||||||
|
if merged[0][0] > 0:
|
||||||
|
allowed.append((0, merged[0][0]))
|
||||||
|
for i in range(1, len(merged)):
|
||||||
|
allowed.append((merged[i-1][1], merged[i][0]))
|
||||||
|
if merged[-1][1] < 1:
|
||||||
|
allowed.append((merged[-1][1], 1))
|
||||||
|
else:
|
||||||
|
allowed.append((0, 1))
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
def sample_from_allowed(allowed):
|
||||||
|
lengths = [end - start for start, end in allowed]
|
||||||
|
total_length = sum(lengths)
|
||||||
|
if total_length <= 0: return None
|
||||||
|
point = np.random.rand() * total_length
|
||||||
|
cumulative = 0
|
||||||
|
for (start, end), length in zip(allowed, lengths):
|
||||||
|
cumulative += length
|
||||||
|
if point <= cumulative:
|
||||||
|
return np.random.uniform(start, end)
|
||||||
|
return allowed[-1][0]
|
||||||
|
|
||||||
|
def sample_channel(neighbor_values, delta):
|
||||||
|
allowed = get_allowed_intervals(neighbor_values, delta)
|
||||||
|
if not allowed: return None
|
||||||
|
return sample_from_allowed(allowed)
|
||||||
|
|
||||||
|
def generate_valid_color(neighbors, r):
|
||||||
|
delta = np.sqrt(r)
|
||||||
|
color = np.zeros(3)
|
||||||
|
for channel in range(3):
|
||||||
|
neighbor_channel = [n[channel] for n in neighbors]
|
||||||
|
sampled = sample_channel(neighbor_channel, delta)
|
||||||
|
if sampled is None:
|
||||||
|
sampled = np.random.rand()
|
||||||
|
color[channel] = sampled
|
||||||
|
return color
|
||||||
|
|
||||||
|
def checker_patch_color(i, j):
|
||||||
|
"""Return a black/white checker color for patch index (i, j)."""
|
||||||
|
if (i + j) % 2 == 0:
|
||||||
|
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
|
||||||
|
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Mode 1: Colored Patches
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def generate_patch_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
nx=40, ny=30, r=0.05,
|
||||||
|
texture_width=2048, texture_height=2048,
|
||||||
|
output_path="//patch_texture.png", uv_centre=None, cross_length=40,
|
||||||
|
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
|
||||||
|
):
|
||||||
|
cam_data = cam.data
|
||||||
|
render = scene.render
|
||||||
|
res_x = render.resolution_x
|
||||||
|
res_y = render.resolution_y
|
||||||
|
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||||
|
sensor_width = cam_data.sensor_width
|
||||||
|
sensor_height = sensor_width / (res_x / res_y)
|
||||||
|
else:
|
||||||
|
sensor_height = cam_data.sensor_height
|
||||||
|
sensor_width = sensor_height * (res_x / res_y)
|
||||||
|
focal = cam_data.lens
|
||||||
|
frame_width = sensor_width / focal
|
||||||
|
frame_height = sensor_height / focal
|
||||||
|
|
||||||
|
edge_sample_grid = max(2, int(edge_sample_grid))
|
||||||
|
patch_uv_polygons = []
|
||||||
|
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||||||
|
skipped_patches = 0
|
||||||
|
|
||||||
|
print(f"Raycasting {nx*ny} patches...")
|
||||||
|
for j in range(ny):
|
||||||
|
v_start = j / ny
|
||||||
|
v_end = (j+1) / ny
|
||||||
|
for i in range(nx):
|
||||||
|
u_start = i / nx
|
||||||
|
u_end = (i+1) / nx
|
||||||
|
uv_poly = sample_patch_uv_polygon(
|
||||||
|
scene, depsgraph, cam, obj,
|
||||||
|
u_start, u_end, v_start, v_end,
|
||||||
|
frame_width, frame_height,
|
||||||
|
edge_sample_grid=edge_sample_grid
|
||||||
|
)
|
||||||
|
if uv_poly is None:
|
||||||
|
patch_uv_polygons.append(None)
|
||||||
|
skipped_patches += 1
|
||||||
|
continue
|
||||||
|
patch_uv_polygons.append(uv_poly)
|
||||||
|
|
||||||
|
neighbors = []
|
||||||
|
if j > 0: neighbors.append(patch_colors[j-1, i])
|
||||||
|
if i > 0: neighbors.append(patch_colors[j, i-1])
|
||||||
|
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
|
||||||
|
patch_colors[j, i] = color
|
||||||
|
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
|
||||||
|
|
||||||
|
# Create image – start with black background
|
||||||
|
img_name = "PatchTexture"
|
||||||
|
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)
|
||||||
|
pixels = list(image.pixels)
|
||||||
|
|
||||||
|
# Fill entire image with black (no mesh areas)
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Now draw the visible patches on top
|
||||||
|
idx = 0
|
||||||
|
for j in range(ny):
|
||||||
|
for i in range(nx):
|
||||||
|
uv_poly = patch_uv_polygons[idx]; idx += 1
|
||||||
|
if uv_poly is None: continue
|
||||||
|
color = patch_colors[j,i]
|
||||||
|
rgba = (color[0], color[1], color[2], 1.0)
|
||||||
|
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
||||||
|
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||||
|
|
||||||
|
# (after drawing all patches)
|
||||||
|
if uv_centre is not None:
|
||||||
|
draw_cross(pixels, texture_width, texture_height,
|
||||||
|
uv_centre[0], uv_centre[1],
|
||||||
|
cross_length, cross_thickness, cross_color)
|
||||||
|
|
||||||
|
image.pixels[:] = pixels
|
||||||
|
image.update()
|
||||||
|
abs_path = bpy.path.abspath(output_path)
|
||||||
|
image.filepath_raw = abs_path
|
||||||
|
image.file_format = 'PNG'
|
||||||
|
image.save()
|
||||||
|
print(f"Patch texture saved to {abs_path}")
|
||||||
|
return image
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Mode 2: Black/White Checker Patches
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def generate_checker_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
nx=40, ny=40,
|
||||||
|
texture_width=2048, texture_height=2048,
|
||||||
|
output_path="//checker_texture.png", uv_centre=None, cross_length=40,
|
||||||
|
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||||
|
gap_fill_px=6
|
||||||
|
):
|
||||||
|
cam_data = cam.data
|
||||||
|
render = scene.render
|
||||||
|
res_x = render.resolution_x
|
||||||
|
res_y = render.resolution_y
|
||||||
|
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||||
|
sensor_width = cam_data.sensor_width
|
||||||
|
sensor_height = sensor_width / (res_x / res_y)
|
||||||
|
else:
|
||||||
|
sensor_height = cam_data.sensor_height
|
||||||
|
sensor_width = sensor_height * (res_x / res_y)
|
||||||
|
focal = cam_data.lens
|
||||||
|
frame_width = sensor_width / focal
|
||||||
|
frame_height = sensor_height / focal
|
||||||
|
|
||||||
|
edge_sample_grid = max(2, int(edge_sample_grid))
|
||||||
|
patch_uv_polygons = []
|
||||||
|
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||||||
|
skipped_patches = 0
|
||||||
|
|
||||||
|
print(f"Raycasting {nx*ny} checker patches...")
|
||||||
|
for j in range(ny):
|
||||||
|
v_start = j / ny
|
||||||
|
v_end = (j+1) / ny
|
||||||
|
for i in range(nx):
|
||||||
|
u_start = i / nx
|
||||||
|
u_end = (i+1) / nx
|
||||||
|
uv_poly = sample_patch_uv_polygon(
|
||||||
|
scene, depsgraph, cam, obj,
|
||||||
|
u_start, u_end, v_start, v_end,
|
||||||
|
frame_width, frame_height,
|
||||||
|
edge_sample_grid=edge_sample_grid
|
||||||
|
)
|
||||||
|
if uv_poly is None:
|
||||||
|
patch_uv_polygons.append(None)
|
||||||
|
skipped_patches += 1
|
||||||
|
continue
|
||||||
|
patch_uv_polygons.append(uv_poly)
|
||||||
|
patch_colors[j, i] = checker_patch_color(i, j)
|
||||||
|
print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
|
||||||
|
|
||||||
|
img_name = "CheckerTexture"
|
||||||
|
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)
|
||||||
|
pixels = list(image.pixels)
|
||||||
|
mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
|
||||||
|
patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
|
||||||
|
|
||||||
|
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||||
|
paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
for j in range(ny):
|
||||||
|
for i in range(nx):
|
||||||
|
uv_poly = patch_uv_polygons[idx]; idx += 1
|
||||||
|
if uv_poly is None:
|
||||||
|
continue
|
||||||
|
color = patch_colors[j,i]
|
||||||
|
rgba = (color[0], color[1], color[2], 1.0)
|
||||||
|
patch_value = 1 if np.mean(color) >= 0.5 else 0
|
||||||
|
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
||||||
|
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||||
|
fill_polygon_value(
|
||||||
|
patch_state,
|
||||||
|
texture_width,
|
||||||
|
texture_height,
|
||||||
|
pixel_poly,
|
||||||
|
patch_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
complete_checker_gaps(
|
||||||
|
pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
|
||||||
|
)
|
||||||
|
|
||||||
|
if uv_centre is not None:
|
||||||
|
draw_cross(pixels, texture_width, texture_height,
|
||||||
|
uv_centre[0], uv_centre[1],
|
||||||
|
cross_length, cross_thickness, cross_color)
|
||||||
|
|
||||||
|
image.pixels[:] = pixels
|
||||||
|
image.update()
|
||||||
|
abs_path = bpy.path.abspath(output_path)
|
||||||
|
image.filepath_raw = abs_path
|
||||||
|
image.file_format = 'PNG'
|
||||||
|
image.save()
|
||||||
|
print(f"Checker texture saved to {abs_path}")
|
||||||
|
return image
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Mode 3: Circular Dot Markers
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def generate_dot_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
dot_diameter_pixels=12,
|
||||||
|
spacing_pixels=40,
|
||||||
|
dot_color=(0,0,0,1), # RGBA 0-1
|
||||||
|
texture_width=2048, texture_height=2048,
|
||||||
|
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
|
||||||
|
):
|
||||||
|
render = scene.render
|
||||||
|
res_x = render.resolution_x
|
||||||
|
res_y = render.resolution_y
|
||||||
|
cam_data = cam.data
|
||||||
|
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||||
|
sensor_width = cam_data.sensor_width
|
||||||
|
sensor_height = sensor_width / (res_x / res_y)
|
||||||
|
else:
|
||||||
|
sensor_height = cam_data.sensor_height
|
||||||
|
sensor_width = sensor_height * (res_x / res_y)
|
||||||
|
focal = cam_data.lens
|
||||||
|
frame_width = sensor_width / focal
|
||||||
|
frame_height = sensor_height / focal
|
||||||
|
|
||||||
|
margin = spacing_pixels // 2
|
||||||
|
x_centers = np.arange(margin, res_x, spacing_pixels)
|
||||||
|
y_centers = np.arange(margin, res_y, spacing_pixels)
|
||||||
|
offset_pix = max(2, dot_diameter_pixels // 4)
|
||||||
|
du_ndc = offset_pix / res_x
|
||||||
|
dv_ndc = offset_pix / res_y
|
||||||
|
|
||||||
|
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
|
||||||
|
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
|
||||||
|
for y_pix in y_centers:
|
||||||
|
v_ndc = y_pix / res_y
|
||||||
|
for x_pix in x_centers:
|
||||||
|
u_ndc = x_pix / res_x
|
||||||
|
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||||
|
u_ndc, v_ndc, frame_width, frame_height)
|
||||||
|
if center_loc is None: continue
|
||||||
|
center_uv = get_uv_from_face(obj, face_idx, center_loc)
|
||||||
|
if center_uv is None: continue
|
||||||
|
|
||||||
|
offsets = [
|
||||||
|
( u_ndc + du_ndc, v_ndc),
|
||||||
|
( u_ndc - du_ndc, v_ndc),
|
||||||
|
( u_ndc, v_ndc + dv_ndc),
|
||||||
|
( u_ndc, v_ndc - dv_ndc)
|
||||||
|
]
|
||||||
|
uv_offsets = []
|
||||||
|
for (u_off, v_off) in offsets:
|
||||||
|
uv_off = raycast_uv_from_camera(
|
||||||
|
scene, depsgraph, cam, obj,
|
||||||
|
u_off, v_off, frame_width, frame_height
|
||||||
|
)
|
||||||
|
uv_offsets.append(uv_off)
|
||||||
|
|
||||||
|
scale_u = 1.0 / res_x
|
||||||
|
scale_v = 1.0 / res_y
|
||||||
|
r_pix = dot_diameter_pixels / 2.0
|
||||||
|
|
||||||
|
right_uv, left_uv, up_uv, down_uv = uv_offsets
|
||||||
|
if right_uv is not None and left_uv is not None:
|
||||||
|
du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
|
||||||
|
a_uv = r_pix * du_dx.length * scale_u
|
||||||
|
elif right_uv is not None:
|
||||||
|
du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
|
||||||
|
a_uv = r_pix * du_dx.length * scale_u
|
||||||
|
elif left_uv is not None:
|
||||||
|
du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
|
||||||
|
a_uv = r_pix * du_dx.length * scale_u
|
||||||
|
else:
|
||||||
|
du_dx = Vector((1.0, 0.0))
|
||||||
|
a_uv = r_pix / texture_width
|
||||||
|
|
||||||
|
if up_uv is not None and down_uv is not None:
|
||||||
|
dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
|
||||||
|
b_uv = r_pix * dv_dx.length * scale_v
|
||||||
|
elif up_uv is not None:
|
||||||
|
dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
|
||||||
|
b_uv = r_pix * dv_dx.length * scale_v
|
||||||
|
elif down_uv is not None:
|
||||||
|
dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
|
||||||
|
b_uv = r_pix * dv_dx.length * scale_v
|
||||||
|
else:
|
||||||
|
b_uv = r_pix / texture_height
|
||||||
|
|
||||||
|
a_uv = max(a_uv, 1.0 / texture_width)
|
||||||
|
b_uv = max(b_uv, 1.0 / texture_height)
|
||||||
|
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
|
||||||
|
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
|
||||||
|
|
||||||
|
print(f"Found {len(dots)} valid dots.")
|
||||||
|
|
||||||
|
# Create image
|
||||||
|
img_name = "DotTexture"
|
||||||
|
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)
|
||||||
|
pixels = list(image.pixels)
|
||||||
|
|
||||||
|
# Black background (no mesh)
|
||||||
|
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||||
|
# White for all mesh areas
|
||||||
|
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||||||
|
# Draw dots
|
||||||
|
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
|
||||||
|
cx_px = cx_uv * (texture_width - 1)
|
||||||
|
cy_px = cy_uv * (texture_height - 1)
|
||||||
|
a_px = a_uv * texture_width
|
||||||
|
b_px = b_uv * texture_height
|
||||||
|
draw_ellipse(pixels, texture_width, texture_height,
|
||||||
|
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
|
||||||
|
|
||||||
|
if uv_centre is not None:
|
||||||
|
draw_cross(pixels, texture_width, texture_height,
|
||||||
|
uv_centre[0], uv_centre[1],
|
||||||
|
cross_length, cross_thickness, cross_color)
|
||||||
|
|
||||||
|
image.pixels[:] = pixels
|
||||||
|
image.update()
|
||||||
|
abs_path = bpy.path.abspath(output_path)
|
||||||
|
image.filepath_raw = abs_path
|
||||||
|
image.file_format = 'PNG'
|
||||||
|
image.save()
|
||||||
|
print(f"Dot texture saved to {abs_path}")
|
||||||
|
return image
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
# ==================== CONFIGURATION ====================
|
||||||
|
MODE = "patches" # "patches", "checker", or "dots"
|
||||||
|
OBJ_NAME = "FingerMesh0525.001"
|
||||||
|
CAM_NAME = "Camera0607"
|
||||||
|
TEXTURE_WIDTH = 1640*2
|
||||||
|
TEXTURE_HEIGHT = 1600*2
|
||||||
|
OUTPUT_NAMES = {
|
||||||
|
"patches": "patch_texture.png",
|
||||||
|
"checker": "checker_texture.png",
|
||||||
|
"dots": "dot_texture.png",
|
||||||
|
}
|
||||||
|
OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
|
||||||
|
|
||||||
|
# Patch mode
|
||||||
|
PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
|
||||||
|
EDGE_SAMPLE_GRID = 5
|
||||||
|
# Checker mode
|
||||||
|
CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
|
||||||
|
# Dot mode
|
||||||
|
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
|
||||||
|
# Cross
|
||||||
|
CROSS_LENGTH_PX = 40
|
||||||
|
CROSS_THICKNESS_PX = 3
|
||||||
|
CROSS_COLOR = (1, 0, 0, 1) # red cross
|
||||||
|
# ======================================================
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
scene = bpy.context.scene
|
||||||
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||||
|
|
||||||
|
if OBJ_NAME:
|
||||||
|
obj = bpy.data.objects.get(OBJ_NAME)
|
||||||
|
else:
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
if obj is None or obj.type != 'MESH':
|
||||||
|
raise ValueError("No valid mesh object found.")
|
||||||
|
|
||||||
|
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
|
||||||
|
if cam is None or cam.type != 'CAMERA':
|
||||||
|
raise ValueError("No camera found.")
|
||||||
|
|
||||||
|
if not obj.data.uv_layers.active:
|
||||||
|
obj.data.uv_layers.new(name="UVMap")
|
||||||
|
|
||||||
|
# Compute UV centre
|
||||||
|
uv_layer = obj.data.uv_layers.active
|
||||||
|
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||||
|
uv_min = uv_coords.min(axis=0)
|
||||||
|
uv_max = uv_coords.max(axis=0)
|
||||||
|
uv_centre = (uv_min + uv_max) / 2.0
|
||||||
|
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
|
||||||
|
|
||||||
|
|
||||||
|
# Compute UV bounding box centre
|
||||||
|
me = obj.data
|
||||||
|
uv_layer = me.uv_layers.active
|
||||||
|
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||||
|
uv_min = uv_coords.min(axis=0)
|
||||||
|
uv_max = uv_coords.max(axis=0)
|
||||||
|
uv_centre = (uv_min + uv_max) / 2.0
|
||||||
|
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
|
||||||
|
|
||||||
|
if MODE == "patches":
|
||||||
|
generate_patch_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
|
||||||
|
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||||
|
output_path=OUTPUT_PATH,
|
||||||
|
uv_centre=uv_centre,
|
||||||
|
cross_length=CROSS_LENGTH_PX,
|
||||||
|
cross_thickness=CROSS_THICKNESS_PX,
|
||||||
|
cross_color=CROSS_COLOR,
|
||||||
|
edge_sample_grid=EDGE_SAMPLE_GRID
|
||||||
|
)
|
||||||
|
elif MODE == "checker":
|
||||||
|
generate_checker_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
nx=CHECKER_NX, ny=CHECKER_NY,
|
||||||
|
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||||
|
output_path=OUTPUT_PATH,
|
||||||
|
uv_centre=uv_centre,
|
||||||
|
cross_length=CROSS_LENGTH_PX,
|
||||||
|
cross_thickness=CROSS_THICKNESS_PX,
|
||||||
|
cross_color=CROSS_COLOR,
|
||||||
|
edge_sample_grid=EDGE_SAMPLE_GRID,
|
||||||
|
gap_fill_px=CHECKER_GAP_FILL_PX
|
||||||
|
)
|
||||||
|
elif MODE == "dots":
|
||||||
|
generate_dot_texture(
|
||||||
|
obj, cam, scene, depsgraph,
|
||||||
|
dot_diameter_pixels=DOT_DIAMETER_PIX,
|
||||||
|
spacing_pixels=DOT_SPACING_PIX,
|
||||||
|
dot_color=DOT_COLOR,
|
||||||
|
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||||
|
output_path=OUTPUT_PATH,
|
||||||
|
uv_centre=uv_centre,
|
||||||
|
cross_length=CROSS_LENGTH_PX,
|
||||||
|
cross_thickness=CROSS_THICKNESS_PX,
|
||||||
|
cross_color=CROSS_COLOR
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
|
||||||
|
|
||||||
|
print(f"Completed in {time.time()-start_time:.2f} seconds.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Blender headless scene validation (collections and naming).
|
||||||
|
|
||||||
|
Usage (from repo root):
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01
|
||||||
|
|
||||||
|
Note: this script loads design.yaml with PyYAML. Prefer installing PyYAML into
|
||||||
|
Blender's Python, or run metadata-only checks via: orisys-design validate <revision>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
yaml = None
|
||||||
|
|
||||||
|
|
||||||
|
def _argv_after_double_dash() -> list[str]:
|
||||||
|
if "--" not in sys.argv:
|
||||||
|
return []
|
||||||
|
return sys.argv[sys.argv.index("--") + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_design(revision_dir: Path) -> dict:
|
||||||
|
if yaml is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PyYAML is not installed in Blender's Python. "
|
||||||
|
"Run: orisys-design validate <revision> without --blender, "
|
||||||
|
"or pip install PyYAML into Blender's Python."
|
||||||
|
)
|
||||||
|
with (revision_dir / "design.yaml").open(encoding="utf-8") as handle:
|
||||||
|
data = yaml.safe_load(handle)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("design.yaml must be a mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _argv_after_double_dash()
|
||||||
|
if not args:
|
||||||
|
print("validate.py: pass revision directory after --", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
revision_dir = Path(args[0]).resolve()
|
||||||
|
try:
|
||||||
|
design = _load_design(revision_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
collections = design.get("collections", {})
|
||||||
|
objects = design.get("objects", {})
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
present = {col.name for col in bpy.data.collections}
|
||||||
|
|
||||||
|
for key, expected_name in collections.items():
|
||||||
|
if expected_name not in present:
|
||||||
|
errors.append(f"Missing collection {expected_name!r} (design key: {key})")
|
||||||
|
|
||||||
|
for key, expected_name in objects.items():
|
||||||
|
obj = bpy.data.objects.get(expected_name)
|
||||||
|
if obj is None:
|
||||||
|
errors.append(f"Missing object {expected_name!r} (design key: {key})")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for message in errors:
|
||||||
|
print(f"ERROR: {message}", file=sys.stderr)
|
||||||
|
print(f"Present collections: {sorted(present)}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"OK: Blender scene matches conventions for {revision_dir.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"sensor": "finger",
|
||||||
|
"revision": "r01",
|
||||||
|
"design_repo": "git@your-nas:team/orisys-sensor-design.git",
|
||||||
|
"design_tag": "finger-r01-assets-v1.0.0",
|
||||||
|
"design_commit": "0000000000000000000000000000000000000000",
|
||||||
|
"bundle_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"exported_at": "2026-09-16T00:00:00Z",
|
||||||
|
"sdk_assets_dir": "assets/30fps-0703",
|
||||||
|
"notes": "Copy into orisys-sdk/sensors/finger/design-lock.json after promotion"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "orisys-sensor-design"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Nominal Orisys sensor geometry design and export tooling"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"PyYAML>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
orisys-design = "orisys_sensor_design.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
sensor: finger
|
||||||
|
revision: r01
|
||||||
|
status: draft
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
sensor: finger
|
||||||
|
revision: r01
|
||||||
|
status: draft
|
||||||
|
|
||||||
|
# Authoring unit in Blender (mm). Export converts using meters_per_source_unit.
|
||||||
|
authoring_unit: mm
|
||||||
|
meters_per_source_unit: 0.001
|
||||||
|
|
||||||
|
source_blend: source/assembly.blend
|
||||||
|
|
||||||
|
# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime).
|
||||||
|
expected:
|
||||||
|
sensing_vertices: 1032
|
||||||
|
sensing_triangles: 1974
|
||||||
|
|
||||||
|
frames:
|
||||||
|
world: world
|
||||||
|
camera: camera
|
||||||
|
|
||||||
|
collections:
|
||||||
|
sensing_surface: SENSOR_SURFACE
|
||||||
|
housing: SENSOR_HOUSING
|
||||||
|
marker_pattern: MARKER_PATTERN
|
||||||
|
camera_rig: CAMERA_RIG
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
Legacy bootstrap blend is vtbs-blender/thumb.blend copied to source/assembly.blend.
|
||||||
|
Split into sensing-surface.blend + housing.blend when two people edit geometry.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Optional golden outputs for regression:
|
||||||
|
|
||||||
|
- `preview.png` — viewport or render still
|
||||||
|
- `expected-metrics.json` — vertex counts, UV residual, bounding box in metres
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
profile: compute
|
||||||
|
description: Runtime sensing bundle consumed by orisys-sdk assets_dir
|
||||||
|
|
||||||
|
output_dir: build/export/finger/r01/compute
|
||||||
|
|
||||||
|
# Files must match orisys-sdk / Studio runtime layout.
|
||||||
|
artifacts:
|
||||||
|
- mesh.npz
|
||||||
|
- camera_intrinsics.npz
|
||||||
|
- world_to_cam.npy
|
||||||
|
|
||||||
|
# Measured unroll maps stay in SDK (not overwritten by nominal export).
|
||||||
|
runtime_owned_by_sdk:
|
||||||
|
- unroll_map_uv_s0.4.npz
|
||||||
|
|
||||||
|
validation:
|
||||||
|
# orisys-app validate_scene.py --legacy-dir <output_dir> --map <sdk unroll map>
|
||||||
|
require_sensing_surface_only: true
|
||||||
|
expected_vertices: 1032
|
||||||
|
expected_triangles: 1974
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
profile: studio
|
||||||
|
description: Compile a Studio .oryscene from compute export + SDK unroll map
|
||||||
|
|
||||||
|
# Always compile from compute export, never from visualization/ pose mesh.
|
||||||
|
compute_export_dir: build/export/finger/r01/compute
|
||||||
|
|
||||||
|
# Measured field domain — lives in orisys-sdk, not this repo.
|
||||||
|
unroll_map: orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz
|
||||||
|
|
||||||
|
compile:
|
||||||
|
tool: orisys-app/tools/scene_assets/compile_scene.py
|
||||||
|
meters_per_source_unit: 0.001
|
||||||
|
output: orisys-app/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene
|
||||||
|
|
||||||
|
validate:
|
||||||
|
tool: orisys-app/tools/scene_assets/validate_scene.py
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
visualization/world_to_cam.npy is for SDK Dashboard viz_pose only.
|
||||||
|
Do not point Studio compile at assets/visualization/.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Source blends (Git LFS)
|
||||||
|
|
||||||
|
Place `assembly.blend` here. Bootstrap from legacy:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item D:\orisys_ws\vtbs-blender\thumb.blend .\assembly.blend
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure Git LFS is installed before the first commit:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git lfs install
|
||||||
|
git add assembly.blend
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4d88cd2e6604e95c44dd483ec5380797dcebdd02ff584b68fb2182bd8e24b511
|
||||||
|
size 8256170
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
sensor: finger
|
||||||
|
revision: r01
|
||||||
|
status: draft
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
sensor: finger
|
||||||
|
revision: r01
|
||||||
|
status: draft
|
||||||
|
|
||||||
|
# Authoring unit in Blender (mm). Export converts using meters_per_source_unit.
|
||||||
|
authoring_unit: mm
|
||||||
|
meters_per_source_unit: 0.001
|
||||||
|
|
||||||
|
source_blend: source/geometry.blend
|
||||||
|
|
||||||
|
# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime).
|
||||||
|
# First exports from geometry.blend may differ until topology is locked.
|
||||||
|
expected:
|
||||||
|
sensing_vertices: 1032
|
||||||
|
sensing_triangles: 1974
|
||||||
|
|
||||||
|
frames:
|
||||||
|
sensor: origin_sensor
|
||||||
|
|
||||||
|
objects:
|
||||||
|
sensing_surface: sensing_surface
|
||||||
|
cam_sensor: cam_sensor
|
||||||
|
origin_sensor: origin_sensor
|
||||||
|
|
||||||
|
collections:
|
||||||
|
frames: FRAMES
|
||||||
|
export_compute: EXPORT_COMPUTE
|
||||||
|
assembly: ASSEMBLY
|
||||||
|
design_sim: DESIGN_SIM
|
||||||
|
exclude: EXCLUDE
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
Canonical source is geometry.blend with named collections and objects.
|
||||||
|
Compute export uses sensing_surface + cam_sensor relative to origin_sensor.
|
||||||
|
Studio observation cameras are derived from mesh bounds; no cam_viz required.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Optional golden outputs for regression:
|
||||||
|
|
||||||
|
- `preview.png` — viewport or render still
|
||||||
|
- `expected-metrics.json` — vertex counts, UV residual, bounding box in metres
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
profile: compute
|
||||||
|
description: Runtime sensing bundle consumed by orisys-sdk assets_dir
|
||||||
|
|
||||||
|
output_dir: build/export/finger/r01/compute
|
||||||
|
|
||||||
|
# Files must match orisys-sdk / Studio runtime layout.
|
||||||
|
artifacts:
|
||||||
|
- mesh.npz
|
||||||
|
- camera_intrinsics.npz
|
||||||
|
- world_to_cam.npy
|
||||||
|
|
||||||
|
# Measured unroll maps stay in SDK (not overwritten by nominal export).
|
||||||
|
runtime_owned_by_sdk:
|
||||||
|
- unroll_map_uv_s0.4.npz
|
||||||
|
|
||||||
|
validation:
|
||||||
|
# orisys-app validate_scene.py --legacy-dir <output_dir> --map <sdk unroll map>
|
||||||
|
require_sensing_surface_only: true
|
||||||
|
expected_vertices: 1032
|
||||||
|
expected_triangles: 1974
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
profile: studio
|
||||||
|
description: Compile a Studio .oryscene from compute export + SDK unroll map
|
||||||
|
|
||||||
|
# Always compile from compute export, never from visualization/ pose mesh.
|
||||||
|
compute_export_dir: build/export/finger/r01/compute
|
||||||
|
|
||||||
|
# Measured field domain — lives in orisys-sdk, not this repo.
|
||||||
|
unroll_map: orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz
|
||||||
|
|
||||||
|
compile:
|
||||||
|
tool: orisys-app/tools/scene_assets/compile_scene.py
|
||||||
|
meters_per_source_unit: 0.001
|
||||||
|
output: orisys-app/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene
|
||||||
|
|
||||||
|
validate:
|
||||||
|
tool: orisys-app/tools/scene_assets/validate_scene.py
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
visualization/world_to_cam.npy is for SDK Dashboard viz_pose only.
|
||||||
|
Do not point Studio compile at assets/visualization/.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Source blends (Git LFS)
|
||||||
|
|
||||||
|
Canonical file: `geometry.blend`
|
||||||
|
|
||||||
|
Required objects (by name):
|
||||||
|
|
||||||
|
- `sensing_surface` — in `EXPORT_COMPUTE`
|
||||||
|
- `cam_sensor` — in `DESIGN_SIM`
|
||||||
|
- `origin_sensor` — Empty in `FRAMES` (sensor-frame datum)
|
||||||
|
|
||||||
|
Ensure Git LFS is installed before the first commit:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git lfs install
|
||||||
|
git add geometry.blend
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:928a0012a17b68f3de9bab42bd9314fa8661f62f624d6460f6c1918ba17813f5
|
||||||
|
size 769866
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cdceb6bb091a7f8e1f820063c18b84ea9fa686949381f6d7b1cee74d63c27c18
|
||||||
|
size 677092
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c92c9d800d22e37f161a9ee4a29b53ff514b7c1d224575a91c5144237cf39963
|
||||||
|
size 650206
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Orisys nominal sensor design tooling (minimal v1)."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Tests for revision metadata and manifest generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_sensor_design.cli import resolve_blender_executable
|
||||||
|
from orisys_sensor_design.manifest import (
|
||||||
|
EXPORT_JOB_SCHEMA,
|
||||||
|
RevisionPaths,
|
||||||
|
build_export_job,
|
||||||
|
build_manifest,
|
||||||
|
default_manifest_path,
|
||||||
|
export_object_names,
|
||||||
|
export_output_dir,
|
||||||
|
export_revision_dir,
|
||||||
|
repo_root,
|
||||||
|
sha256_directory,
|
||||||
|
validate_revision_metadata,
|
||||||
|
write_export_job,
|
||||||
|
write_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def revision_dir() -> Path:
|
||||||
|
root = repo_root()
|
||||||
|
return root / "sensors" / "finger" / "r01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_root_finds_pyproject(revision_dir: Path) -> None:
|
||||||
|
root = repo_root(revision_dir)
|
||||||
|
assert (root / "pyproject.toml").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_metadata_reports_missing_blend(tmp_path: Path) -> None:
|
||||||
|
revision = tmp_path / "r99"
|
||||||
|
(revision / "profiles").mkdir(parents=True)
|
||||||
|
(revision / "design.yaml").write_text(
|
||||||
|
"sensor: finger\nrevision: r99\nmeters_per_source_unit: 0.001\n"
|
||||||
|
"source_blend: source/assembly.blend\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for name in ("compute.yaml", "studio.yaml"):
|
||||||
|
(revision / "profiles" / name).write_text(f"profile: {name.split('.')[0]}\n", encoding="utf-8")
|
||||||
|
paths = RevisionPaths.from_revision_dir(revision)
|
||||||
|
errors = validate_revision_metadata(paths)
|
||||||
|
assert any("Source blend not found" in message for message in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_object_names_from_design(revision_dir: Path) -> None:
|
||||||
|
names = export_object_names(RevisionPaths.from_revision_dir(revision_dir))
|
||||||
|
assert names == {
|
||||||
|
"sensing_surface": "sensing_surface",
|
||||||
|
"cam_sensor": "cam_sensor",
|
||||||
|
"origin_sensor": "origin_sensor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_export_job_round_trips_json(revision_dir: Path, tmp_path: Path) -> None:
|
||||||
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
|
output_dir = tmp_path / "compute"
|
||||||
|
job = build_export_job(paths, output_dir, "compute")
|
||||||
|
assert job["schema"] == EXPORT_JOB_SCHEMA
|
||||||
|
assert job["profile"] == "compute"
|
||||||
|
assert job["objects"] == {
|
||||||
|
"sensing_surface": "sensing_surface",
|
||||||
|
"cam_sensor": "cam_sensor",
|
||||||
|
"origin_sensor": "origin_sensor",
|
||||||
|
}
|
||||||
|
assert Path(job["output"]) == output_dir.resolve()
|
||||||
|
|
||||||
|
job_path = write_export_job(job, tmp_path)
|
||||||
|
assert job_path.name == "_export_job.json"
|
||||||
|
assert job_path.parent == tmp_path
|
||||||
|
loaded = json.loads(job_path.read_text(encoding="utf-8"))
|
||||||
|
assert loaded["schema"] == EXPORT_JOB_SCHEMA
|
||||||
|
assert loaded["objects"]["cam_sensor"] == "cam_sensor"
|
||||||
|
assert loaded["objects"]["sensing_surface"] == "sensing_surface"
|
||||||
|
assert loaded["objects"]["origin_sensor"] == "origin_sensor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_output_dir_matches_profile(revision_dir: Path) -> None:
|
||||||
|
root = repo_root(revision_dir)
|
||||||
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
|
out = export_output_dir(root, paths, "compute")
|
||||||
|
assert out == (root / "build/export/finger/r01/compute").resolve()
|
||||||
|
assert export_revision_dir(root, paths, "compute") == (
|
||||||
|
root / "build/export/finger/r01"
|
||||||
|
).resolve()
|
||||||
|
assert default_manifest_path(root, paths, "compute") == (
|
||||||
|
root / "build/export/finger/r01/manifest.json"
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_hash_ignores_generated_metadata(tmp_path: Path) -> None:
|
||||||
|
payload = tmp_path / "compute"
|
||||||
|
payload.mkdir()
|
||||||
|
(payload / "mesh.npz").write_bytes(b"mesh")
|
||||||
|
digest = sha256_directory(payload)
|
||||||
|
(payload / "_export_job.json").write_text("{}", encoding="utf-8")
|
||||||
|
(payload / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
|
assert sha256_directory(payload) == digest
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> None:
|
||||||
|
manifest = build_manifest(
|
||||||
|
root=repo_root(revision_dir),
|
||||||
|
revision_dir=revision_dir,
|
||||||
|
profile="compute",
|
||||||
|
)
|
||||||
|
out = tmp_path / "manifest.json"
|
||||||
|
write_manifest(out, manifest)
|
||||||
|
loaded = json.loads(out.read_text(encoding="utf-8"))
|
||||||
|
assert loaded["sensor"] == "finger"
|
||||||
|
assert loaded["revision"] == "r01"
|
||||||
|
assert loaded["profile"] == "compute"
|
||||||
|
assert loaded["expected_artifacts"] == [
|
||||||
|
"mesh.npz",
|
||||||
|
"camera_intrinsics.npz",
|
||||||
|
"world_to_cam.npy",
|
||||||
|
]
|
||||||
|
assert loaded["source_blend"].endswith("geometry.blend")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_blender_respects_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
fake = tmp_path / "blender.exe"
|
||||||
|
fake.write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("ORISYS_BLENDER", str(fake))
|
||||||
|
monkeypatch.delenv("BLENDER_EXE", raising=False)
|
||||||
|
assert resolve_blender_executable() == fake.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_blender_from_program_files(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("ORISYS_BLENDER", raising=False)
|
||||||
|
monkeypatch.delenv("BLENDER_EXE", raising=False)
|
||||||
|
monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None)
|
||||||
|
|
||||||
|
pf = tmp_path / "ProgramFiles"
|
||||||
|
v42 = pf / "Blender Foundation" / "Blender 4.2"
|
||||||
|
v50 = pf / "Blender Foundation" / "Blender 5.0"
|
||||||
|
v42.mkdir(parents=True)
|
||||||
|
v50.mkdir(parents=True)
|
||||||
|
exe42 = v42 / "blender.exe"
|
||||||
|
exe50 = v50 / "blender.exe"
|
||||||
|
exe42.write_text("", encoding="utf-8")
|
||||||
|
exe50.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("ProgramFiles", str(pf))
|
||||||
|
monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86"))
|
||||||
|
|
||||||
|
# No lock preference → newest folder name wins (5.0).
|
||||||
|
assert resolve_blender_executable(root=None) == exe50.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_blender_prefers_lock_lts(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("ORISYS_BLENDER", raising=False)
|
||||||
|
monkeypatch.delenv("BLENDER_EXE", raising=False)
|
||||||
|
monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None)
|
||||||
|
|
||||||
|
pf = tmp_path / "ProgramFiles"
|
||||||
|
v42 = pf / "Blender Foundation" / "Blender 4.2"
|
||||||
|
v50 = pf / "Blender Foundation" / "Blender 5.0"
|
||||||
|
v42.mkdir(parents=True)
|
||||||
|
v50.mkdir(parents=True)
|
||||||
|
(v42 / "blender.exe").write_text("", encoding="utf-8")
|
||||||
|
(v50 / "blender.exe").write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("ProgramFiles", str(pf))
|
||||||
|
monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86"))
|
||||||
|
|
||||||
|
root = tmp_path / "repo"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "blender.lock.json").write_text(
|
||||||
|
json.dumps({"blender_version": "4.2.0", "blender_lts": "4.2"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert resolve_blender_executable(root=root) == (v42 / "blender.exe").resolve()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
|
|
||||||
|
rem Convenience launcher: find Blender on this machine and run compute export.
|
||||||
|
rem Session-local only — does not write machine paths into the repo.
|
||||||
|
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: could not cd to repo root from %~dp0
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined ORISYS_BLENDER (
|
||||||
|
if exist "%ORISYS_BLENDER%" goto :run
|
||||||
|
)
|
||||||
|
|
||||||
|
set "ORISYS_BLENDER="
|
||||||
|
set "PF=%ProgramFiles%"
|
||||||
|
if not defined PF set "PF=C:\Program Files"
|
||||||
|
|
||||||
|
rem Prefer Blender 5.0 when present, then newest Blender * folder.
|
||||||
|
if exist "%PF%\Blender Foundation\Blender 5.0\blender.exe" (
|
||||||
|
set "ORISYS_BLENDER=%PF%\Blender Foundation\Blender 5.0\blender.exe"
|
||||||
|
goto :found
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f "delims=" %%D in ('dir /b /ad /o-n "%PF%\Blender Foundation\Blender *" 2^>nul') do (
|
||||||
|
if exist "%PF%\Blender Foundation\%%D\blender.exe" (
|
||||||
|
set "ORISYS_BLENDER=%PF%\Blender Foundation\%%D\blender.exe"
|
||||||
|
goto :found
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ERROR: blender.exe not found under "%PF%\Blender Foundation"
|
||||||
|
echo Set ORISYS_BLENDER to your blender.exe path, then re-run.
|
||||||
|
exit /b 2
|
||||||
|
|
||||||
|
:found
|
||||||
|
echo Using Blender: %ORISYS_BLENDER%
|
||||||
|
|
||||||
|
:run
|
||||||
|
where orisys-design >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: orisys-design not on PATH. From repo root run: python -m pip install -e .
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
orisys-design export sensors/finger/r01 --profile compute
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
Reference in New Issue
Block a user