QA Notes: The Wall at 71°

Rotating a sky view: from Euler angles to a solved drag, and the five attempts a green test suite could not tell apart.

A case study from evo.polaris. One night, one symptom — a drag that stopped eighteen degrees short of its limit and flipped near the horizon's opposite — a diagnosis that was confidently wrong, a fix that was solved rather than searched for, and a lesson about what a test suite can and cannot see. Every number on this page was measured on the day, and the ones that were wrong are left in.

The report

The sky view in evo.polaris can be steered by hand: drag the picture and the star under your finger stays under your finger. On the night in question it did not. In my own words at the time:

“If I click and drag directly on Venus up and down, up and down, it flips and flops in ways it should not. Generally the motion is just not right with a lot of other places.”

“I'm also hitting a wall going multiple different directions and it just won't go past it. This is definitely a regression.”

I had a hypothesis, and it was a good one. The view was stored as two angles — azimuth and altitude, Euler angles by another name — and two-angle rotation systems have a well-known failure at the pole, where a degree of freedom collapses. I know the alternative from the inside: before I was an Autodesk employee I was a full-time contractor there, and the work I did and tested was the initial implementation of the TCB rotation controller into 3ds Max. TCB stands for tension, continuity and bias — the three controls of a Kochanek–Bartels spline, which shape how motion eases into and out of each keyframe — and the rotation controller applies them to quaternion keys, drawing a smooth spherical curve between keys without ever computing an angle. (Not the software's original quaternion storage, which predates me.) So the suggestion was the obvious one: stop storing angles; store a quaternion.

That suggestion turned out to be right about the fix and wrong about the cause, and the difference is the whole case study.

What the code was doing

The aim was { az, alt }. A drag was solved by iteration: rotate the direction that was grabbed onto the direction now under the finger, collapse the resulting orientation back to two angles, rebuild the view from those two angles, measure how far the grabbed star now missed the finger, and try again — six passes, keeping the best miss. The basis builder that turned two angles into a view refused anything past ±89.9°, because at exactly 90° its cross products collapse; that fence was there to keep the maths finite.

Two things followed from that, and neither was gimbal lock.

  1. A step whose answer the angles could not hold was refused whole. Not clamped, not taken partway — the solver simply kept its previous best answer, which for a full-screen drag from 30° upward was no movement at all once the destination would have crossed the fence. The view stopped a full drag-length below the limit. With a 65° field of view and the limit at 89°, that put the wall at 71°. Smaller drags crept higher, which is how the cause was confirmed before a line of code changed.
  2. Near the poles, the collapse to two angles moves the target the iteration is chasing. Two points a degree apart across the zenith differ by 180° of azimuth. Down near the nadir the six passes converged on answers that jumped, or on “do not move”. That was the flip-flop.

Measuring before naming

Before changing anything, the shipped solver was run through three probes in Node, against the same projection the app draws with. A true round trip — grab a point, drag it to another point, let go, grab it there and drag it back — must land exactly where it started; anything else is movement the finger did not ask for.

View altitudeRound-trip error, shipped solver
0.0000°
45°0.0002°
−45°0.0000°
−77°0.0316°
−85°0.6960°
+85°6.2817°

Fine at ordinary altitudes, and falling apart within a few degrees of either pole. Five full-screen drags straight up from 30° read 30 → 76 → 58 → 58 → 58: the second drag went backwards, because the best of six refused passes was worse than the first. And a continuity probe — thirty small steps of the finger, 0.41° each, at −85° — found one frame where the view moved 4.50°. That single number is the flip-flop, with the hand-waving removed.

The report had said Venus, which that night sat lower than −45°. At −59° the probe showed the old solver behaving; at −77° it wobbled; at −85° it jumped. The mechanism is the same at every depth and the phone's screen is smaller than the probe's, which moves every threshold; but I do not know the exact altitude at which it was seen, and this page does not pretend to.

Why it was not gimbal lock

Gimbal lock is a property of a representation: at a pole, two of three Euler axes line up and a degree of freedom is lost. A sky view that keeps its horizon level has only two degrees of freedom to begin with — where it points, round and up — and loses nothing until it points exactly at the zenith. The wall was at 71°, eighteen degrees below the place where anything is singular. What failed was not the representation; it was a solver that answered “refuse” when the honest answer was “this far and no further”.

That distinction matters for the fix. Storing a quaternion removes the singularity from the arithmetic; it does nothing about a search that gives up. The drag had to be solved differently, and the quaternion turned out to be the right place to keep the answer rather than the answer itself.

What it changed to

The drag is solved, not searched for. A level view has two unknowns, azimuth and altitude. “Put this direction under that screen point” is two constraints. Two unknowns, two constraints: there is a closed form. Write the screen point as a unit direction in the screen's own frame, with components a to the right, b forward and c up. In a level view at altitude t, that direction sits at a height above the horizon of

bsint+ccost =Rsin(t+φ) , R=b2+c2 , φ=atan2(c,b)

and that height has to equal the grabbed direction's own height, gz. A sinusoid inverts exactly. It has two solutions in general — the same star at the same screen height, seen from either side of the zenith — and where the required height exceeds R it has none, which is the honest case the old solver refused: a star at 60° cannot be shown 45° right of centre in any level view. With the altitude fixed, the azimuth is a plain turn of the horizontal parts:

K=bcostcsint , az=atan2(K,a) atan2(gy,gx)

Where there is no solution, the nearest altitude is taken and the view leans on its limit instead of stopping short of it. That one sentence is the whole difference between a wall at 71° and a limit at 89°.

The orientation is held as a quaternion, scalar first, because that is the natural home for everything around the solve. A glide to a target is a slerp, which takes the short way round without anyone reasoning about the 360° seam. The altitude limit is a rotation about the view's own right axis, so it slides along the limit rather than snapping to it. And “level” is a property the view has by construction — a basis built from the forward vector and world up, never re-derived from angles. The angles still exist, derived from the rotation for the two jobs that genuinely need them: the readout that tells you where you are looking, and the limits. Nothing writes them. It is the arrangement I tested at Autodesk, in miniature: the quaternion where the mathematics happens, angles where a person reads them.

Five attempts, one browser

The design above was the sixth version. The five before it are the part of this page I most want a QA engineer to read, because every one of them passed the app's test suite — four hundred tests, green each time — and every one of them was wrong in a way a person would see in seconds.

AttemptWhat went wrongFound by
Trackball (compose “rotate grabbed onto finger” freely) with damped levelling The levelling fights the drag: 2.6° round-trip error, and the horizon 13° off level after sixty drags Node probe
Trackball with exact levelling 25° round-trip error at the zenith, 31° of azimuth walk at −77° Node probe
Closed-form solve, choosing between the two solutions by nearest altitude Pinned at 89°, every further upward drag turned the sky round by 180° Browser
… by nearest direction At 89° every view is within 2° of every other, so the far-side answer clamped back onto 89° looked as near as standing still — the view could never come down Browser
… in the (azimuth, altitude) chart, with the azimuth re-derived at the clamped altitude The derivation changes sign across the pole: the same 180° spin, one layer down Browser
… in the chart with the altitude unclamped; then clamp the altitude and keep the azimuth as solved Holds Browser, then pinned in the suite

Why could the suite not tell them apart? Because of what it is. It is a good suite — it caught three magnetic-model bugs by the shape of their error and a rotated star chart by the real sky — but it is source-shape tests plus geometry: it reads the shipped files and asserts that the right calls are in the right places, and it checks functions against known values. Each rejected attempt had the right calls in the right places and passed every value it was asked for. None of the tests dragged anything.

The instrument that told them apart was embarrassingly simple: the app running in a browser, driven by synthetic PointerEvents on the full-screen canvas — press at one point, eight moves, release — and the hidden text that says “Looking 340° round and 45° up” read back after each one. Up five times past the ceiling, sideways three times, down eight, then Venus up and down. The first three failures produced sequences like 360/89, 180/89, 0/89, 180/89; the fix produced 360/89, 360/89, 0/89, 0/89. Same script every time, a few seconds a run.

The same script also caught a regression none of the rotation work introduced on purpose: the drag had inherited its screen redraw from the function it used to call, stopped calling it, and so moved the readout without moving the picture. Four hundred green tests were silent about that too.

The numbers after

ProbeShipped solverSolved drag
Round-trip error at −85°0.696°0.0000°
Round-trip error at 0°, ±45°, −77°0.0000 – 0.0316°0.0000°
Five full-screen drags up from 30°30 → 76 → 58 → 58 → 5830 → 76 → 89 → 89 → 89
Worst frame jump for a 0.41° finger step, at −85°4.50°0.40°
Horizon tilt after sixty off-centre drags10−18 (level by construction)
Venus up and down, three cycles, on the device pathdrifted178/89 ↔ 179/51, identical every cycle

The rotation module now carries twenty-one tests of its own that hold those numbers — a drag must land the grabbed direction under the finger to within a third of an arcsecond; a round trip must return exactly; five drags from 30° must reach 89° and stay; pinned at a limit, a further drag must not change the azimuth by more than a millionth of a degree — and the browser script stays in the repository's notes as the thing to run before anyone says the drag is fixed.

What it cost, and what it does on purpose

A second wall went with the first. Hand steering used to stop thirty degrees below the horizon, where a target could go to −89°. That floor had to be computed from where the view already was, and a limit derived from the position it limits ratchets — it had already frozen the controls once, under the Sun at night. It also no longer bought anything: the ground is a see-through wireframe now, and the app's own targets live below it. One limit, −89° to 89°, for the arrows, a tap, a drag and a target alike.

The view stops at the zenith, and that is not a bug. A view that keeps its horizon level cannot pass over the top: the only continuous path over the pole rolls the sky upside down, which is the trackball behaviour rejected above, and the only level alternative is a 180° spin of the picture. Every planetarium stops there and has you turn round. A sideways drag at the zenith does turn the picture round, because that is the exact answer for a level view.

The instrument became a feature. The numbers I was scraping out of a hidden hint to diagnose all of this are now on the picture — azimuth with its compass point, and altitude — because a person pointing a tracker wants them too.

What I would tell a QA team

  • Measure the symptom before you name it. “Gimbal lock” was a plausible name from someone who has built the alternative. Three probes and one table showed the wall eighteen degrees away from any singularity, and the name was wrong. The fix would have been designed differently if the name had been believed.
  • A green suite tells you what it was asked, not what the product does. Know which kind of tests you have. Source-shape tests are excellent at stopping regressions of a decision; they are blind to a decision that is wrong. Geometry tests are exact; they are blind to the interaction that feeds them.
  • Build the cheapest instrument that exercises the real thing, and keep it. A thirty-second browser script found five wrong designs and one unrelated regression in one evening. It cost less than any one of the tests that missed them.
  • “Nearest” needs a metric, and the wrong metric fails only at the edges. Three branch-selection rules were each correct in the middle of the sky and each wrong at a pole in a different way. The edges are where the users were.
  • Keep the wrong numbers. The rejected attempts are in the commit message, the pull request, the tests and this page, with their errors, because the next person to touch this code will have the same good idea I had.

What is not claimed

  • I did not reproduce the flip-flop at Venus's exact altitude. The probes show the mechanism clearly at −85° and marginally at −77°, with a different screen size and field of view from the phone it was seen on. The same solver is gone either way.
  • A round trip that pushes against the 89° limit does not return exactly, and is not meant to: leaning on the limit loses the star on purpose. The exact round trips above are all for reachable drags.
  • The zenith stop is a limit of level views, not a fix. Crossing the pole smoothly means either an upside-down far side or a spin, and this app chose neither.
  • The code and the measurements on this page were produced with Claude Code, working from my reports, my hypothesis and my decisions, the same way the rest of evo.polaris was built. The wrong diagnosis was mine; so was the insistence that the numbers be taken before the name.

The app is free at polaris.evomedia.net — no account, nothing stored about you. The rest of its mathematics, and how each piece was checked, is in the evo.polaris case study. The source repository is private for now; a walkthrough is available on request. See my employment history, résumé, or get in touch — I'm available for new roles.