/** * arLateralDrift — ABSOLUTE cross-pan drift from the AR camera pose. * * ## Why this exists * * The IMU lateral guard in `usePanMotion` cannot see slow drift, and that is * not a tuning failure — it is arithmetic. Recovering displacement from an * accelerometer means double-integrating, which lets bias grow without bound: * 0.25 deg of attitude error alone fabricates ~32 cm over 20 s (measured). * The high-pass that bounds it (`RESIDUAL_BIAS_TAU_S`) is exactly what removes * slow real motion. Sensitivity to slow drift and immunity to slow sensor * error are THE SAME KNOB, so no threshold on that channel can have both. * * A 2026-08-26 device session measured the consequence: across nine captures * the accelerometer estimate correlated with the stitcher's own image-derived * translation at **r = -0.28** — anti-correlated — with the capture carrying * the LEAST real translation producing the HIGHEST reading (2.1 cm real -> * 4.84 cm reported, against 10.4 cm real -> 1.55 cm reported). * * In AR none of that applies. `ARFrameMeta.pose.translation` is ARKit's * VIO world position in METRES. It is a POSITION, not an acceleration: * - no integration, so no bias accumulation and no high-pass; * - therefore arbitrarily slow drift is visible; * - and tilt cannot masquerade as translation, because tilt changes the * ROTATION, which this never reads for the displacement itself. * * This module turns that pose stream into an absolute cross-pan distance. * * ## Choosing the cross-pan axis * * "Lateral" means orthogonal to the intended sweep, so it depends on pan mode. * Critically it must also be an axis the sweep's own ARC does not project onto * — otherwise a normal pan (which traces an arc of radius ~15-60 cm about the * wrist/elbow/shoulder) reads as drift. That confusion is precisely what * broke the panorama/SCANS resolver: see `resolveStitchModeAuto`, where * `ratio` reduces to `r/(r+0.10)` and the pan angle cancels out entirely. * * - `panMode: 'vertical'` — landscape hold, sweeping UP/DOWN. The arc * lies in a VERTICAL plane, so it has no horizontal component. Lateral is * the HORIZONTAL direction across the view: the camera's right vector at * capture start, flattened into the horizontal plane. * - `panMode: 'horizontal'` — portrait hold, sweeping LEFT/RIGHT. The arc * is a yaw and lies in a HORIZONTAL plane, so it has no vertical * component. Lateral is WORLD UP. * * In both cases the arc is orthogonal to the measured axis by construction, so * an operator pivoting cleanly in place reads ~0 no matter how far they sweep * or how long their arms are. * * ARKit world frame: right-handed, **Y up**, **-Z forward**; quaternion packed * `[x, y, z, w]` (see `RNISARFramePlugin.swift`). */ /** A world-space 3-vector. */ export type Vec3 = readonly [number, number, number]; /** A unit quaternion packed `[x, y, z, w]`, matching `ARFrameMeta.pose`. */ export type Quat = readonly [number, number, number, number]; /** Pan axis vocabulary, mirroring `PanMode`. */ export type ArPanMode = 'vertical' | 'horizontal'; /** * FLOOR of the cross-pan drift allowance, CENTIMETRES. * * Since the allowance became proportional (see `DEFAULT_AR_LATERAL_RATIO`) this * is no longer a ceiling: the effective budget is * `clamp(ratio * alongPanTravel, THIS, DEFAULT_AR_LATERAL_MAX_CM)`. It governs * two cases the ratio cannot — the opening of every capture, where along-pan * travel is ~0 and a ratio would evaluate to 0, and genuinely short sweeps. * * This is REAL centimetres from ARKit's pose, unlike `DEFAULT_LATERAL_BUDGET_CM` * in the non-AR path, which gates a high-passed rate proxy whose readings bear * no fixed relation to distance (it peaked at 1.6 cm across an entire device * session). The two numbers state a similar intent; they do not measure the * same thing, and they should not be assumed to track each other. * * 10 cm comes from device traces on 2026-08-26. Cross-pan excursion measured * ~8-11 cm largely INDEPENDENT of sweep length, so a short sweep would be * judged before the ratio could apply: at 14.5 cm of travel the ratio offered * 5.8 cm and a 9.4 cm excursion was stopped, while the same operator's 52 cm * sweep carried 11.3 cm of drift to conf=1.000 untouched. A floor below the * hand's natural excursion therefore stops captures for being SHORT rather * than for being crooked, and because the stop truncates the sweep, along-pan * travel never accumulates enough for the ratio to rescue it. * * Still one operator, one device, one scene. `drift=` / `long=` / `allow=` in * the `[panMotion.ar]` line are the numbers to retune from. */ export declare const DEFAULT_AR_LATERAL_BUDGET_CM = 10; /** Rotate `v` by unit quaternion `q`. */ export declare function _rotateByQuat(q: Quat, v: Vec3): Vec3; /** * The world-space axis along which cross-pan drift is measured. * * Returns `null` when the geometry is degenerate — for `'vertical'`, a camera * whose right vector is (near-)parallel to gravity, i.e. the phone rolled to * within a few degrees of straight up/down. The caller must treat `null` as * "cannot measure this frame" rather than as zero drift. */ export declare function _lateralAxis(q: Quat, mode: ArPanMode): Vec3 | null; /** * Default absolute cross-pan ROTATION budget, DEGREES. * * The gyro trigger (`lateralTurnRateRadPerSec`) is a RATE gate at 0.15 rad/s * = 8.6 deg/s, so it cannot see a slow pivot at all: 6 deg/s accumulates 90 * DEGREES of yaw over 15 s and never trips it. That is the same blind spot * slow translation had, in the other channel — a rate gate measures how FAST * you are turning, never how FAR you have turned. * * 25 deg is a deliberate starting point, not a tuned default: it is well past * the few degrees of wander a straight sweep involves, and well short of the * 90 deg a genuine wrong-way pivot reaches. Collect `arRotDeg` peaks from * real captures before trusting it. */ export declare const DEFAULT_AR_LATERAL_ROT_DEG = 12; /** Camera forward (view) direction in world space. */ export declare function _forwardOf(q: Quat): Vec3; /** * Signed cross-pan ROTATION between two poses, RADIANS. * * Measured on the forward VECTOR rather than by decomposing the quaternion, * so a roll about the view axis — which does not change where the camera * points — contributes nothing. That matters: roll is the motion that * corrupted the accelerometer guard, and it must not be double-counted here. * * - `'vertical'` — the sweep pitches within a vertical plane, so lateral * rotation is the AZIMUTH change (turning left/right off the plane). * - `'horizontal'` — the sweep yaws within a horizontal plane, so lateral * rotation is the ELEVATION change (tilting up/down off the plane). * * Both are orthogonal to the intended sweep by construction, so panning * further never accumulates lateral rotation. */ export declare function _lateralRotationRad(startQ: Quat, curQ: Quat, mode: ArPanMode): number; /** Signed cross-pan displacement in METRES between two poses. */ export declare function _lateralDriftMetres(startQ: Quat, startT: Vec3, curT: Vec3, mode: ArPanMode): number | null; /** * The ALONG-pan axis — the direction the sweep is supposed to travel. * * It is exactly `_lateralAxis` with the mode flipped: a vertical sweep drifts * horizontally and travels up/down, a horizontal sweep drifts up/down and * travels horizontally. Sharing the helper keeps the two axes orthogonal by * construction instead of by two separate derivations that can disagree. */ export declare function _longitudinalAxis(q: Quat, mode: ArPanMode): Vec3 | null; /** Signed displacement along the pan direction, METRES. */ export declare function _longitudinalMetres(startQ: Quat, startT: Vec3, curT: Vec3, mode: ArPanMode): number | null; /** * Default lateral:longitudinal RATIO. * * A fixed centimetre budget asks the wrong question. Sideways travel only * matters RELATIVE to how far the sweep has gone: 6 cm of drift across a 60 cm * top-to-bottom pan is 10 % — the motion is still overwhelmingly vertical, and * a hand cannot do better than that over half a metre. The same 6 cm across a * 10 cm pan is 60 %, which is not a pan at all, it is a slide. An absolute cap * cannot tell those apart, so it either stops the good long sweep or permits * the bad short one. * * 0.40 allows 24 cm across a 60 cm sweep and 8 cm across a 20 cm one. Tunable * per host via `arLateralRatio`; `<= 0` restores pure absolute behaviour. */ export declare const DEFAULT_AR_LATERAL_RATIO = 0.4; /** * Ceiling on the ratio allowance, CENTIMETRES. * * The allowance grows with the sweep, so without a cap a long enough pan would * permit unbounded sideways travel. * * 40 cm, not 25: at ratio 0.40 a 25 cm cap binds from 62.5 cm of along-pan * travel, which is SHORTER than a normal shelf sweep -- so the cap, not the * ratio, governed every real capture. Measured 2026-08-26: a 70.2 cm sweep * carrying 27.3 cm of drift was stopped by the cap even though the ratio * itself allowed 28.1 cm. At 40 cm the ratio stays in charge out to a metre. */ export declare const DEFAULT_AR_LATERAL_MAX_CM = 40; /** * Cross-pan allowance for the CURRENT along-pan distance, METRES. * * `floorM` is the short-sweep budget (nothing is stopped below it, which also * covers the capture opening where `longM` is ~0 and a ratio would be * meaningless). `capM` bounds the other end. `ratio <= 0` disables the * proportional term entirely and the floor becomes a plain absolute budget — * the pre-ratio behaviour, bit for bit. */ export declare function _lateralAllowanceM(longM: number, floorM: number, ratio: number, capM: number): number; /** Running state for the AR drift guard. Mutated in place, like `LateralState`. */ export interface ArDriftState { /** Pose the drift is measured FROM, or null before a trusted frame arrives. */ startQ: Quat | null; startT: Vec3 | null; /** Signed cross-pan displacement, metres. */ driftM: number; /** Largest |driftM| seen this capture — the number worth logging. */ peakM: number; /** Signed ALONG-pan displacement, metres — the ratio's denominator. */ longM: number; /** Largest |longM| this capture. */ peakLongM: number; /** The allowance in force at the last sample, metres (for telemetry). */ allowanceM: number; /** Latched once the budget was exceeded for the grace window. */ exceeded: boolean; /** Start of the current continuous over-budget run, or null. */ overBudgetSinceMs: number | null; /** Frames skipped because tracking was not `normal`. */ untrackedCount: number; /** Frames skipped because the lateral axis was degenerate. */ degenerateCount: number; /** Signed cross-pan rotation from the seeded pose, RADIANS. */ rotRad: number; /** Largest |rotRad| this capture — the number worth logging. */ peakRotRad: number; /** Which channel latched: displacement, rotation, or neither. */ latchedBy: 'none' | 'drift' | 'rotation'; /** Start of the current continuous over-budget ROTATION run. */ rotOverSinceMs: number | null; } export declare function _freshArDriftState(): ArDriftState; export declare function _resetArDriftState(s: ArDriftState): ArDriftState; /** * Advance the guard by one AR frame. Pure (mutates and returns `s`). * * SEEDING IS DEFERRED TO THE FIRST `normal` FRAME, deliberately. Finalizing a * capture restarts the AR session, so the next capture opens with the tracker * relocalising and emitting `limited` poses that can jump metres. Anchoring * the origin to one of those would inject a large phantom drift at t=0 — the * same class of bug as the AR hold self-ending on unstable init poses. * * @param budgetM absolute cross-pan budget, METRES. `<= 0` disables the * latch while still tracking (and logging) the distance. */ export declare function _advanceArDrift(s: ArDriftState, q: Quat, t: Vec3, trackingState: string, mode: ArPanMode, budgetM: number, graceMs: number, nowMs: number, /** Absolute cross-pan ROTATION budget, RADIANS. `<= 0` disables that * channel while still measuring it. */ rotBudgetRad?: number, /** lateral:longitudinal ratio. `<= 0` = pure absolute (`budgetM` alone). */ ratio?: number, /** Ceiling on the ratio allowance, METRES. `<= 0` = uncapped. */ capM?: number): ArDriftState; //# sourceMappingURL=arLateralDrift.d.ts.map