// Per-unit HMD calibration config — parsed from the device config JSON // (SteamVR lighthouse cache dump / `downloadconfig`). Dev scaffold for M2; // native Watchman flash read is M3 (ADR locked decision 3). // // Facts this implementation encodes (spike S3, spikes/s3-distortion-truth/FINDINGS.md): // - DISTORT_POLY3 is the DIRECT polynomial d = 1 + k1*r2 + k2*r4 + k3*r6 // (NOT the Monado reciprocal), per channel, center shared per eye. // - Output rescale exactly 0.5/(1 + grow_for_undistort). // - undistort_r2_cutoff is parsed but must NOT be applied within the panel // (corners reach r2 ~ 2.1 > cutoff 1.0 and still follow the polynomial). // - Distortion center convention: center_x = -K[0][2], center_y = +K[1][2]. // - eye_to_head in config is the 3x3 canting rotation; the SteamVR driver // does NOT expose it (parallel_render_cameras: true) — it folds the cant // into the asymmetric projection instead. Never mix the two approaches. // - IPD translation comes from a user setting; config ipd.default_mm is the // fallback default only. #pragma once #include #include namespace sauna { // One color channel's POLY3 distortion. Coefficients verbatim from config. struct DistortionChannel { double center_x = 0.0; // shared per eye on Beyond, stored per channel (B2 may differ) double center_y = 0.0; double k[3] = {0.0, 0.0, 0.0}; // k1, k2, k3 }; struct EyeCalib { DistortionChannel rgb[3]; // red, green, blue (config: distortion_red, distortion, distortion_blue) double grow_for_undistort = 0.0; double undistort_r2_cutoff = 0.0; // NOT applied in the warp (S3) double intrinsics[3][3] = {}; // K; fx=K[0][0], fy=K[1][1], K[2][2]=-1 double eye_to_head[3][3] = {}; // canting rotation (config-only, see header note) double color_mult[3] = {1.0, 1.0, 1.0}; // display_color_mult row for this eye }; // A tracked-frame definition (config "head" / "imu" blocks): basis vectors of // the component frame expressed in head frame, plus its position offset (m). struct FrameCalib { double plus_x[3] = {1.0, 0.0, 0.0}; double plus_z[3] = {0.0, 0.0, 1.0}; double position[3] = {0.0, 0.0, 0.0}; }; struct ImuCalib { FrameCalib frame; double acc_bias[3] = {0.0, 0.0, 0.0}; double acc_scale[3] = {1.0, 1.0, 1.0}; double gyro_bias[3] = {0.0, 0.0, 0.0}; // gyro_scale is ABSENT from this unit's config (LHR-599F3B91) — gate 2's // physical measurement is the authoritative source. Parsed if present. bool has_gyro_scale = false; double gyro_scale[3] = {1.0, 1.0, 1.0}; }; struct HmdConfig { std::string serial; // device_serial_number, "LHR-..." std::string model; // model_number int eye_width_px = 0; int eye_height_px = 0; bool parallel_render_cameras = false; double ipd_default_mm = 0.0; double seconds_from_vsync_to_photons = 0.0; double seconds_from_photons_to_vblank = 0.0; FrameCalib head; ImuCalib imu; EyeCalib eye[2]; // [0]=left, [1]=right }; // Parses the config JSON at `path`. On failure returns false and sets *err. // Distortion type is validated: anything but DISTORT_POLY3 is an error // (only POLY3 is S3-verified; all production Beyonds are POLY3). bool LoadHmdConfig(const std::string& path, HmdConfig* out, std::string* err); // Same, from in-memory JSON text — the native Watchman flash read path // (M3 step 6: device/tundra_config.h ReadWatchmanConfig produces the text). bool LoadHmdConfigFromString(const std::string& text, HmdConfig* out, std::string* err); // Per-eye asymmetric projection frustum tangents, replicating the driver's // cant fold for parallel render cameras (closed form verified EXACT against // GetProjectionRaw dumps of two units — tools/derive_projection_fold.py): // each axis is a 1D rotation homography (tan-subtraction) of the // grow-expanded intrinsics frustum edge, cant tangents from eye_to_head // column z (R02/R22 yaw, R12/R22 pitch). Interior of the RT stays affine in // distorted NDC (EvalDistortion) — that affine/homography edge mismatch is // the driver's own approximation, replicated bit-for-bit ("replicate the // fold" branch of the S3 warning; never mix with truly canted cameras). // Convention: tan = L + u·(R−L), y-down (v=0 ↦ T), looking down −Z. void ProjectionRawTangents(const EyeCalib& eye, double* L, double* R, double* T, double* B); // Panel/viewport UV (0..1, origin top-left, OpenVR ComputeDistortion // convention) -> source UV into the eye render target, per channel. // Direct port of the S3-verified formula (poly3.py, hypothesis poly+grow): // t = (2u-1, 2v-1) - center; d = 1 + k1*r2 + k2*r4 + k3*r6 // uv_src = 0.5 + (t*d + center) * 0.5/(1 + grow_for_undistort) // out_uv[ch][0..1] = (u_src, v_src) for ch in {red, green, blue}. No clamp. void EvalDistortion(const EyeCalib& eye, double u, double v, double out_uv[3][2]); } // namespace sauna