feat(lint): add off_pivot_rotation hub-referenced layout check (#2744)

## What it catches

A gauge needle / clock hand / dial pointer / radar sweep that rotates about the **wrong pivot** — the recovered center-of-rotation sits far from the dial hub (e.g. `transform-origin` at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub.

This is a genuine gap in the current checks: `rotation_pivot_drift` (#2741) provably **cannot** catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a **dial-hub reference** distinguishes them. This is the separate hub-referenced check that analysis called for.

## How it works

- Sampler maps 2 material endpoints per frame via `getScreenCTM` (honors the actual rendered transform, independent of `svgOrigin`).
- Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit).
- Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift `> 0.35 * pointer_length`. One warning per hub.
- Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the `<svg>`) so a pointer rotated by a `div` ancestor is measured correctly.
- Multi-body guard: `>= 2` bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed.

## Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams)

- **7 / 7 true positives, 0 false positives across all 81 samples.**
- Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080.
- **The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs** (`vlm_has_defects: false`) — the deterministic hub-reference check beats the VLM on this defect class.
- FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a `div` ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard.
- fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly.

## Validation

- Autonomous Gemini-3.6 **video**-judge fuzz run to surface candidate defects, then a **deterministic FP sweep** across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class).
- 9 unit tests (`checkPipeline.offPivotRotation.test.ts`) + full check suite pass; `bun run build` green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Xuanru Li
2026-07-24 17:38:58 -07:00
committed by GitHub
parent e7f9918d21
commit e710a1686f
8 changed files with 878 additions and 13 deletions
+60 -4
View File
@@ -42,6 +42,8 @@ import type {
ContrastCapture,
GeometryCandidateRequest,
MotionSpecResolution,
OffPivotFrame,
OffPivotRotationSample,
RotationSample,
RunAuditGrid,
} from "./checkTypes.js";
@@ -349,6 +351,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
collectLayoutGeometry: () => collectLayoutGeometry(page),
collectRotationSample: (time) => collectRotationSample(page, time),
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
collectMotionFrame: (time, selectors, scopes) =>
collectMotionFrame(page, time, selectors, scopes),
@@ -470,13 +473,20 @@ async function collectLayoutGeometry(page: Page): Promise<string> {
});
}
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
const raw = await page.evaluate(() => {
const sample = Reflect.get(window, "__hyperframesRotationSample");
/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
* and return its array result (or [] when absent / non-array). Shared by the
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
async function evaluateSampler(page: Page, globalName: string): Promise<unknown[]> {
return page.evaluate((name) => {
const sample = Reflect.get(window, name);
if (typeof sample !== "function") return [];
const result = Reflect.apply(sample, window, []);
return Array.isArray(result) ? result : [];
});
}, globalName);
}
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
const raw = await evaluateSampler(page, "__hyperframesRotationSample");
return raw.flatMap((value) => parseRotationSample(value, time));
}
@@ -494,6 +504,51 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
return [{ time, selector, cx, cy, w, h, angle }];
}
async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
const raw = await evaluateSampler(page, "__hyperframesOffPivotRotationSample");
return { time, samples: raw.flatMap(parseOffPivotRotationSample) };
}
/** Read every named key as a finite number; null if ANY is missing/non-finite.
* The mapped return type keeps each field a plain `number` (not `number |
* undefined`) so callers read `nums.ax` without re-narrowing. */
function requiredNumbers<K extends string>(
value: Record<string, unknown>,
keys: readonly K[],
): { [P in K]: number } | null {
const out = {} as { [P in K]: number };
for (const key of keys) {
const num = numberValue(value, key);
if (num === null) return null;
out[key] = num;
}
return out;
}
const OFF_PIVOT_REQUIRED_NUMBERS = ["ax", "ay", "bx", "by", "len", "angle", "hubCount"] as const;
function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const nums = requiredNumbers(value, OFF_PIVOT_REQUIRED_NUMBERS);
if (!selector || !nums) return [];
return [
{
selector,
ax: nums.ax,
ay: nums.ay,
bx: nums.bx,
by: nums.by,
len: nums.len,
angle: nums.angle,
hx: numberValue(value, "hx"),
hy: numberValue(value, "hy"),
hr: numberValue(value, "hr"),
hubCount: nums.hubCount,
},
];
}
async function collectGeometryCandidates(
page: Page,
time: number,
@@ -1078,6 +1133,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"panel_out_of_canvas",
"connector_detached",
"rotation_pivot_drift",
"off_pivot_rotation",
"motion_appears_late",
"motion_out_of_order",
"motion_off_frame",