# 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 # 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+ - Blender version pinned in `blender.lock.json` (install separately) No Git LFS is required — binaries are committed as ordinary files. ### 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 . ``` Four 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) Generate the colored patch texture headlessly orisys-design texture sensors/finger/r01 # 4) 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 ``` ## Texture patches (headless) `orisys-design texture` generates a PNG, editable SVG, and native vector PDF for each UV map on the sensing surface. The SVG/PDF are built directly from the UV mesh and camera-space patch polygons — they do **not** embed or trace the PNG. UV unwrapping is **still an in-app Blender step**: this command consumes the UV maps already stored in `source_blend` and never creates or unwraps one. Unwrap the mesh in the Blender GUI, save the `.blend`, then run the command. A `.blend` saved in Edit mode is fine — the headless run enters Object mode in memory and never writes the file. ```powershell orisys-design texture sensors/finger/r01 # writes build/textures/finger/r01/_texture_job.json then runs Blender ``` Output is revision-scoped, so nothing has to be copied by hand: ```text build/textures/finger/r01/ ├── _texture_job.json # Blender input, regenerated by texture (gitignored) ├── patch_texture_produce.png # raster preview/use (tracked) ├── patch_texture_produce.svg # editable grouped vector artwork ├── patch_texture_produce.pdf # vector production/print handoff (tracked) ├── patch_texture_compute.png ├── patch_texture_compute.svg └── patch_texture_compute.pdf ``` `produce` is the hardware-production layout and `compute` the compute/runtime layout; they are the two UV maps authored on `sensing_surface`. Both outputs share one set of settings and one seed, and both read the same camera-space patch grid, so a given physical patch carries the same colour in either file — the images differ only in how the unwrap arranges the patches. **Square in which space?** The patch grid is defined in the camera image, not in flat UV coordinates or physical surface distances. For square camera pixels, `nx / ny` must match the camera image aspect ratio (80×80 for a square frame, 80×45 for 16:9). Changing the PNG dimensions does not change the camera grid. Curvature and UV unwrapping can make these camera-space squares look rectangular, skewed, or curved in the flat texture; that alone is not a defect. Thin spikes outside the UV surface, however, are not expected projection distortion. Generated artifacts are **committed** so a revision's textures can be synced to hardware production. They are stored as real files, not Git LFS pointers, so a plain `git clone` yields usable images and `.blend` files; `.gitattributes` only marks binaries as non-text to protect them from line-ending conversion. Only `_texture_job.json` is ignored, because it records absolute machine paths and is rewritten every run. SVG imports into CorelDRAW as named groups (`background`, `mesh`, `patches`, and `reference-cross`) containing individual editable polygons. PDF contains the same filled vector paths on a correctly sized page, with no embedded bitmap. Seam-wrapped duplicate polygons are clipped to the page, so no stray objects sit outside the artwork. Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, the `uv_outputs` map (UV map → artifact stem), raster dimensions, vector page size in millimetres, patch grid, colour separation, edge sampling, reference cross, and an optional seed. The current production page is configurable as: ```yaml vector: width_mm: 164 height_mm: 160 ``` CLI flags override the shared rendering values for every output: ```powershell orisys-design texture sensors/finger/r01 ` --width 1024 --height 1024 --nx 16 --ny 12 --seed 7 --no-cross ``` Manual Blender invocation with the same job: ```powershell blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- ` --job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json ``` A single manual target is also possible (this cannot be combined with `--job`). `--output` names the PNG; same-stem SVG/PDF files are generated alongside it. The vector page defaults to 164×160 mm and can be overridden manually: ```powershell blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- ` --uv-map produce --output D:\tmp\produce.png --width 1024 --height 999 ` --vector-width-mm 164 --vector-height-mm 160 ``` Only the colored "patches" mode is reachable headlessly; the checker and dot helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The default 3280×3200 grid matches the in-app script and is deliberately slow (pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick check runs, and the full default grid for the textures you actually ship. ## Inspect printability (advisory) `orisys-design inspect` measures an **already generated** texture and reports whether it can be produced. It reads the vector SVG, never runs Blender, and never gates: a failed constraint is reported but the command still exits 0. ```powershell orisys-design inspect sensors/finger-xynova/r01 # writes build/textures///texture_report.json ``` Millimetres come from the SVG's own declared page (`width`/`height` in mm) and its `viewBox`, so the numbers describe the artifact that exists. When that page disagrees with `design.yaml`, the report fills `page_mismatch` with both sizes and a reason — a texture generated before a page change is not silently accepted. `design.yaml` carries the thresholds; CLI flags override them: ```yaml printability: printer_dpi: 600 # dot pitch = 25.4 / dpi = 0.0423 mm min_dots: 2 # printer dots across the smallest feature min_feature_mm: 0.10 min_edge_mm: 0.05 max_sliver_ratio: 4.0 min_patch_area_mm2: 1.0e-4 ``` ### Report fields The report (`orisys-sensor-design/texture-report-v2`) keeps only what drives a decision. Top level: `schema`, `generated_at`, `revision` (repo-relative), `settings`, `overall`, and `maps[]`. Each map carries: | Field | Meaning | |---|---| | `uv_map`, `file` | which map, and the SVG inspected (`name`, `relative_path`, `sha256`) | | `page_mm`, `mm_per_px`, `uniform_scale` | physical page and its scale onto the viewBox | | `raster_dpi` | effective raster resolution; informational, never gates | | `nominal_cell` | grid cell size in mm and across its shortest side in printer dots | | `patches` | `total`, `material`, `debris`, `below_dot`, `degenerate`, `nonconvex` | | `min_width_mm`, `min_edge_mm` | `min`, `p5`, `p50` in mm (`min_width_mm.method` notes the caliper basis) | | `max_sliver_ratio` | worst longest-edge ÷ minimum-width ratio | | `coverage` | `patch_area_fraction` of the mesh, and `mesh_polygons` | | `colours` | `distinct` fills and the closest `min_rgb_distance` between two of them | | `reference_cross` | arm `thickness_mm` and whether it is `printable` at the configured dots | | `page_mismatch` | `null`, or `{artifact_mm, declared_mm, reason}` when stale | | `notes` | situational explanations only | | `checks`, `verdict` | one entry per constraint: `name`, `required`, `actual`, `passed` | | `offenders` | worst material patches, then debris examples, each with a locator | ### Locating a reported patch A patch id is an **emission-order ordinal**, not a stable name: it renumbers whenever the texture is regenerated, and on its own it says nothing about where the patch is. `file.sha256` therefore identifies the exact SVG a report describes, and every offender carries a locator: ```json { "kind": "material", "id": "patch-33927", "fill": "#56faa1", "centroid_px": [2483.924, 1255.508], "centroid_mm": [12.419618, 6.277541], "min_width_mm": 0.000417, "sliver_ratio": 1208.131, "area_mm2": 0.000105 } ``` Use the id to find the `` in the SVG, `centroid_px` to locate it in the viewBox, `centroid_mm` to place it on the printed page, and `fill` to recognise it on screen. `--csv` writes the reported offenders; `--csv-all` writes every material patch with the same columns, which is the practical way to triage thousands of patches in a spreadsheet or CorelDRAW. Only the SVG is read, so `file` names the SVG even though the PDF remains the print master and the PNG the raster preview. ### Reading the checks Measured feature statistics use **material** patches, those at or above `min_patch_area_mm2`. Cell clipping emits a long tail of numerically degenerate slivers (~1% of polygons below one output pixel on the Xynova revision); counting those as features would make every revision fail. They are not hidden: `patches.debris`, `patches.below_dot` and the debris entries in `offenders` report them, and `notes` says how many were excluded. Checks are **per polygon**. Abutting polygons sharing one fill merge into a single printed feature, so a cell split by triangle or seam boundaries prints whole even though each fragment is smaller. Compare `nominal_cell.dots` against `min_dots` to judge that; per-polygon minima matter for processes that resolve every object separately, such as cutting or weeding. A failing `min_feature_mm` alongside a passing `nominal_cell.dots` means the artwork is fragmented, not that the pattern itself is unprintable. ## Preview the export `orisys-design preview` opens the built export in a minimal PyOpenGL window so a revision can be checked before it reaches Studio: the sensing surface highlighted, the exported housing around it, and the exported camera frustum. ```powershell python -m pip install -e .[preview] # numpy, PyQt5, PyOpenGL orisys-design preview sensors/finger-xynova/r01 ``` Controls: left-drag orbit, middle/right-drag pan, wheel zoom, `H` toggle housing, `W` wireframe, `C` frustum. `--self-test` renders one frame, reports how many mesh pixels were drawn, and exits without waiting for a window — use it to check a machine's GL setup. `--screenshot PATH` writes that frame to a PNG so a view can be reviewed or shared without describing it. `--no-housing` and `--no-frustum` simplify the view. Normals are crease-aware: faces are grouped per vertex and split where the dihedral angle exceeds `--crease-angle` (default 35°), so machined edges read as creases instead of smooth roll-offs. `--smooth` disables splitting. This matters because the housing is sharp and the sensing surface is not — 13.5% of `frame_L` edges exceed 30° while the sensing surface has none, and the default leaves its 2508 vertices untouched. Exit codes: `2` for a missing optional dependency, `3` for a payload that has not been exported, `4` when no OpenGL context can be created (the offscreen/minimal Qt platform cannot create one at all, so run on a desktop session). ### Housing is a separate artifact `export` also writes **`housing.npz`** for the objects in the `ASSEMBLY` collection, transformed into the same sensor frame as the sensing surface. It is deliberately *not* merged into `mesh.npz`: `orisys-sdk`'s unroll consumes `mesh.npz` vertices as the sensing surface, so housing vertices there would corrupt it. Declare the collections with `--housing-collections` or via `design.yaml`'s `collections.assembly`. ### Lighting mirrors Studio, by value This window reimplements a small renderer rather than importing Studio's, so the look is copied rather than shared. Every mirrored value — clear colour, light direction, the `0.35 + 0.65 * lambert` shading, housing graphite — lives in `preview/appearance.py` with the `gl_backend.py` line it came from, and `tests/test_preview.py` re-reads Studio's source and fails if it changes. That keeps the duplication honest and makes adopting real reuse a one-module swap. The sensing surface is drawn in a preview-only highlight colour; Studio colours it from the measurement LUT instead. Treat this as a design-time check, not the authoritative renderer. ### One caveat about export units The exported sensor frame is **not metres**. `origin_sensor` carries a uniform scale, so a ~21 mm assembly exports with bounds around ±574 units and `world_to_cam` is a scaled (non-rigid) pose: its rotation column norms are `0.003937` on Xynova, not `1`. Projection is still self-consistent — all 2508 sensing vertices land inside the 1000×1000 frame — but any consumer that assumes metres will be wrong. The preview therefore frames the model from its own bounding sphere rather than Studio's fixed metre limits, and prints a warning when the pose is not rigid. 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). - **Binary files**: `.blend`, textures and exported `*.npz` are committed as ordinary files (no Git LFS). `.gitattributes` marks them `-text` only. ## Git LFS on Gitea (NAS) — not currently used **Status: this repository does not use Git LFS.** `.gitattributes` only marks binaries as non-text, and real `.blend` / `.png` / `.pdf` bytes are committed directly. This is deliberate: LFS pointers are unusable without a reachable LFS server, so a plain clone or `git checkout` would hand you 131-byte files instead of images. The notes below are kept as a migration guide for when the NAS server is ready. **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` does **not** track anything through LFS today. To turn LFS on, add the extensions back with a filter (the file contains a ready-made example line), run `git add --renormalize .` so real bytes become pointers, and commit `.gitattributes` together with the renormalized files. ### 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 + r01 + headless compute export | | **B** | Lock file in SDK/App + CI reproduces export hashes | | **C** (later) | Markers, camera metrics, simulation datasets |