# 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 `docs/workflow/sensor-design-workflow.md` in the workspace root (`D:\orisys_ws\docs\workflow\sensor-design-workflow.md`). This repository intentionally implements a **minimal v1** for a small team. Measured properties of the geometry and cameras — topology, surface area, normals, incidence, cell pitch, and the runtime unroll domain — are in [`docs/geometry-measurements.md`](docs/geometry-measurements.md). The multi-view ownership design and the optics model both depend on them, and they expire when a camera moves. ## 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; see below) Install the CLI (editable): ```powershell cd D:\orisys_ws\orisys-sensor-design python -m pip install -e . ``` The base install is stdlib + PyYAML. Feature extras are `dev`, `preview`, `optics`, `viz` and `probe`; see `requirements.txt` for the flat list, which is what the full test suite needs. Blender is only used by the in-scene commands, so `validate`, `export`, `texture` and `manifest` run without it. 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 Four commands for v1: ```powershell # 1) Check revision metadata and (optionally) Blender scene conventions orisys-design validate sensors/finger/r01 # 2) Export nominal geometry plus the unwarp/valid-mask map (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 └── unroll_map_uv_s0.42.npz # unwarp/valid-mask domain (name carries source_scale) ``` The last one is the **UV domain** the application treats as sensing: everything outside its `valid_mask` island renders grey. Blender writes no mask, so the export builds it afterwards with the SDK's own builder (`orisys.unroll.precompute`), from the profile's `uv_map:` block — no Blender involved, so the mask can also be rebuilt from an exported payload alone: ```powershell python tools/build_uv_map.py --export-dir build/export/finger/r01/compute ` --profile sensors/finger/r01/profiles/compute.yaml ``` The parameters are never guessed (a wrong output size or source scale still produces a plausible-looking mask), and a failed map build exits non-zero *before* the artifact check, so a bundle whose mask is missing or stale cannot be reported as complete. The SDK asset copy is updated only when the revision is promoted — gated by `orisys-app/tools/scene_assets/compare_uv_map.py`. ## 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 ``` `stroke-texture` exports black capsule strokes using the same camera grid, projection and UV output size as `texture_patch`. First generate the single-view texture job, then run `orisys-design stroke-texture sensors/finger-xynova/r01`. The stroke output is independent of the color artwork pixels. The revision's `stroke_pattern` block controls the seed, width and length ratios, and orientation mode; `--output-dir` selects a fresh destination. The command writes PNG/SVG/PDF, resolved settings and a hash manifest. Two-view `--views` jobs are not supported. The projected-cell ratios do not guarantee a 0.2 mm physical stroke width; measure the production SVG before manufacturing. ## 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 ``` ## Probe sensitivity bench `orisys-design deform|render|track|sensitivity|optimise` answers *"which marker pattern gives the best tracking sensitivity on this sensor?"* It deforms the sensing surface through a **known** displacement, renders what `cam_sensor` actually sees, measures DIS optical flow against the zero-displacement reference, and reports a ranked, auditable score. The full interface contract is in [`docs/probe-pipeline.md`](docs/probe-pipeline.md). ```powershell python -m pip install -e .[probe] # numpy + opencv-python # 1) deform 31 frames (1 reference + 3 axes x 10 steps) orisys-design deform sensors/finger-xynova/r01 # 2) render what the camera sees for each frame orisys-design render sensors/finger-xynova/r01 # 3) DIS flow against the reference orisys-design track sensors/finger-xynova/r01 # 4) score and rank (writes sensitivity_report.json) orisys-design sensitivity sensors/finger-xynova/r01 --skip-deform --skip-render --skip-track # 5) search patch-pattern parameters orisys-design optimise sensors/finger-xynova/r01 --dry-run # 6) compare different settings and plot the measured optical flow orisys-design compare sensors/finger-xynova/r01 ``` ### Visualising the flow and comparing settings `orisys-design compare` runs one marker pattern under several estimator settings and writes three figures plus `settings_comparison.json` into `build/probe///comparison/`: | figure | what it shows | |---|---| | `settings_flow.png` | one panel per setting: the rendered marker around the probe with the measured flow as arrows, the ROI, the cell grid and the **effective window** drawn on it, and the applied vs measured displacement. Below each panel, the measured `\|flow\|` field on a shared colour scale. | | `settings_tuning.png` | score vs the camera-space cell, and vs the cell/window ratio, one series per setting. | | `settings_response.png` | two panels. **Signal**: response vs applied displacement per setting, with each curve's *own* noise floor drawn as a line and the ideal 1:1 line. **Retention**: `gain(σ)/gain(clean)` vs injected noise σ, so a pattern that looks strong but collapses under noise is visible at a glance. | Only the settings that need re-running do: `nx` regenerates the texture, `radius_mm` regenerates the deformation and frames, and `dis_mode` reuses the *same frames* (the variant directory holds a manifest with absolute frame paths), so the decisive estimator comparison costs seconds rather than a re-render. ```powershell # the shipped setting vs the full-resolution estimator, on identical frames orisys-design compare sensors/finger-xynova/r01 --grid 31,56,80 --dis-modes 1,0 # a different rubber radius changes the optimum, so compare for stability orisys-design compare sensors/finger-xynova/r01 --radii 0.5,1.0,1.5 ``` `orisys-design sensitivity` chains all of steps 1–4 itself, so a single run is usually enough; the `--skip-*` flags let you re-score without re-rendering. All artifacts land in `build/probe///`. ### How the deformation is defined The vertices in the `probe` vertex group move rigidly by a displacement expressed in a **probe-average orthonormal frame** — x and y are shear, z is the surface normal, i.e. normal force. The 0.01 mm steps run from 0 to 0.10 mm per axis. Vertices outside the group follow Blender's proportional-editing falloff (`smooth`/`linear`/`sharp`/`constant`) out to `radius_mm`, representing the outer rubber. The falloff is computed explicitly in millimetres rather than through `bpy.ops.transform.translate(use_proportional_edit=True)`: that operator is viewport-modal and its radius is screen-space, so it is neither available nor reproducible under `blender -b`. The `.blend` is never written — the whole pipeline is read-only towards it. `deform_report.json` reports how many vertices actually moved. That matters: on the Xynova revision the median mesh edge is 0.63 mm, so the default 0.5 mm radius reaches only 5 of 2502 vertices outside the group, producing a sharp 6-vertex island rather than a smooth bulge. Raise `--radius-mm` to ~1.0 mm (17 vertices) or 1.5 mm (30 vertices) for a rounded indentation. ### Reading the report `render_manifest.json` records the render settings actually applied. Two are forced for correctness: the view transform must be `Standard` (the scene ships with AgX, a filmic tonemapper that silently rewrites every marker value) and denoising is off (it smears the pattern the tracker is meant to measure). The render also asserts that re-rendering the reference frame produces identical bytes, because a non-reproducible render invalidates every later number. `sensitivity_report.json` carries, per axis and per scale, the measured `gain` (measured mm of flow per mm of applied displacement), the `snr` against the null-frame floor, cross-axis leakage, and `resolvable_step_mm`. **Be careful reading small steps.** The Xynova design camera renders 500x500, which is **13.25 px/mm** at the probe, so 0.01 mm is only **0.13 px**. That is a *per-pixel* precision limit, not an estimator limit: because the response is averaged over the probe ROI (~139 px), the measured zero-input floor is only **0.029 px** and a single 0.01 mm step (0.13 px) clears it about 4.5x over. The report therefore states `resolvable_step_mm` per axis, reports the floor and the step separately in pixels, and evaluates the process scale (250x250 by default) alongside the render scale so you can see what the runtime can actually resolve. Do not assume a sub-pixel step is unmeasurable — check the floor. Sensitivity here is a **relative ranking under a fixed estimator and camera**, not a force calibration, and it must be read under a **stated noise model** — see the section above. The optimizer's winner is the best of a bounded deterministic candidate grid, and is not a proven global optimum. ### The measured gain is confounded with the ROI size This is the most important caveat in the tool, and it is measured rather than theoretical. On Xynova the probe patch plus its falloff taper spans only about **13 px**, i.e. 1-2 DIS patches (8 px), so DIS regularizes toward the static background and the coherent motion is measured at roughly **0.67x** of the applied displacement. Injecting a synthetic rigid translation of the same 1.3248 px recovers **17%** at a patch radius of 6.6 px (equal to the ROI), **51%** at r 10, **81%** at r 20 and **95%** at r 40. So the absolute gain depends strongly on `radius_mm`: compare patterns only while the radius, ROI and scale are held fixed. `orisys-design optimise` varies `nx`/`ny`/`seed` and never the radius for exactly this reason. Attribution matters too: the deficit is **not** aliasing, noise or a diluted average. The real flow's radial profile is flat inside the ROI, and the texture-level known-answer recovers a 2 px shift as 2.0000 px. Only strongly *periodic* synthetic textures alias (a 3.11 px checker turns a 2 px shift into 8.17 px); the shipped random per-cell pattern does not. ### The noise floor was wrong, and it changed the answer **Read [`docs/probe-findings.md`](docs/probe-findings.md) for the conclusions, the tables and the numbers; [method](docs/probe-method.md) for why.** The short version: An earlier claim here — that nx=31 is the best pattern and that "the marker cell must match the estimator's effective window" — is **retracted**. It was driven by `floor_px`, which is a render-seed antialiasing artefact, not sensor noise: its trend reverses under additive noise, and it understates the real idle floor (0.2267 px p95 |flow| on `data/dd02-0703/190.mp4`) by roughly 2–6x, understating the coarse patterns most. Under the calibrated model, the shipped **80x80 has the highest clean response on both axes and the best noise retention** (84–99% vs 59–73%), so it is competitive or better, not worse. Not yet re-ranked: the process (250x250) scale and the `radius_mm=1.5` block, where the score ordering differs. Every report carries `noise_models` and `noise_calibration`, and `settings_response.png` has a retention panel. ### What the bench measures The marker grid is defined **across the camera image**, so a cell is `render_width / nx` pixels, not a millimetre quantity: at 500x500 the shipped 80x80 grid has a **6.25 px** cell and nx=31 has **16.13 px**. `dis_mode=1` is OpenCV's `setFinestScale(1)`, so DIS computes flow at half resolution and upscales, giving an effective window of `patch_px * 2**dis_mode` = **16 px**. Those are the right quantities to watch — but the earlier reading of them as *the* explanation of the ranking was an artefact of the floor above, so treat the ratio as a useful diagnostic, not a validated law. The measured gain is also **confounded with the ROI size**: the probe patch plus its falloff taper spans only ~13 px (1-2 DIS patches), so DIS regularizes toward the static background. Injecting a synthetic rigid translation of the same 1.3248 px recovers ~17% at a patch radius of 6.6 px (equal to the ROI), 51% at r 10, 81% at r 20 and 95% at r 40. Compare patterns only with the radius, ROI and scale held fixed — which is why `orisys-design optimise` varies `nx`/`ny`/`seed` and never the radius. ## 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) and `unroll_map_uv_s.npz`, the unwarp/valid-mask domain built from the profile's `uv_map:` block. 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 + the exported unroll map. The map now travels with the payload; the SDK asset copy is refreshed only here, and only after `tools/scene_assets/compare_uv_map.py` agrees with what ships. 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 |