# MB-001 — Remote head-tracked stereo rig: implementation handoff **Audience:** an AI coding agent (e.g. Claude) that will implement MB-001 with TouchDesigner (TD), including a **simulator** that uses the 3D model. **Status of this document:** compiled 2026-11-03 (Asia/Hong_Kong) from the project's research files. Nothing here has been run on the physical rig or inside TouchDesigner. Every claim carries a tag: | Tag | Meaning | |---|---| | **[S]** | Sourced — taken from a vendor page, datasheet, repo or official doc listed in §17 | | **[D]** | Derived — arithmetic or geometry from sourced values (or numerically checked on the box) | | **[E]** | Estimate or design choice — plausible, **not measured**; change freely | | **[?]** | Unverified, unknown or conflicting — verify before relying on it | If this file and `data/params.json` disagree, treat it as a bug and prefer the vendor source in §17. `data/params.json` carries the same numbers in machine-readable form. --- ## 0. Read this first (60-second brief) 1. **What it is.** A tripod-mounted, 2-axis (pan + tilt) camera head with **two USB cameras 63 mm apart** at a remote site. A person wearing an **Apple Vision Pro** looks around; the rig turns to follow, and the stereo video comes back to the headset. **Nothing is mounted on the Vision Pro.** [S: project brief] 2. **Only one wireless hop:** rig machine → Vision Pro over Wi-Fi 6. Cameras (USB) and servos (wired) attach to the *same* rig machine (Raspberry Pi 5 assumed). [S: project brief; Pi 5 is an assumption [?]] 3. **Your two deliverables:** - **(A) Simulator in TouchDesigner** — loads `models//mb001_.glb`, drives `yoke` (pan) and `cam_bar` (tilt) from head-pose input, renders two virtual cameras 63 mm apart and outputs a side-by-side frame. Needs no hardware. Spec: §11–§13. - **(B) Rig software** — receives head pose, filters/predicts it, drives the servos, captures and sends stereo video. Spec: §4, §5, §9. 4. **Start with variant `module38_sts3215`** (the recommended build). Use the other two only if the user asks. 5. **Units are millimetres everywhere in `models/`** (including the GLB — it is *not* in metres). GLB is **y-up**; URDF/STL/SCAD are **z-up** (the "CAD frame"). See §2. 6. **Things most likely to bite you** (details in §14): TD sign conventions were **not tested in TD**; UDP port is **not specified** anywhere (proposed default only); head-pose forward axis of the VisionProTeleop matrix is **unverified**; tilt limits are an **estimate** and three documents disagree; all latency figures are **estimates**. --- ## 1. Goal and scope **In scope** - Head-pose → pan/tilt control of a 2-servo camera head (remote only). - Stereo capture from two USB cameras, side-by-side (SBS) video to the headset. - A TouchDesigner simulator of the rig (3D model + virtual stereo cameras) usable for development and demos without hardware. **Out of scope / not decided** [S: project brief] - Anything mounted on or worn with the headset beyond the stock Vision Pro. - Rolling the camera (no roll axis; 2-DoF only). - Depth/IPD mismatch correction (cameras are fixed at 63 mm; the headset's IPD varies per user — see §14). - A custom visionOS app is **not** specified; the existing option analysed is VisionProTeleop (§4–§5). Whether to write a visionOS app (needed for the NDI route) is an open decision. --- ## 2. Conventions (read before touching the model) ### 2.1 Units and frames | Asset | Frame | Up | Cameras look | Units | |---|---|---|---|---| | `cad/mb001_rig.scad`, `parts_stl/*.stl`, `urdf/*.urdf` | **CAD frame** | **+Z** | **+Y** | mm (URDF: metres; meshes scaled 0.001) | | `*.glb`, `*.fbx`, `parts_obj/*.obj`, `*_assembly.obj` | **glTF frame** | **+Y** | **+Z** | **mm** (GLB `asset.extras.units = "mm"`) | - CAD → glTF mapping **[D]**: `(x, y, z)_CAD → (−x, z, y)_glTF`. In both frames the camera "left" is the side where the *left* camera sits: CAD `x = −31.5`, glTF `x = +31.5`. - Origin is the centre of the base plate underside (z = 0 is the print bed). The URDF is in the CAD frame and is **not** REP-103 (REP-103 wants +X forward). - The existing viewer GLBs on the website (`/mb-001/` 3D viewer) are in **metres** and have a different hierarchy. **Do not use them for the simulator**; use the GLBs in `models/`. ### 2.2 Joints **[D]** (checked numerically on the box: URDF forward kinematics equals the GLB node transforms at 6 test poses, error ≈ 1e-17 m; **not** checked in TouchDesigner) | Joint | Axis (CAD) | Axis (glTF) | Positive rotation | GLB node & property | Limits implemented | |---|---|---|---|---|---| | **pan** | +Z through x = y = 0 | +Y | cameras turn **LEFT** | node `yoke`: `rotation.y = +pan` | ±90° [E] | | **tilt** | +X | −X | cameras pitch **UP** | node `cam_bar`: `rotation.x = −tilt` | ±30° [E] (sources conflict, §8.3) | The yoke node carries everything that rotates with pan (tilt servo, horn, cam_bar, cameras). Each node's origin (pivot) sits **on its joint axis**, so you can rotate a node about its own origin with no offset maths. Rest pose is `pan = tilt = 0` (cameras look straight along +Y_CAD / +Z_glTF, level). ### 2.3 Mapping the head-pose packet to joints **[D]** The UDP packet (§4.1) defines `yaw_deg` **+ = look right** and `pitch_deg` **+ = look up**. Pan is positive to the *left*, so: ``` pan_deg = -yaw_deg tilt_deg = +pitch_deg ``` A head turn of +10° to the right → `pan = −10°` → the yoke rotates 10° clockwise seen from above. If you instead build on the VisionProTeleop matrix, the sketch in §4.3 gives yaw + = *left* (opposite) — **[?] unverified**; resolve by the test in §4.3. ### 2.4 GLB scene graph (all three variants identical in structure) ``` mb001_ (root; extras: units mm, frames, source, rest pose) └─ base pivot at origin (z=0 bed) ├─ servo_pan, pi, pca9685, ubec static └─ yoke PAN pivot (extras.joint = "pan", axis_local [0,1,0], limit_deg [-90,90]) ├─ horn, servo_tilt └─ cam_bar TILT pivot (extras.joint = "tilt", axis_local [-1,0,0], limit_deg [-30,30]) ├─ cam_L → cam_body_L, lens_L, eye_L (cam_L is an empty at x = +31.5) └─ cam_R → cam_body_R, lens_R, eye_R (cam_R is an empty at x = −31.5) ``` `eye_L` / `eye_R` are empty nodes at the assumed optical centre (lens front face, **[?] assumed**; for `module38` 18 mm in front of the camera origin). Put the virtual cameras there. The same parts exist as separate STL (z-up, link-local) and OBJ (y-up) files. --- ## 3. System architecture ```mermaid flowchart LR subgraph HS["Apple Vision Pro (Wi-Fi 6)"] HP["ARKit head pose
queryDeviceAnchor ~120 Hz [S]"] DISP["Video display
(head-anchored / world-locked quad)"] end subgraph RIG["Rig machine (Raspberry Pi 5 assumed)"] RX["UDP / gRPC pose receiver"] CTRL["Filter + predict + rate-limit
100 Hz loop, watchdog"] SERV["Servo driver
STS3215 bus or PCA9685 PWM"] CAP["Capture 2x USB cam
MJPEG 2x 1280x720 @30"] SBS["Stitch side-by-side 2560x720"] ENC["Encode H.264 / VP8 (software)
+ WebRTC/NDI/SRT"] end subgraph MECH["Pan/tilt head (wired to rig machine)"] PAN["Pan servo (yaw)"] TILT["Tilt servo (pitch)"] CAMS["2x USB cameras, 63 mm apart"] end HP -- "pose 60-90 Hz, UDP 24 B (or VisionProTeleop gRPC :12345)" --> RX RX --> CTRL --> SERV SERV -- "serial / PWM" --> PAN SERV --> TILT CAMS -- "USB" --> CAP --> SBS --> ENC ENC -- "Wi-Fi 6, 8-12 Mbps target [E]" --> DISP ``` **Simulator variant of the same graph** (no hardware): *pose source (real headset or fake sender) → TD pose parser → joint transforms on the GLB → 2 virtual cameras → SBS render → (optional delay model) → NDI/WebRTC out or on-screen*. ### 3.1 Data flows | # | Flow | Direction | Medium | Rate | Payload | Notes | |---|---|---|---|---|---|---| | F1 | Head pose | headset → rig | UDP (proposed) or VisionProTeleop gRPC | 60–90 Hz (UDP) / ~200 Hz send loop (gRPC) | 24 B packet (§4.1) / 4×4 matrix (§4.2) | The only uplink; **port not specified [?]** | | F2 | Servo command | rig → servos | wired serial (STS) or PWM | 100 Hz loop [E] | goal position per servo | §9 | | F3 | Camera frames | cameras → rig | USB 2.0 HS (UVC, MJPEG) | 30 fps | 2×1280×720 MJPEG | camera + USB is ~43 % of typical latency [E] | | F4 | Video | rig → headset | WebRTC (VisionProTeleop) or NDI/SRT | 30 fps | 2560×720 SBS | the only downlink | | F5 | Frame metadata (recommended) | rig → headset | WebRTC data channel / NDI metadata | per frame | capture-time pan/tilt | enables the world-locked reprojection in §5.4 | --- ## 4. Head pose in (headset → rig) ### 4.1 Recommended wire format: 24-byte UDP packet **[E: project design, servos-power.md §A.5]** Little-endian. `struct.pack('10 %). - **Signalling (local mode):** the rig machine is the WebRTC *offerer*; JSON SDP over **plain TCP :9999**; also HTTP `/webrtc_info` on **:8888**. The rig must accept **inbound TCP 9999 (and 8888) from the headset**. Remote mode uses a room code and Cloudflare Workers signalling/TURN by default (`wss://visionpro-signaling.parkyh9492.workers.dev/ws`) — a **third-party dependency** to be aware of. - **Headset rendering:** VPT shows the stream as a flat **head-anchored video plane** (optionally world-fixed); there is no per-eye stereo rendering in VPT (SBS is shown on one plane unless you write a visionOS app) **[?: confirm when testing]**. ### 5.3 TouchDesigner / NDI route (alternative or simulator output) **[S: research notes + Derivative docs, §17]** | Route | Network | Notes | |---|---|---| | **NDI Out TOP** | LAN | SpeedHQ CPU codec, YUV 4:2:2; "very low latency vs RTSP/H.264, at the cost of higher bandwidth" (Derivative). visionOS receive needs the **NDI SDK for Apple ≥ 6.2** (library `libndi_visionos`), Bonjour `_ndi._tcp` and the multicast entitlement. High-bandwidth NDI stereo 1080p60 can be multi-hundred Mbps. | | **WebRTC DAT + Video Stream Out TOP** | LAN/internet | HW encode only on Windows + Nvidia. H.264 at 8–20 Mbps/eye is gentler on Wi-Fi than NDI. | | **SRT / RTSP** | LAN | RTSP often ~100–500+ ms; no turnkey Apple API for SRT. | | **Syphon / Spout** | **same machine only** | useful only to bridge into another encoder. | visionOS receiver options (for whoever writes the headset app): ShaderGraph *Camera Index Switch* material, `LowLevelTexture`/`TextureResource`, or CompositorServices. ### 5.4 Recommended display model: world-locked quad, reprojected **[E: design recommendation]** The pan/tilt loop adds lag *w × T* between head and picture (angular mismatch = head speed × total latency). At ~210 ms total, a 30 / 90 / 250 / 380 °/s head turn leaves the picture **6 / 19 / 53 / 80° behind** **[D]**. Therefore: send the capture-time `pan/tilt` with each frame (F5), render the video as a world-locked quad, and rotate it by (capture-time rig angle − current head angle). Motion-to-photon comfort tiers quoted in the research: < 20–40 ms comfortable, 50–80 ms usable, > 100 ms risky **[S: research notes]** — this rig will be in the "risky" tier unless tuned; reprojection is the mitigation. ### 5.5 Network facts **[S]** - Vision Pro: Wi-Fi 6 (802.11ax) only — 5 GHz, 80 MHz, 2×2, 1200 Mbps PHY. Raspberry Pi 5 onboard Wi-Fi is 802.11ac (Wi-Fi 5) — put the **rig machine on Ethernet** (scenario a, §10). Wi-Fi 6 5 GHz idle jitter avg 4.2 ms, max spike 22 ms; Ethernet 0.3/1.4 ms (jitter.is, via latency-budget.md). - Practical: dedicated 5 GHz SSID, rig wired to the AP, nothing else on the SSID. --- ## 6. Hardware bill of materials Prices are as shown on the cited page on **2026-11-03** or earlier and are indicative **[S]**. "Owned" = already in the user's hands. ### 6.1 Core build (recommended, "heavy" variant `module38_sts3215`) | Item | Part / number | Key specs | Price | Source | |---|---|---|---|---| | Rig machine | Raspberry Pi 5 (assumed; "or similar") | no H.264 HW encoder; onboard Wi-Fi 802.11ac; 40-pin header (§7) | — | raspberrypi.com | | Pi PSU | Official 27 W USB-C PD, 5.1 V / 5 A (Adafruit #5814) | USB total current limit rises to 1.6 A with a 5 A supply | US$14.04 | Adafruit | | Servo ×2 | **Feetech STS3215, 7.4 V variant** (Waveshare **ST3215-7.4V**, SKU 33014) | 45.2×24.7×35 mm, 55 g, **0.192 s/60° (312 °/s) no-load @7.4 V**, stall torque **19.5 kgf·cm @7.4 V** (conflict: 19 / 16.5 elsewhere), rated load 5 kgf·cm, 4096 steps/360° (0.0879°), TTL bus 38 400–1 M baud (default 1 M), no-load 150 mA, **locked 2.5 A**, 4–7.4 V | US$16.99 or 21.99 (two variants on the page; mapping unresolved [?]) | Waveshare docs | | Servo bus adapter | **Waveshare Bus Servo Adapter (A)**, SKU 25514 | USB Type-C and UART, 5.5×2.1 mm DC jack, 42×33 mm; wiki input **9–12.6 V (match servo voltage)** and "actually 5–8.4 V can also be input" → **bench-confirm before feeding 7.4 V [?]** | US$4.99 | Waveshare | | Servo supply | 7.4 V (2S) source ≥ 5 A stall-capable, fuse ≈ 5–7 A [E] | separate from the Pi, common ground | — | servos-power.md | | Cameras ×2 | **Owned:** Quanrui Vision USB48MP01-QFZ (serial OFZ20240723-XFH9GD3M) | see §6.3 | owned | cameras.csv `CAM-01` | | Mount | 3D-printed `base`, `yoke`, `cam_bar` (+ `horn`) from `models//parts_stl/` | PETG/PLA; masses are estimates (§8.4) | — | this pack | ### 6.2 Alternatives (from servos-power.md §B) **[S]** | Option | Driver | Torque (kgf·cm) | Speed (s/60°) | Size mm / mass | Voltage | Stall / blocking current | Price | |---|---|---|---|---|---|---|---| | **Light:** Savox SH-0257MG+ ×2 | Adafruit PCA9685 (#815, US$14.95) over I²C | 1.8 @4.8 V / **2.2 @6 V** | 0.13 / 0.09 | 22.8×12.0×25.4 / 14 g | 4.8–6 V | blocking 1400 mA, rated 180 mA | €21.95 each. **Marginal for tilt** (needs ≥ 2.5 kgf·cm) | | TowerPro MG90S | PCA9685 | 1.8 / 2.2 (Waveshare listing 2.0/2.8) | 0.10 / 0.08 | 22.8×12.2×28.5 / 13.4 g | 4.8–6 V | not in TowerPro spec (secondary: ~0.9 A @6 V [?]) | US$2.30 | | TowerPro MG996R ×2 (for the `brio_mg996r` variant) | PCA9685 | 9.4 / **11** | 0.17 / **0.14** (Waveshare listing: 0.19/0.18; latency doc uses 0.15 — conflict [?]) | 40.7×19.7×42.9 / 55 g | 4.8–7.2 V | stall 2.5 A @6 V, running 0.5–0.9 A | US$4.70 | | DSSERVO DS3218 | PCA9685 | 18 @5 V / **21.5 @6.8 V** | 0.16 / 0.14 | 40×20×40.5 / 60 g | 4.8–6.8 V | stall 1.8 A @5 V, 2.2 A @6.8 V | US$30.18–32.09 | | Savox SC-1258TG+ | PCA9685 | 9.6 / 12.0 | 0.10 / 0.08 | 40.3×20.2×37.4 / 64 g | 4.8–6 V | blocking 5 A, rated 120 mA | €69.95 | | Pololu Micro Maestro #1350 | (driver alternative to PCA9685) | — | — | — | — | serial-timeout failsafe sends servos home if the Pi dies | US$27.95 | PCA9685 facts **[S: Adafruit guide]**: default I²C address **0x40**; logic VCC 3–5 V; servo power **V+ 5–6 V** (board accepts up to 12 V per Adafruit — do not exceed the servo's rating); **never connect VCC to V+**; `OE` pin disables all outputs; 25 mA max per output pin; 12-bit PWM ≈ 4.9 µs per count at 50 Hz **[D]**. ### 6.3 Cameras (selected rows of `data/cameras.csv`, 20 rows total; all numbers from that sheet) | ID | Model | Sensor / max res | 1080p fps | Size mm | Weight g | FOV / lens | Price | Notes | |---|---|---|---|---|---|---|---|---| | CAM-01 | Quanrui Vision / RERVISION (锐尔威视) / ELP (same Shenzhen supplier family) Quanrui Vision 全瑞视讯 USB48MP01-QFZ (owned; same family as RERVISION RER-USB48MP01 / ELP-USB48MP01) | Samsung S5KGM1ST04-FG9 (GM1) 48MP / 8000x6000 (3 fps MJPEG per rervision.cn; 5 fps per ELP page; 4000x3000 12-15 fps depending on page) | 30 (MJPEG) | 38 x 38 (double-deck board; height not published) | 100 (ELP AF70 page only; rervision.cn publishes none - looks high for a bare 38x38 board, weigh your unit) | Autofocus M8.15 lens, ~82 deg D / ~70 deg H (this QFZ lens variant unconfirmed; ELP also lists AF100 / KAF70 / KAF100 variants whose FOV was not verified) | Owned unit - n/a. Same-family ELP-USB48MP01-AF70: USD 116.99 (webcamerausb.com, as shown 2026-11-03) | '-QFZ' suffix not found in any public listing; specs are the family's. Spec inconsistencies between pages (fps at 8000x6000/4000x3000, S/N, weight); 1080p30 and | | CAM-02 | ELP (Ailipu) ELP-USBFHD08S-L60 (OV4689 high-speed) | OmniVision OV4689 / 1920x1080 | 60 (MJPEG) | 38 x 38 | ~30 (vendor page) | S-mount fixed focus; page lists 3.6 mm standard, options 2.1/2.8/6/8/12 mm; model L60 = 6 mm lens, FOV not stated; 'FOV(D)170' appears only in the optional-lens line | USD 65.10 (webcamerausb.com, as shown 2026-11-03) | Two 1080p60 MJPEG streams on USB 2.0: check bandwidth per controller. | | CAM-05 | ELP (Ailipu) ELP-USB3D1080P02-V83 (dual JX-F35 1080p synchronized stereo) | 2x JX-F35 (1/2.7 inch, 3.0 um pixels) / 3840x1080 (side-by-side stitched; each eye 1920x1080) | 60 (3840x1080 MJPEG @60 = 2x 1080p60) | 80 x 16.5 (board) | ~45 (webcamerausb.com page) | M12 no-distortion lens, ~80-85 deg HFOV / 56 deg VFOV; other lenses optional (65-187 deg HFOV); baseline not published | USD 78.23 (webcamerausb.com, as shown 2026-11-03) | Bare board, no enclosure. Measure baseline yourself (80 mm board suggests lens spacing < 80 mm, not confirmed). | | CAM-06 | ELP (Ailipu) ELP-USBFHD06H-L100 (IMX323/IMX322, on-camera H.264) | Sony IMX322 per svpro.cc spec table (other ELP/Amazon listings use IMX323 in the title; same board) / 1920x1080 | 30 (H.264, MJPEG) | 38 x 38 (compatible 32 x 32) | ~30 (vendor spec table; same page's 'Weight 0.25 kg' is shipping/packaging) | M12 'no distortion' L100 lens; FOV degrees not stated (other lenses: 2.1/2.8/3.7/6 mm, 170/180 deg fisheye) | USD 54.60 (svpro.cc, as shown 2026-11-03); USD 57.20 (webcamerausb.com, as shown by search index) | Verify H264 appears in v4l2-ctl --list-formats-ext; H.264 GOP/bitrate controls unknown. | | CAM-10 | Arducam Arducam B0385 OV9782 colour global shutter USB (external trigger) | OmniVision OV9782 (colour) / 1280x800 | n/a (1280x800 max) | 38 x 38 (datasheet; hole pitch 34x34 / 28x28) | unknown | 70 deg H, EFL 2.8 mm, distortion <1 %, M12x0.5 | USD 59.99 (arducam.com via search-index copy, page blocked to fetcher); Amazon US 'currently unavailable' (checked 2026-11-03); iot-store.com.au out of stock | Stock is patchy. | | CAM-17 | Logitech Logitech Brio 4K (V-U0040 / 960-001746) | onsemi AR1335 13MP RGB (+ OmniVision OV7251 IR) / 4096x2160 (4K DCI) | 60 (MJPEG) | 102 x 27 x 26.5 (webcam only; clip adds 36.3 x 23.8 x 62.7) | 65 (Logitech datasheet and product page, excl. cable); 85 (Logitech support spec page, unclear basis); mounting clip 69.2 g separate. User's ~63 g not found in any source | 90 / 78 / 65 deg diagonal presets (Logi Tune, Windows/macOS only); 5x digital zoom; autofocus | USD 169.99 (logitech.com US, as shown 2026-11-03) | Mic-array and Windows Hello IR camera add bulk you do not need. | | CAM-20 | Stereolabs Stereolabs ZED Mini (ZED-121210) | 2x 4MP 1/3-inch sensors (models not published) / 2x 2208x1242 (2K @15 fps) | 30 (2x 1920x1080 side-by-side) | 124.5 x 30.5 x 26.5 | 60 (Stereolabs store page) | 102 deg H x 57 deg V; baseline 63 mm | USD 399 (stereolabs.com store, as shown 2026-11-03) | Most expensive; listed as ready-made benchmark. 720p60 side-by-side is available as a lighter encode load. | **Owned camera facts to carry into the simulator:** USB 2.0 High Speed UVC, MJPEG/YUY2, 38×38 mm board (**depth not published → 15 mm is ASSUMED [?]**), weight **100 g from one ELP page only — looks too high, weigh it [?]**, autofocus M8.15 lens ~82° D / ~70° H **for the ELP-family listing; the '-QFZ' lens variant is unconfirmed [?]**; the '-QFZ' suffix is not found in any public listing. Current draw 550–610 mA per ELP-USB48MP01 listing → 1.10–1.22 A for two **[S; Quanrui unit unverified]**. No hardware sync between the two cameras **[S: sheet]** (use `CAM-10` Arducam OV9782 with external trigger or `CAM-05` ELP dual-lens if sync matters). ### 6.4 Power plan **[S/E as tagged]** - **Pi** from its own 27 W PD supply. **Servos on a SEPARATE supply with a common ground; never power servos from the Pi pins.** - STS3215: 7.4 V (2S) direct into the Waveshare adapter (after confirming the adapter's input range, §6.1). PWM servos: 5–6 V via a UBEC (DS3218 up to 6.8 V): Pololu D24V50F5 (fixed 5 V / 5 A, US$32.95) or S18V20F5 (2 A only, US$29.95, light build only). - Stall rail currents for 2 servos **[S]**: STS3215 **5.0 A**; MG996R 5.0 A; DS3218 4.4 A @6.8 V; SH-0257MG+ 2.8 A; SC-1258TG+ 10 A. Running ≈ 0.4–1.0 A. Pi + cameras ≈ 5 V / 2–3 A **[S]**. - Fuse ≈ 5–7 A **[E]**; bulk capacitors 470–1000 µF per 1–2 servos and 1000–2200 µF at the supply **[E: rule of thumb, unsourced]**. - Camera current: USB48MP01 550–610 mA each; Brio 500 mA declared (≈896 mA reported once) → use a powered USB 3 hub for Brios **[S]**. --- ## 7. Wiring and pinouts Diagram: `data/wiring.svg` (and the website's wiring figure). ### 7.1 STS3215 path (recommended) ``` Pi 5 USB-A ──USB-C──▶ Waveshare Bus Servo Adapter (A) (UART/USB selector set to USB) 2S 7.4 V supply ──DC jack 5.5×2.1 mm──▶ adapter power in (fuse 5–7 A in line [E]) Adapter bus connector ──▶ STS3215 #1 (pan, ID 1) ──daisy chain──▶ STS3215 #2 (tilt, ID 2) Cameras: 2× USB (UVC) directly to the Pi (or via powered hub) ``` - The servo 3-pin connector pinout (signal / V+ / GND order) is **not published on the Feetech/Waveshare pages inspected → UNVERIFIED [?]; read the servo label or datasheet before powering.** Use the supplied cables. - Servo IDs: factory default is **1** (Feetech); set one servo at a time (power them individually) to ID 2 **[S: Feetech wiki 4.4]**. - Safe bring-up (Feetech wiki 4.x) **[S]**: connect one servo, ping, small slow move first, only then change persistent settings. ### 7.2 PCA9685 path (light/alternative builds) | Pi 5 40-pin header | PCA9685 | Note | |---|---|---| | pin 1 — 3V3 | VCC | logic power | | pin 3 — GPIO2 / SDA1 | SDA | 1.8 kΩ pull-ups to 3.3 V already on the Pi | | pin 5 — GPIO3 / SCL1 | SCL | | | pin 6 — GND | GND | plus servo-supply GND tied here (common ground) | | — | V+ | servo supply 5–6 V from the UBEC, **not** from the Pi pins and **never** tied to VCC | Also on the Pi 5 header **[S: raspberrypi.com docs]**: pins 2 and 4 = 5 V; pin 8 = TXD (GPIO14), pin 10 = RXD (GPIO15); GPIO is **3.3 V and not 5 V tolerant**. The fan header is JST-SH 4-pin (+5 V / PWM / GND / tach). **`pigpio` and `RPi.GPIO` do not work on the Pi 5**; hardware PWM is on GPIO12/13/18/19 via `dtoverlay=pwm-2chan`. Software PWM on Linux jitters — the PCA9685 or the bus servos avoid it. --- ## 8. Mechanical All values from `cad/mb001_rig.scad` (OpenSCAD 2021.01), millimetres, Z up **[S: project CAD]** unless tagged. Files in `models/` were generated from it by the scripts in `cad/`. ### 8.1 Parameters | Parameter | Value | Meaning | |---|---|---| | `cam_spacing` | **63** | centre-to-centre distance of the two cameras (stereo baseline / IPD) | | `tripod_hole` | 6.6 | base-plate hole for a tripod screw | | `wall` | 2.4 | printed wall | | `base_t` | 6 | base-plate thickness | | `deck_x` | 100 | base deck width (X) | | `deck_y0`, `deck_y1` | −112, 46 | base deck extent in Y (length 158) | | `horn_t`, `yoke_t`, `arm_t`, `bar_t` | 3, 4, 4, 4 | horn, yoke plate, yoke arm, camera-bar thicknesses | | `gap` | 1 | clearance | | derived | `bar_len = cam_spacing + cw + 20`; `bar_h = ch + 14`; `z_s = base_t + sH − 4`; `z_y = z_s + horn_t + 1`; `arm_x = bar_len/2 + gap + arm_t/2`; `tilt_z = z_y + yoke_t + bar_h/2 + 6`; `yoke_w = 2·arm_x + arm_t` | (`cw`,`ch` camera width/height; `sH` servo height) | Servo table in the SCAD `[L, W, H, shaft_off, flange_w, horn_d]` **mm**: mg90s [22.8,12.2,28.5,5.5,32.5,20]; sh0257 [22.8,12.0,25.4,5.5,32.5,20]; mg996r [40.7,19.7,42.9,10,53,25]; ds3218 [40,20,40.5,10,54,25]; **sts3215 [45.2,24.7,35,10,45.2,25]**. L/W/H are sourced; **`shaft_off`, `flange_w`, spline height and `horn_d` are approximate [?]**. Camera boxes: `module38` = 38×38×15 (**depth assumed [?]**); `brio` = 27×102×26.5 mounted rotated 90° so the lens axes are 63 mm apart (**check the lens is centred in the housing [?]**). Ghost parts (Pi 5 56×85×17 at x −28, y −112.5; PCA9685 62×14×8 [size unsourced]; UBEC 14×30×10 [placeholder]) are layout aids, not final. Pi 5 mounting-hole pattern is 58×49. ### 8.2 Derived frames **[D]** (match OpenSCAD echoes; CAD frame, mm) | Variant | servo | camera | pan_link z | tilt_link z | yoke_w | bar_len | bar_h | cameras at (x, y, z) | |---|---|---|---|---|---|---|---|---| | `module38_sts3215` | sts3215 | module38 38x38x15 | 38 | 77 | 131 | 121 | 52 | (∓31.5, 2, 77) | | `module38_sh0257` | sh0257 | module38 38x38x15 | 28.4 | 67.4 | 131 | 121 | 52 | (∓31.5, 2, 67.4) | | `brio_mg996r` | mg996r | brio 27x102x26.5 | 45.9 | 116.9 | 120 | 110 | 116 | (∓31.5, 2, 116.9) | Pan joint origin is at `z = z_s + 1` on the vertical axis (the axis passes through x = y = 0; the height along it is a modelling convention). Tilt joint origin is `tilt_z` (axis +X through x = 0). Cameras sit at `x = ∓31.5` (left = −31.5 in CAD), `y = +2`, `z = tilt_z`. Note: in the SCAD preview the pan servo/horn are static; physically the horn turns with the yoke — in the URDF/GLB the horn belongs to the yoke. ### 8.3 Rotation limits — **conflict, flagged** | Source | Pan | Tilt | Tag | |---|---|---|---| | `servos-power.md` | ±90° | −30…+45° | [E] design choice | | VPT deep-dive example | — | −30…+60° | [E] | | CAD note | — | "≈ ±30°" from a collision sweep (cam_bar can hit the yoke plate at large angles) | [E], not verified | | **Implemented in URDF/GLB extras** | **±90°** | **±30°** | most conservative; change in `params.json` | Add soft limits 5° inside the hard stops in software **[E]**. Test the real mechanical limit before widening. ### 8.4 Masses (kg in URDF; g here) | Part (GLB node / STL name) | `module38_sts3215` | `module38_sh0257` | `brio_mg996r` | |---|---|---|---| | base | 43.4 g [E] | 41.9 g [E] | 43.7 g [E] | | yoke | 14.8 g [E] | 14.8 g [E] | 17.5 g [E] | | cam_bar | 9.5 g [E] | 9.5 g [E] | 19.6 g [E] | | horn | 0.6 g [E] | 0.4 g [E] | 0.6 g [E] | | servo_pan | 55 g [S] | 14 g [S] | 55 g [S] | | servo_tilt | 55 g [S] | 14 g [S] | 55 g [S] | | cam_body_L | 100 g [?] | 100 g [?] | 65 g [S] | | cam_body_R | 100 g [?] | 100 g [?] | 65 g [S] | | lens_L | 0 (not modelled) | 0 (not modelled) | 0 (not modelled) | | lens_R | 0 (not modelled) | 0 (not modelled) | 0 (not modelled) | | pi | 0 (not modelled) | 0 (not modelled) | 0 (not modelled) | | pca9685 | 0 (not modelled) | 0 (not modelled) | 0 (not modelled) | | ubec | 0 (not modelled) | 0 (not modelled) | 0 (not modelled) | Tags: servo bodies **[S]** (STS3215 55 g, SH-0257 14 g, MG996R 55 g); camera mass `module38` 100 g **[?]** (single ELP listing; weigh it), Brio 65 g **[S]** (datasheet; conflicting values 63 g guide / 85 g listings); printed parts **[E]** = STL volume × 1.24 g/cm³ × 0.35 fill (all three numbers assumed); Pi, PCA9685, UBEC set to **0 and excluded [?]** (no sourced mass). **URDF inertias are computed from these and are placeholders, not measurements.** Joint `damping`/`friction` = 0.01 are **placeholders [E]**. Effort = servo stall torque × 0.0980665 N·m/kgf·cm; velocity = no-load speed, **not derated**. ### 8.5 Torque check **[S/D]** (two analyses exist — both are included, they use different assumed payloads) - `servos-power.md` §B.1: light payload 0.24 kg, COM 30 mm → tilt ≥ **2.5**, pan ≥ 0.6 kgf·cm; heavy payload 0.40 kg, COM 50 mm → tilt ≥ **7.5**, pan ≥ 2.3 kgf·cm (both already include a ×3 margin). STS3215 stall 19.5 → 7.8× the light-case tilt requirement. (Waveshare's "rated load" of 5 kgf·cm at 7.4 V is *below* the heavy 7.5 requirement — continuous holding near the heavy case is outside the rated load [?].) - `latency-budget.md` §4.3: small 0.15 kg at 40 mm, large 0.30 kg at 60 mm → needs ~2–3 and ~6–9 kgf·cm. --- ## 9. Control loop ### 9.1 Pipeline **[S: servos-power.md §A.3/A.5; values E where tagged]** ``` UDP in ─▶ magic/version/seq check (drop stale) ─▶ yaw/pitch → pan/tilt (§2.3) ─▶ One-Euro filter ─▶ rate limit (deg/s) ─▶ interpolate/extrapolate onto a fixed 100 Hz clock ─▶ clamp to soft limits ─▶ map to servo units ─▶ wired write ``` | Parameter | Value | Tag | |---|---|---| | Loop rate | **100 Hz** fixed clock (servo thread separate from network thread) | E | | One-Euro `mincutoff` / `beta` / `dcutoff` | **≈ 1.0 Hz / 0.05–0.1 / 1.0 Hz** at ~100 Hz; tune by eye | S (start values: https://gery.casiez.net/1euro/, https://github.com/casiez/OneEuroFilter) | | Smoothing budget | **≥ 10 Hz effective cutoff while moving, 10–15 ms lag**; a first-order low-pass adds ≈ 1/(2π·fc): 20 Hz → 8 ms, 10 Hz → 16 ms, 5 Hz → 32 ms. (One-Euro's cutoff rises with speed, so the 1 Hz `mincutoff` and the ≥ 10 Hz-when-moving rule are consistent.) | D | | Prediction horizon | **30–60 ms** on the pose | E | | Extrapolation | ≤ **50 ms** past the last packet, then clamp | E | | Rate limit `MAX_RATE` | **120 °/s** to start (tune against servo speed) | E | | Dead band | **0.3–0.5°** (stops jitter-hunting) | E | | Servo lag target | ≤ 40 ms slow turns; < 60 ms extra on quick turns | E | | Servo speed target | ≥ ~400 °/s no-load, ≥ ~280 °/s loaded (×0.7 derating is an estimate); keep STS3215 speed limit just under 300 °/s | E | | Head-speed design range | slow 30–90 °/s; quick 250–380 °/s; a 780 °/s reflex is **not** tracked | S (research; underlying papers *not re-verified* [?]: "Utah thesis", PMC6444911 were seen only in search summaries) | | Soft limits | 5° inside hard limits | E | ### 9.2 Watchdog / failsafe **[E: design from servos-power.md §A.3]** - `T_HOLD = 250 ms` with no valid packet → **hold** the last commanded angle (STS3215 keeps holding torque; keep PWM servos powered). - `T_PARK = 2 s` → **glide to the park pose (pan 0°, tilt 0°)** at 20 °/s, or **torque off** (`reg 40 = 0`) if the park pose is unsafe. - On resume, ramp using the same rate limit — **never snap**. - Flag bit1 (pause/park) forces park; bit0 recenters the yaw/pitch reference. - Process supervision: systemd `WatchdogSec=` on the control service. Pololu Maestro (if used) has a serial timeout that sends servos home if the Pi dies **[S]**. - Clamp every command to the soft limits *before* it leaves the process. ### 9.3 Reference implementation (Python, hardware-independent) **[D]** ```python import math, time class OneEuro: def __init__(self, rate=100.0, mincutoff=1.0, beta=0.07, dcutoff=1.0): self.rate, self.mc, self.beta, self.dc = rate, mincutoff, beta, dcutoff self.x = None; self.dx = 0.0 @staticmethod def _a(rate, cutoff): # smoothing factor of a 1st-order low-pass tau = 1.0 / (2 * math.pi * cutoff); te = 1.0 / rate return 1.0 / (1.0 + tau / te) def __call__(self, x): if self.x is None: self.x = x; return x dx = (x - self.x) * self.rate self.dx += self._a(self.rate, self.dc) * (dx - self.dx) fc = self.mc + self.beta * abs(self.dx) self.x += self._a(self.rate, fc) * (x - self.x) return self.x def slew(cur, target, max_rate, dt): step = max_rate * dt return cur + max(-step, min(step, target - cur)) # loop @100 Hz: pan = slew(pan, clamp(filt_pan(-yaw)), 120, 0.01); tilt likewise ``` ### 9.4 Mapping angles to servo units - **STS3215** **[S: Feetech STS memory table]**: 4096 counts per 360° → **0.0879°/count** (11.378 counts/°). Goal position register 42 (2 bytes). Calibrate the mechanical centre as 2048 by mounting the horn square and writing **register 40 = 128** ("calibrate current position to 2048") with the shaft unloaded and still; do it **once** (repeating accumulates offset). Then `goal = 2048 + sign · round(angle_deg / 0.0879)`; pan ±90° = ±1024 counts. `sign` depends on how the horn is mounted — test and store `pan_sign`, `tilt_sign` **[?]**. BIT15 of the goal is the direction bit for negative values per Feetech; in normal position mode just write 0–4095. Optionally set the angle limits (registers 9 and 11, EEPROM → write `lock` 55 = 0 first, set back to 1 after) as a hardware safety stop. - **PWM servos via PCA9685:** typical hobby servo pulse 500–2500 µs over 50 Hz frame, 12-bit counts (`count = pulse_us / 4.88` at 50 Hz **[D]**); calibrate per servo **[?]** — no per-servo pulse limits are sourced here. ### 9.5 STS3215 serial protocol notes **[S: Feetech wiki — protocol and STS memory table]** - Half-duplex TTL, **8N1, 1 Mbps default**, baud 38 400–1 M (codes 0–7: 1 M, 500 k, 250 k, 128 k, 115 200, 76 800, 57 600, 38 400). - Frame: `FF FF ID LEN INSTR [PARAMS…] CHK`; `LEN` counts INSTR + PARAMS + CHK; `CHK = ~(ID + LEN + INSTR + PARAMS) & 0xFF`; broadcast ID `0xFE`; **16-bit values are low byte first (STS)**. - Instructions: Ping 0x01, Read 0x02, Write 0x03, Reg Write 0x04, Action 0x05, Sync Read 0x82, **Sync Write 0x83**. - Sync Write (one frame moves both servos): `FF FF FE LEN 83 ADDR L [ID d0 … d(L−1)] × N CHK` with `LEN = (L+1)·N + 4`. - **Examples [D]** (computed in Python from the formulas above; **not tested on hardware**): both servos to centre (ID 1, ID 2 → 2048 at addr 0x2A, L = 2): `ff ff fe 0a 83 2a 02 01 00 08 02 00 08 35`; ID 1 → 3072 (+90°), ID 2 → 1707 (≈ −30°): `ff ff fe 0a 83 2a 02 01 00 0c 02 ab 06 88`. | Register (dec / hex) | Name | Bytes | Unit / note | |---|---|---|---| | 40 / 0x28 | Torque switch | 1 | 0 off, 1 on, 2 damping, **128 = set current position to 2048** | | 41 / 0x29 | Acceleration | 1 | 8.79 °/s² per count; 0 = max | | 42 / 0x2A | **Goal position** | 2 | 0.0879°/count; BIT15 = direction bit | | 46 / 0x2E | Running speed | 2 | 0.732 RPM per count (or 0.0146 RPM depending on phase BIT2); 0 = max when phase BIT3 = 1 | | 48 / 0x30 | Torque limit | 2 | 0.1 %; default 1000 (from reg 16) | | 55 / 0x37 | Lock flag | 1 | 0 = EEPROM writes persist | | 56 / 0x38 | Present position | 2 | mask BIT15 | | 58 / 0x3A | Present speed | 2 | | | 60 / 0x3C | Present load | 2 | 0.1 %; BIT10 = direction | | 62 / 0x3E | Present voltage | 1 | 0.1 V | | 63 / 0x3F | Present temperature | 1 | °C | | 66 / 0x42 | Moving flag | 1 | | | 69 / 0x45 | Present current | 2 | 6.5 mA per count | Also: reg 5 = ID (0–253), reg 6 = baud code, reg 21–23 = position-loop P/D/I, regs 13/14/15 = temperature/voltage limits, 28 = protection current. Writing goal position turns torque on automatically. Python libraries: Feetech/Waveshare SDKs (community registers reference: https://github.com/commanderfun/STS3215/blob/main/REGISTER_REFERENCE.md — community, not vendor). --- ## 10. Latency budget (ALL FIGURES ARE ESTIMATES [E] unless marked) Source: `MB-001-latency-budget.md` (project research; anchors in §10.2). Scenario **(a)** = rig machine on Ethernet, headset on Wi-Fi 6; **(b)** = rig machine is the Wi-Fi AP / direct link; **(c)** = remote site over internet/VPN, adds one WAN round trip *R* plus ~7 ms. ### 10.1 Per-stage table (ms): best / typical / worst — scenario (a) | Stage | What | Best | Typical | Worst | |---|---|---|---|---| | S1 | Head pose capture and send | 1 | 6 | 12 | | S2 | Wi-Fi uplink | 2 | 4 | 25 | | S3 | Rig machine + servo command | 1 | 3 | 20 | | S4 | Servo response (pan/tilt move) | 15 | 40 | 90 | | S5 | Camera exposure + USB | 50 | 90 | 110 | | S6 | MJPEG decode ×2 + SBS compose (Pi CPU) | 6 | 14 | 25 | | S7 | x264 encode (software) | 8 | 20 | 35 | | S8 | Wi-Fi downlink | 3 | 6 | 40 | | S9 | Jitter buffer | 2 | 7 | 20 | | S10 | Hardware decode on the headset | 2 | 5 | 10 | | S11 | Render / scan-out | 8 | 15 | 30 | | **Total (a)** | | **98** | **210** | **417** | Scenario (b): **96.5 / 207 / 384** (S2 1.5/3/12, S8 2/4/20). Scenario (c): typical **237 / 277 / 367 ms** for WAN round-trip R = 20 / 60 / 150 ms (example R values, not measurements). A realistic tuned target is **~150–170 ms typical** [E]. Camera + USB (S5) is ~43 % and the servo (S4) ~19 % of the typical total; the network (S2 + S8) is only ~10 ms **[D]**. Biggest wins: lower-latency cameras (shorter exposure, higher fps), faster servos, avoiding software encode. ### 10.2 Measured anchors **[S]** | Anchor | Value | Conditions | |---|---|---| | VPT wireless stereo 2560×720 | mean 59.9 ms, p50 52, p95 117 | synthetic frames, **round trip**, not glass-to-glass; 1000 trials, 30 fps, MacBook Pro M2 → Vision Pro, Wi-Fi 6 | | VPT wireless stereo 3840×1080 | mean 56.6, p50 64, min 16, p95 113, p99 140, max 179 | same | | VPT wired stereo 3840×1080 | mean 23.0, p95 40 | same | | Transitive | direct 110 ms, local WebRTC 130 ms, TURN 170 ms; jitter buffer ~7 ms local | USB webcam → display, 30 fps (via latency-budget.md) | S7–S11 of the table sum to 23 / 53 / 135 ms vs VPT's measured ~52–60 ms mean round-trip, consistent in order of magnitude **[D]**. ### 10.3 Simulator relevance A simulator that wants to *feel* like the rig should insert a delay model (§11.6): ~210 ms typical total, of which the pose→servo→camera loop (S1–S5) is ~143 ms typical. --- ## 11. TouchDesigner implementation guide > **Nothing in this section has been run in TouchDesigner (no TD on the box).** Facts about TD come from the Derivative docs (§17) or are marked [?] general knowledge to verify. Acceptance tests in §13 are designed to catch convention errors early. ### 11.1 Import the model - **Preferred: `glTF In COMP`** (https://docs.derivative.ca/GlTF_In_COMP). Accepts `.gltf`/`.glb` 2.0 and builds **nested Geometry COMPs that follow the node hierarchy** **[S]**; pulse **Import** after setting the file. Then you get `…/base/yoke/cam_bar/…` as nested Geometry COMPs. Check the actual names in the imported network (TD may sanitise names) **[?]**. - **Alternative: `FBX COMP`** (https://docs.derivative.ca/FBX_COMP) — imports `.fbx` and `.obj`, has an **Import Scale** parameter, and caches to `.tdc` files in `TDImportCache` **[S]**. The FBX files in this pack were re-imported in Blender to confirm hierarchy and units; **TD's handling is untested [?]**. - **Units: keep millimetres.** One TD unit = 1 mm in this pack. Camera near/far, light distances and ground-plane size must be set in mm (the rig is ~160 mm long; the base deck is 100×158 mm). If you scale by 0.001 instead, scale *everything*, including the IPD (0.063). - **Axes:** TD is y-up and right-handed like glTF, so **no axis conversion is expected [D]**. Verify with test T2 below. - Materials are glTF PBR; give the scene a Light COMP / environment light so parts are visible (the `eye_*` empties and `cam_L/R` empties carry no geometry). ### 11.2 Receive head pose Use a **UDP In DAT** (https://docs.derivative.ca/UDP_In_DAT): parameters `active`, `port`, `format` (**One Per Message**, `permessage`) and `callbacks`; it captures all messages without queueing/buffering **[S]**. Callback signature `onReceive(dat, rowIndex, message, bytes, peer)` **[S]**. Example (untested) **[D]**: ```python import struct def onReceive(dat, rowIndex, message, bytes, peer): if len(bytes) != 24: return magic, ver, flags, seq, t_us, yaw, pitch = struct.unpack_from(' drop return parent().store('last_seq', seq) c = op('pose_const') # Constant CHOP with channel names yaw, pitch, flags c.par.value0 = yaw; c.par.value1 = pitch; c.par.value2 = flags parent().store('t_rx', absTime.seconds) # for the watchdog (§9.2) return ``` For OSC instead of UDP, use `OSC In CHOP/DAT` with the same two floats; any wire format is fine as long as the **sign convention in §2.3** is kept. ### 11.3 Condition the pose Port §9.3 (One-Euro, slew limit, deadband, watchdog hold/park) into a Script CHOP or an Execute DAT running per frame (60 fps cook = 16.7 ms steps; use real `dt`). Output channels: `pan = −yaw`, `tilt = +pitch`, clamped to the limits in §8.3. A simulator may skip prediction; keep the structure so you can compare filter settings visually. ### 11.4 Drive the joints - `…/yoke` Geometry COMP: **`ry = pan`** (degrees, about its own origin = the pan axis). - `…/yoke/cam_bar` Geometry COMP: **`rx = −tilt`**. - Because every node's pivot sits on its joint axis and each node rotates about a single axis, rotation order does not matter **[D]**. Confirm the nested Geometry COMPs' `tx ty tz` hold the GLB node translations (so pivots are where §2.4 says) **[?]**. - Export the CHOP channels to these parameters (CHOP export or `par.ry.expr`). ### 11.5 Virtual stereo cameras Two **Camera COMP**s, parallel (not toed-in), 63 mm apart, at the `eye_L` / `eye_R` poses. The Camera COMP's field of view is set with `viewanglemethod` (horizontal FOV / vertical FOV / focal+aperture) plus `fov` or `focal`/`aperture`, and `near`/`far` **[S: https://docs.derivative.ca/Camera_COMP]**. Two ways to place them (A is robust, B is less code but needs verification): - **(A) Closed form [D]** — compute the pose per frame in a Script CHOP / expression, in the glTF frame, mm. With `pz` = pan-link height, `tz` = tilt-link height (from §8.2: `module38_sts3215` pz = 38, tz = 77), side = +31.5 for the *left* eye and −31.5 for the right: `p_eye = Ry(pan) · ( Rx(−tilt) · (side, 0, 2 + 18) + (0, tz − pz, 0) ) + (0, pz, 0)` (the "2" is the camera's y offset, 18 = assumed lens-front offset) Orientation of the *rig* looking direction: `R = Ry(pan) · Rx(−tilt)`, looking along its local +Z. **Camera COMPs look down −Z by default [?: standard TD behaviour, verify]**, so use `R · Ry(180°)` for the Camera COMP's rotation (i.e. add `ry = 180` in the camera's local frame) — **this is the single most likely sign/handedness mistake; test T4 checks it.** - **(B) Parent to the model.** Put the Camera COMPs under/with the `eye_L`/`eye_R` nodes (or follow them with an Object CHOP in world space) and add the 180° yaw. Check that the imported nested COMPs really propagate transforms to child Camera COMPs **[?]**. Camera intrinsics: simulate the real cameras. Default horizontal FOV **70° [?]** (the ELP-family lens is ~82° diagonal / ~70° horizontal; the '-QFZ' lens is unconfirmed); resolution 1280×720 each; near 5 mm, far 5000 mm **[E]**. Provide a per-variant override. Real lenses are rolling-shutter, autofocus, with some distortion — not simulated unless asked. ### 11.6 Render, delay model, output 1. Two **Render TOP**s (1280×720) — one per Camera COMP, same Geometry and Light COMPs. Add a visible environment (floor grid, a few coloured posts at known angles) so rotation and parallax are evident. 2. Combine into a **2560×720 side-by-side** frame **[ left | right ]** (Layout TOP or two Composite/Transform TOPs). 3. **Optional delay model** (so the sim feels like the rig): delay the video by ~(S5+S6+S7+S8+S9+S10+S11) and the joint angles by (S1+S2+S3+S4) using the *typical* column of §10 (S1–S4 ≈ 53 ms for the pose → servo-move chain, S5–S11 ≈ 157 ms for camera → display, ≈ 210 ms total) **[E]**; expose each stage as a parameter in `params.json`'s `latency_budget_ms`. A frame-delay line (e.g. Cache TOP + Cache Select TOP) is the usual TD approach **[?]**. 4. **Output:** an `NDI Out TOP` (named e.g. `MB001-sim`) for headset/LAN receivers, or a `Video Stream Out TOP` / `WebRTC DAT`, or a plain Window COMP for desktop use (§5.3). 5. A **fake pose sender** (Python, no headset required) for development: ```python import socket, struct, time, math PORT = 5005 # PROPOSED default [E] -- must match the UDP In DAT s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); seq = 0; t0 = time.monotonic() while True: t = time.monotonic() - t0 yaw = 60 * math.sin(2 * math.pi * 0.2 * t) # +-60 deg, 0.2 Hz (peak ~75 deg/s) pitch = 20 * math.sin(2 * math.pi * 0.3 * t) seq += 1 s.sendto(struct.pack('