mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -593,6 +593,23 @@
|
||||
// makes non-trivial.
|
||||
"packages/studio-server/src/helpers/screenshotClip.ts",
|
||||
"packages/studio/vite.browser.ts",
|
||||
// off_pivot_rotation Kåsa circle fit (feat/needle-pivot-offset-check):
|
||||
// fitCirclePoints in layout-audit.browser.js and fitCircle in
|
||||
// checkPipeline.ts are the same least-squares circle fit, but the browser
|
||||
// copy is injected as a raw string via page.addScriptTag and cannot import
|
||||
// the Node-side module across puppeteer's serialization boundary. The two
|
||||
// copies carry matching "KEEP IN SYNC" headers; the duplication is
|
||||
// intentional and per-language, so it's exempted here rather than faked
|
||||
// away with cosmetic divergence.
|
||||
"packages/cli/src/commands/layout-audit.browser.js",
|
||||
"packages/cli/src/utils/checkPipeline.ts",
|
||||
// check.test.ts: the fakeDriver-based command tests share a pre-existing
|
||||
// arrange/act/assert scaffold (runScenario + vi.fn runPipeline + spy +
|
||||
// createCheckCommand). Adding the required collectOffPivotRotationSample
|
||||
// stub to the CheckAuditDriver fake shifts line numbers and re-flags that
|
||||
// inherited clone; consistent with the norm above of leaving parallel
|
||||
// command-test cases unabstracted.
|
||||
"packages/cli/src/commands/check.test.ts",
|
||||
],
|
||||
},
|
||||
"health": {
|
||||
|
||||
@@ -150,6 +150,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
|
||||
collectRotationSample: vi.fn(async (_time: number) => []),
|
||||
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
|
||||
collectGeometryCandidates: vi.fn(async () => []),
|
||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||
|
||||
@@ -1531,4 +1531,197 @@
|
||||
}
|
||||
return samples;
|
||||
};
|
||||
|
||||
// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
|
||||
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
|
||||
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
|
||||
// center orbits either way), so this records two MATERIAL points on each
|
||||
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
|
||||
// rendered transform is honored regardless of svgOrigin/transform-origin — and
|
||||
// the dial's static hub (the point shared by the most non-rotating circles).
|
||||
// The pipeline fits a rotation to the material-point trajectories to recover
|
||||
// the real center-of-rotation and flags it when it drifts off that hub.
|
||||
function ctmRotationDeg(ctm) {
|
||||
if (!ctm) return null;
|
||||
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function ctmScale(ctm) {
|
||||
return Math.hypot(ctm.a, ctm.b);
|
||||
}
|
||||
|
||||
function mapPoint(svg, ctm, x, y) {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = x;
|
||||
point.y = y;
|
||||
const mapped = point.matrixTransform(ctm);
|
||||
return { x: mapped.x, y: mapped.y };
|
||||
}
|
||||
|
||||
// Walks up to (and including) the composition root, NOT just the owner <svg>:
|
||||
// an element spun by a div ancestor above its svg must not be mistaken for a
|
||||
// static hub anchor (else a lone rotating arc becomes its own dial center).
|
||||
function hasRotatedAncestor(element, root) {
|
||||
let node = element;
|
||||
while (node) {
|
||||
const angle = rotationAngleDeg(getComputedStyle(node).transform);
|
||||
if (angle !== null && Math.abs(angle) > 1) return true;
|
||||
if (node === root) break;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
|
||||
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
|
||||
// string (no import across the puppeteer boundary), so the Kåsa math is
|
||||
// intentionally duplicated per-language. Any change must land in both copies.
|
||||
function fitCirclePoints(points) {
|
||||
const count = points.length;
|
||||
if (count < 3) return null;
|
||||
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
|
||||
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
|
||||
let suu = 0,
|
||||
svv = 0,
|
||||
suv = 0,
|
||||
suuu = 0,
|
||||
svvv = 0,
|
||||
suvv = 0,
|
||||
svuu = 0;
|
||||
for (const point of points) {
|
||||
const u = point.x - meanX;
|
||||
const v = point.y - meanY;
|
||||
suu += u * u;
|
||||
svv += v * v;
|
||||
suv += u * v;
|
||||
suuu += u * u * u;
|
||||
svvv += v * v * v;
|
||||
suvv += u * v * v;
|
||||
svuu += v * u * u;
|
||||
}
|
||||
const det = suu * svv - suv * suv;
|
||||
if (Math.abs(det) < 1e-6) return null;
|
||||
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
|
||||
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
|
||||
const cx = uc + meanX;
|
||||
const cy = vc + meanY;
|
||||
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
|
||||
let squaredError = 0;
|
||||
for (const point of points) {
|
||||
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
|
||||
squaredError += delta * delta;
|
||||
}
|
||||
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
|
||||
}
|
||||
|
||||
// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
|
||||
// the largest static, near-circular path and recover its arc center.
|
||||
function arcHubForSvg(svg, root) {
|
||||
let best = null;
|
||||
for (const path of Array.from(svg.querySelectorAll("path"))) {
|
||||
if (hasRotatedAncestor(path, root)) continue;
|
||||
if (typeof path.getTotalLength !== "function") continue;
|
||||
const total = path.getTotalLength();
|
||||
if (total < 200) continue;
|
||||
const ctm = path.getScreenCTM();
|
||||
if (!ctm) continue;
|
||||
const points = [];
|
||||
for (let i = 0; i <= 16; i++) {
|
||||
const local = path.getPointAtLength((total * i) / 16);
|
||||
points.push(mapPoint(svg, ctm, local.x, local.y));
|
||||
}
|
||||
const fit = fitCirclePoints(points);
|
||||
if (!fit || fit.radius < 40) continue;
|
||||
if (fit.residual > 0.05 * fit.radius) continue;
|
||||
if (!best || fit.radius > best.radius) best = fit;
|
||||
}
|
||||
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
|
||||
}
|
||||
|
||||
function dialHubForSvg(svg, root) {
|
||||
const centers = [];
|
||||
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
|
||||
if (hasRotatedAncestor(circle, root)) continue;
|
||||
const ctm = circle.getScreenCTM();
|
||||
if (!ctm) continue;
|
||||
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
|
||||
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
|
||||
const center = mapPoint(svg, ctm, cx, cy);
|
||||
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
|
||||
centers.push({ x: center.x, y: center.y, radius });
|
||||
}
|
||||
let best = null;
|
||||
for (const anchor of centers) {
|
||||
const cluster = centers.filter(
|
||||
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
|
||||
);
|
||||
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
|
||||
}
|
||||
if (best && best.cluster.length >= 2) {
|
||||
const count = best.cluster.length;
|
||||
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
|
||||
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
|
||||
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
|
||||
return { hx, hy, hr, count };
|
||||
}
|
||||
return arcHubForSvg(svg, root);
|
||||
}
|
||||
|
||||
window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
|
||||
const root =
|
||||
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const samples = [];
|
||||
const hubCache = new Map();
|
||||
const CANDIDATE_CAP = 60;
|
||||
for (const element of Array.from(
|
||||
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
|
||||
)) {
|
||||
if (samples.length >= CANDIDATE_CAP) break;
|
||||
const svg = element.ownerSVGElement;
|
||||
if (!svg || typeof element.getBBox !== "function") continue;
|
||||
if (element.closest("[data-layout-allow-orbit]")) continue;
|
||||
if (!isVisibleElement(element, 0.05)) continue;
|
||||
const ctm = element.getScreenCTM();
|
||||
const angle = ctmRotationDeg(ctm);
|
||||
if (ctm === null || angle === null) continue;
|
||||
let bbox;
|
||||
try {
|
||||
bbox = element.getBBox();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const long = Math.max(bbox.width, bbox.height);
|
||||
const short = Math.min(bbox.width, bbox.height);
|
||||
if (short <= 0 || long / short < 3 || long < 40) continue;
|
||||
const vertical = bbox.height >= bbox.width;
|
||||
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
|
||||
const a = vertical
|
||||
? mapPoint(svg, ctm, midMajor, bbox.y)
|
||||
: mapPoint(svg, ctm, bbox.x, midMajor);
|
||||
const b = vertical
|
||||
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
|
||||
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
|
||||
let hub = hubCache.get(svg);
|
||||
if (hub === undefined) {
|
||||
hub = dialHubForSvg(svg, root);
|
||||
hubCache.set(svg, hub);
|
||||
}
|
||||
samples.push({
|
||||
selector: selectorFor(element),
|
||||
ax: round(a.x),
|
||||
ay: round(a.y),
|
||||
bx: round(b.x),
|
||||
by: round(b.y),
|
||||
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
|
||||
angle: round(angle),
|
||||
hx: hub ? round(hub.hx) : null,
|
||||
hy: hub ? round(hub.hy) : null,
|
||||
hr: hub ? round(hub.hr) : null,
|
||||
hubCount: hub ? hub.count : 0,
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { countAngularBodies, detectOffPivotRotation } from "./checkPipeline.js";
|
||||
import type { OffPivotFrame, OffPivotRotationSample } from "./checkTypes.js";
|
||||
|
||||
/** A sample carrying its frame time, for test ergonomics; {@link toFrames}
|
||||
* re-hoists the time into the on-the-wire {@link OffPivotFrame} envelope. */
|
||||
type TimedSample = OffPivotRotationSample & { time: number };
|
||||
|
||||
/** One needle sample; defaults describe a sizable pointer on a hub at (500,500). */
|
||||
function sample(overrides: Partial<TimedSample> = {}): TimedSample {
|
||||
return {
|
||||
time: 0,
|
||||
selector: "#needle",
|
||||
ax: 0,
|
||||
ay: 0,
|
||||
bx: 0,
|
||||
by: 0,
|
||||
len: 200,
|
||||
angle: 0,
|
||||
hx: 500,
|
||||
hy: 500,
|
||||
hr: 220,
|
||||
hubCount: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge per-selector timed samples into the time-hoisted frame envelope the
|
||||
* detector consumes (all samples sharing a time land in one frame). */
|
||||
function toFrames(...groups: TimedSample[][]): OffPivotFrame[] {
|
||||
const byTime = new Map<number, OffPivotRotationSample[]>();
|
||||
for (const { time, ...rest } of groups.flat()) {
|
||||
const frame = byTime.get(time);
|
||||
if (frame) frame.push(rest);
|
||||
else byTime.set(time, [rest]);
|
||||
}
|
||||
return [...byTime.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([time, samples]) => ({ time, samples }));
|
||||
}
|
||||
|
||||
interface SweepOptions {
|
||||
/** Center the endpoints actually orbit (the recovered center-of-rotation). */
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Screen hub the check compares the recovered center against. */
|
||||
hx?: number;
|
||||
hy?: number;
|
||||
selector?: string;
|
||||
frames?: number;
|
||||
spreadDeg?: number;
|
||||
/** Outer/inner material-point radii; both share one angle so the pointer is
|
||||
* rigid (constant separation |a-b| = rOuter - rInner). */
|
||||
rOuter?: number;
|
||||
rInner?: number;
|
||||
/** Per-frame inner-radius growth — breaks rigidity (endpoint deformation). */
|
||||
innerGrowth?: number;
|
||||
hubCount?: number;
|
||||
}
|
||||
|
||||
/** A rigid pointer swept about `(cx,cy)`: two material points on one radial line
|
||||
* orbit that center, so their separation is constant (rigid body). */
|
||||
function sweep(options: SweepOptions): TimedSample[] {
|
||||
const {
|
||||
cx,
|
||||
cy,
|
||||
hx = 500,
|
||||
hy = 500,
|
||||
selector = "#needle",
|
||||
frames = 6,
|
||||
spreadDeg = 180,
|
||||
rOuter = 100,
|
||||
rInner = 40,
|
||||
innerGrowth = 0,
|
||||
hubCount = 3,
|
||||
} = options;
|
||||
return Array.from({ length: frames }, (_, i) => {
|
||||
const deg = frames <= 1 ? 0 : (spreadDeg * i) / (frames - 1);
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const inner = rInner + innerGrowth * i;
|
||||
const ax = cx + rOuter * Math.cos(rad);
|
||||
const ay = cy + rOuter * Math.sin(rad);
|
||||
const bx = cx + inner * Math.cos(rad);
|
||||
const by = cy + inner * Math.sin(rad);
|
||||
return sample({
|
||||
selector,
|
||||
time: i,
|
||||
angle: deg,
|
||||
ax,
|
||||
ay,
|
||||
bx,
|
||||
by,
|
||||
len: Math.hypot(ax - bx, ay - by),
|
||||
hx,
|
||||
hy,
|
||||
hubCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("detectOffPivotRotation", () => {
|
||||
it("fires when the recovered center-of-rotation is far from the dial hub", () => {
|
||||
// Rigid pointer orbits (500,300) — 200px above the hub at (500,500).
|
||||
const findings = detectOffPivotRotation(toFrames(sweep({ cx: 500, cy: 300 })));
|
||||
expect(findings).toHaveLength(1);
|
||||
const [f] = findings;
|
||||
expect(f?.code).toBe("off_pivot_rotation");
|
||||
expect(f?.severity).toBe("warning");
|
||||
expect(f?.selector).toBe("#needle");
|
||||
expect(f?.message).toContain("200px");
|
||||
expect(f?.fixHint).toContain("hub");
|
||||
});
|
||||
|
||||
it("does not fire on a correctly-hubbed needle (center-of-rotation on the hub)", () => {
|
||||
expect(detectOffPivotRotation(toFrames(sweep({ cx: 500, cy: 500 })))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire with fewer than the minimum (overdetermined) samples", () => {
|
||||
// Blocker-1 core: any 3 non-collinear points fit a circle with ~0 residual,
|
||||
// so a 3-sample "clean circle" guard is vacuous. Below 5 samples we refuse.
|
||||
expect(detectOffPivotRotation(toFrames(sweep({ cx: 500, cy: 300, frames: 4 })))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(detectOffPivotRotation(toFrames(sweep({ cx: 500, cy: 300, frames: 5 })))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire on arbitrary 3-point endpoint deformation (the reviewer repro)", () => {
|
||||
// Three arbitrary non-collinear endpoint positions: geometrically they fit a
|
||||
// circle exactly, but there are too few samples to be an overdetermined fit.
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, ax: 600, ay: 300, bx: 540, by: 300 }),
|
||||
sample({ time: 1, angle: 90, ax: 505, ay: 402, bx: 500, by: 341 }),
|
||||
sample({ time: 2, angle: 180, ax: 402, ay: 305, bx: 461, by: 299 }),
|
||||
];
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when the tracked points deform (non-rigid) even with >=5 samples", () => {
|
||||
// Endpoint A traces a clean wide circle about (500,300) — a naive fit would
|
||||
// recover a 200px-off center and flag it — but the inner point drifts outward
|
||||
// each frame, so the figure is deforming, not rotating rigidly.
|
||||
const group = sweep({ cx: 500, cy: 300, frames: 6, innerGrowth: 8 });
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when the >=5-sample trajectory is not a clean circle (high residual)", () => {
|
||||
// Rigid separation (len constant) but the points jitter along a rough path,
|
||||
// not one circle — the overdetermined RMS-residual guard rejects it.
|
||||
const scatter = [
|
||||
[600, 300],
|
||||
[520, 420],
|
||||
[470, 260],
|
||||
[610, 350],
|
||||
[430, 400],
|
||||
[560, 250],
|
||||
];
|
||||
const group = scatter.map(([ax, ay], i) =>
|
||||
sample({
|
||||
time: i,
|
||||
angle: i * 40,
|
||||
ax: ax ?? 0,
|
||||
ay: ay ?? 0,
|
||||
bx: (ax ?? 0) - 60,
|
||||
by: ay ?? 0,
|
||||
len: 60,
|
||||
}),
|
||||
);
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when the pointer barely sweeps (fixed tilt)", () => {
|
||||
expect(
|
||||
detectOffPivotRotation(toFrames(sweep({ cx: 500, cy: 300, spreadDeg: 10 }))),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when no dial hub is resolvable", () => {
|
||||
const group = sweep({ cx: 500, cy: 300 }).map((s) => ({
|
||||
...s,
|
||||
hx: null,
|
||||
hy: null,
|
||||
hubCount: 0,
|
||||
}));
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when the hub has too few concentric circles", () => {
|
||||
const group = sweep({ cx: 500, cy: 300 }).map((s) => ({ ...s, hubCount: 1 }));
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires the hub to be present for a sustained majority, not just the last frame", () => {
|
||||
// Hub resolves only on the final frame; every earlier frame lacks it. A
|
||||
// single-frame hub is not a reliable anchor — suppression is structural.
|
||||
const group = sweep({ cx: 500, cy: 300 }).map((s, i, all) =>
|
||||
i === all.length - 1 ? s : { ...s, hx: null, hy: null, hubCount: 0 },
|
||||
);
|
||||
expect(detectOffPivotRotation(toFrames(group))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collapses nested candidates on the same hub to a single finding", () => {
|
||||
const group = sweep({ cx: 500, cy: 300 });
|
||||
const child = sweep({ cx: 500, cy: 300, selector: "#needle > path:nth-of-type(1)" });
|
||||
const findings = detectOffPivotRotation(toFrames(group, child));
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.selector).toBe("#needle");
|
||||
});
|
||||
|
||||
it("suppresses multiple distinct bodies orbiting one hub (orbit/atom system)", () => {
|
||||
// Two rigid bodies at DIFFERENT angular positions about the same hub — an
|
||||
// orbit/radial system, not a single dial pointer.
|
||||
const bodyA = sweep({ cx: 700, cy: 500, selector: "#electron-a" });
|
||||
const bodyB = sweep({ cx: 300, cy: 500, selector: "#electron-b" });
|
||||
expect(detectOffPivotRotation(toFrames(bodyA, bodyB))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("flags one off-pivot hand on a multi-hand clock whose sibling hands are correctly hubbed", () => {
|
||||
// Analog clock: three hands on one hub at (500,500). Two are correctly
|
||||
// hubbed (center-of-rotation on the hub → not eligible findings); one is
|
||||
// off-pivot (orbits (500,300), 200px above the hub). The multi-body
|
||||
// suppression must count only ELIGIBLE candidates — the two hubbed hands
|
||||
// are not orbiting bodies, so the lone off-pivot hand is NOT suppressed.
|
||||
const offPivot = sweep({ cx: 500, cy: 300, selector: "#hand-second" });
|
||||
const hubbedA = sweep({ cx: 500, cy: 500, selector: "#hand-hour" });
|
||||
const hubbedB = sweep({ cx: 500, cy: 500, selector: "#hand-minute" });
|
||||
const findings = detectOffPivotRotation(toFrames(offPivot, hubbedA, hubbedB));
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.selector).toBe("#hand-second");
|
||||
});
|
||||
|
||||
it("returns nothing for an empty sample set", () => {
|
||||
expect(detectOffPivotRotation([])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countAngularBodies (multi-body / orbit-atom suppression guard)", () => {
|
||||
it("collapses tightly-clustered angles to a single body", () => {
|
||||
expect(countAngularBodies([10, 12, 13, -8])).toBe(1);
|
||||
});
|
||||
|
||||
it("counts well-separated angular positions as distinct bodies", () => {
|
||||
expect(countAngularBodies([0, 90, 180])).toBe(3);
|
||||
});
|
||||
|
||||
it("wraps around 360 so 350 and 5 are the same body", () => {
|
||||
expect(countAngularBodies([350, 5])).toBe(1);
|
||||
expect(countAngularBodies([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
ContrastAuditEntry,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
OffPivotFrame,
|
||||
OffPivotRotationSample,
|
||||
RotationSample,
|
||||
} from "./checkTypes.js";
|
||||
|
||||
@@ -193,6 +195,10 @@ interface GridSamples {
|
||||
/** Every rotatable element's geometry at each layout sample; grouped by
|
||||
* selector after the run to detect rotation_pivot_drift. */
|
||||
rotationSamples: RotationSample[];
|
||||
/** One time-hoisted frame of every elongated rotating SVG figure's
|
||||
* material-point geometry + dial hub per layout sample; flattened by selector
|
||||
* to detect off_pivot_rotation. */
|
||||
indicatorFrames: OffPivotFrame[];
|
||||
}
|
||||
|
||||
interface GeometrySeen {
|
||||
@@ -365,6 +371,7 @@ async function collectGridSamples(
|
||||
contrastMs: 0,
|
||||
geometrySignatures: [],
|
||||
rotationSamples: [],
|
||||
indicatorFrames: [],
|
||||
};
|
||||
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
|
||||
await driver.seek(time);
|
||||
@@ -378,6 +385,7 @@ async function collectGridSamples(
|
||||
issuesAtTime.push(...layoutIssues);
|
||||
collected.geometrySignatures.push(await driver.collectLayoutGeometry());
|
||||
collected.rotationSamples.push(...(await driver.collectRotationSample(time)));
|
||||
collected.indicatorFrames.push(await driver.collectOffPivotRotationSample(time));
|
||||
}
|
||||
if (canvas) {
|
||||
const geometryIssues = await collectGeometryAt(
|
||||
@@ -548,14 +556,21 @@ function rotationDriftFinding(
|
||||
};
|
||||
}
|
||||
|
||||
function groupRotationSamplesBySelector(samples: RotationSample[]): Map<string, RotationSample[]> {
|
||||
const bySelector = new Map<string, RotationSample[]>();
|
||||
for (const sample of samples) {
|
||||
const group = bySelector.get(sample.selector);
|
||||
if (group) group.push(sample);
|
||||
else bySelector.set(sample.selector, [sample]);
|
||||
/** Bucket items by a derived key, preserving insertion order within each bucket. */
|
||||
function groupBy<T>(items: T[], keyOf: (item: T) => string): Map<string, T[]> {
|
||||
const byKey = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const group = byKey.get(keyOf(item));
|
||||
if (group) group.push(item);
|
||||
else byKey.set(keyOf(item), [item]);
|
||||
}
|
||||
return bySelector;
|
||||
return byKey;
|
||||
}
|
||||
|
||||
/** Bucket time-samples by their CSS selector so each element's trajectory can be
|
||||
* analyzed independently. Shared by rotation_pivot_drift and off_pivot_rotation. */
|
||||
function groupBySelector<T extends { selector: string }>(samples: T[]): Map<string, T[]> {
|
||||
return groupBy(samples, (sample) => sample.selector);
|
||||
}
|
||||
|
||||
/** Enough samples to establish a spin trajectory (one frame can't). */
|
||||
@@ -628,7 +643,7 @@ export function detectRotationPivotDrift(
|
||||
): AnchoredLayoutIssue[] {
|
||||
const findings: AnchoredLayoutIssue[] = [];
|
||||
const viewportFloor = ROTATION_DRIFT_VIEWPORT_FRACTION * Math.min(canvas.width, canvas.height);
|
||||
for (const group of groupRotationSamplesBySelector(samples).values()) {
|
||||
for (const group of groupBySelector(samples).values()) {
|
||||
if (!isRotationDriftCandidate(group)) continue;
|
||||
const medianSize = median(group.map((s) => Math.max(s.w, s.h)));
|
||||
const threshold = Math.max(ROTATION_DRIFT_SIZE_FRACTION * medianSize, viewportFloor);
|
||||
@@ -640,6 +655,296 @@ export function detectRotationPivotDrift(
|
||||
return findings;
|
||||
}
|
||||
|
||||
// off_pivot_rotation thresholds. A pointer must actually sweep, sit on a
|
||||
// resolvable dial hub, and its recovered center-of-rotation must land far from
|
||||
// that hub relative to the pointer's own length.
|
||||
//
|
||||
// The fit MUST be OVERDETERMINED: any 3 non-collinear points fit a circle with
|
||||
// ~zero residual, so a 3-sample "clean circle" guard is vacuous — arbitrary
|
||||
// endpoint deformation or translation would read as a perfect orbit. Require
|
||||
// >=5 distinct time-samples so the least-squares residual actually measures
|
||||
// whether the trajectory lies on one circle.
|
||||
const INDICATOR_MIN_SAMPLES = 5;
|
||||
const INDICATOR_MIN_ANGLE_SPREAD_DEG = 20;
|
||||
const INDICATOR_MIN_HUB_CIRCLES = 2;
|
||||
// The Kåsa fit's RMS residual normalized by the fitted radius. With >=5 samples
|
||||
// a genuine rotation lands well under this; non-circular motion (deformation,
|
||||
// translation, skew, jitter/scatter) blows past it, so we skip rather than
|
||||
// misread it as an off-hub pivot.
|
||||
const INDICATOR_MAX_FIT_RESIDUAL_FRACTION = 0.05;
|
||||
// Rigid-body guard: rotation preserves shape, so the pairwise distance between
|
||||
// the two tracked material points must stay ~constant. If it swings more than
|
||||
// this fraction of its median across the window the element is deforming, not
|
||||
// rotating rigidly — do not flag.
|
||||
const INDICATOR_MAX_RIGID_LEN_VARIATION = 0.15;
|
||||
// Drift beyond this fraction of the pointer length is a wrong pivot. A correctly
|
||||
// hubbed needle recovers a center within a few px of the hub (≈0); a base/edge
|
||||
// pivot mistake puts the recovered center a large fraction of the needle away.
|
||||
const INDICATOR_DRIFT_LENGTH_FRACTION = 0.35;
|
||||
|
||||
interface CircleFit {
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
residual: number;
|
||||
}
|
||||
|
||||
/** Kåsa algebraic circle fit; null when the points are near-collinear/degenerate
|
||||
* (no stable center recoverable).
|
||||
*
|
||||
* KEEP IN SYNC with `fitCirclePoints` in
|
||||
* packages/cli/src/commands/layout-audit.browser.js — the browser sampler needs
|
||||
* the same fit to resolve arc-drawn dial hubs, but it is injected as a raw
|
||||
* string (no import across the puppeteer boundary), so the math is intentionally
|
||||
* duplicated per-language. Any change to the algorithm must land in both. */
|
||||
function fitCircle(points: Array<{ x: number; y: number }>): CircleFit | null {
|
||||
const count = points.length;
|
||||
if (count < 3) return null;
|
||||
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
|
||||
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
|
||||
let suu = 0;
|
||||
let svv = 0;
|
||||
let suv = 0;
|
||||
let suuu = 0;
|
||||
let svvv = 0;
|
||||
let suvv = 0;
|
||||
let svuu = 0;
|
||||
for (const point of points) {
|
||||
const u = point.x - meanX;
|
||||
const v = point.y - meanY;
|
||||
suu += u * u;
|
||||
svv += v * v;
|
||||
suv += u * v;
|
||||
suuu += u * u * u;
|
||||
svvv += v * v * v;
|
||||
suvv += u * v * v;
|
||||
svuu += v * u * u;
|
||||
}
|
||||
const determinant = suu * svv - suv * suv;
|
||||
if (Math.abs(determinant) < 1e-6) return null;
|
||||
const rhsU = (suuu + suvv) / 2;
|
||||
const rhsV = (svvv + svuu) / 2;
|
||||
const uc = (rhsU * svv - rhsV * suv) / determinant;
|
||||
const vc = (rhsV * suu - rhsU * suv) / determinant;
|
||||
const cx = uc + meanX;
|
||||
const cy = vc + meanY;
|
||||
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
|
||||
// RMS radial residual: penalizes any point straying off the fitted circle
|
||||
// more sharply than a mean-absolute error, so an overdetermined fit exposes
|
||||
// non-circular trajectories instead of averaging them away.
|
||||
let squaredError = 0;
|
||||
for (const point of points) {
|
||||
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
|
||||
squaredError += delta * delta;
|
||||
}
|
||||
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
|
||||
}
|
||||
|
||||
/** A dial hub that is present for a SUSTAINED majority of the sampled window,
|
||||
* not just the final frame — a transient single-frame hub is not a reliable
|
||||
* anchor. The representative center is the median across the frames that carry
|
||||
* one, so a stray frame can't yank it. */
|
||||
const INDICATOR_MIN_HUB_PRESENCE_FRACTION = 0.6;
|
||||
|
||||
interface ResolvedHub {
|
||||
hx: number;
|
||||
hy: number;
|
||||
}
|
||||
|
||||
function resolveHub(group: OffPivotRotationSample[]): ResolvedHub | null {
|
||||
const present = group.filter(
|
||||
(s) => s.hx !== null && s.hy !== null && s.hubCount >= INDICATOR_MIN_HUB_CIRCLES,
|
||||
);
|
||||
if (present.length < INDICATOR_MIN_HUB_PRESENCE_FRACTION * group.length) return null;
|
||||
const hx = median(present.map((s) => s.hx as number));
|
||||
const hy = median(present.map((s) => s.hy as number));
|
||||
return { hx, hy };
|
||||
}
|
||||
|
||||
/** Rigid-body guard: a rotation preserves the figure's shape, so the distance
|
||||
* between the two tracked material points stays ~constant. Arbitrary endpoint
|
||||
* deformation (morphing, stretching) changes it — reject so it isn't misread as
|
||||
* an off-hub orbit. */
|
||||
function isRigidBody(group: OffPivotRotationSample[]): boolean {
|
||||
const lengths = group.map((s) => s.len).filter((len) => len > 0);
|
||||
if (lengths.length < group.length) return false;
|
||||
const mid = median(lengths);
|
||||
if (mid <= 0) return false;
|
||||
const spread = Math.max(...lengths) - Math.min(...lengths);
|
||||
return spread <= INDICATOR_MAX_RIGID_LEN_VARIATION * mid;
|
||||
}
|
||||
|
||||
/** Recover the center-of-rotation from whichever major-axis endpoint traces the
|
||||
* larger orbit (more movement → more numerically stable), rejecting scattered
|
||||
* fits. The fit is OVERDETERMINED (>=INDICATOR_MIN_SAMPLES points) so a high RMS
|
||||
* residual genuinely means the trajectory is not one circle. */
|
||||
function recoverPivot(group: OffPivotRotationSample[]): CircleFit | null {
|
||||
if (group.length < INDICATOR_MIN_SAMPLES) return null;
|
||||
const endpointA = group.map((s) => ({ x: s.ax, y: s.ay }));
|
||||
const endpointB = group.map((s) => ({ x: s.bx, y: s.by }));
|
||||
const fits = [fitCircle(endpointA), fitCircle(endpointB)].filter(
|
||||
(fit): fit is CircleFit => fit !== null,
|
||||
);
|
||||
if (fits.length === 0) return null;
|
||||
// Scoped-known blind spot: we keep the wider-radius endpoint fit and, if it
|
||||
// fails the residual gate below, return null rather than falling back to the
|
||||
// narrower fit. Deliberately conservative — a missed pointer (false negative)
|
||||
// over threading both fits and risking a spurious pivot on a noisy arc.
|
||||
const best = fits.reduce((widest, fit) => (fit.radius > widest.radius ? fit : widest));
|
||||
if (best.radius <= 0) return null;
|
||||
if (best.residual > INDICATOR_MAX_FIT_RESIDUAL_FRACTION * best.radius) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** An off-pivot sample with its frame time reattached for grouping/annotation.
|
||||
* The wire shape ({@link OffPivotFrame}) hoists time to the frame; the detector
|
||||
* flattens back to timed samples so the last frame's time anchors the finding. */
|
||||
type TimedIndicatorSample = OffPivotRotationSample & { time: number };
|
||||
|
||||
function offPivotRotationFinding(
|
||||
group: TimedIndicatorSample[],
|
||||
drift: number,
|
||||
): AnchoredLayoutIssue | null {
|
||||
const last = group[group.length - 1];
|
||||
if (!last) return null;
|
||||
const rect: LayoutRect = {
|
||||
left: Math.min(last.ax, last.bx) - 4,
|
||||
top: Math.min(last.ay, last.by) - 4,
|
||||
right: Math.max(last.ax, last.bx) + 4,
|
||||
bottom: Math.max(last.ay, last.by) + 4,
|
||||
width: Math.abs(last.bx - last.ax) + 8,
|
||||
height: Math.abs(last.by - last.ay) + 8,
|
||||
};
|
||||
return {
|
||||
code: "off_pivot_rotation",
|
||||
severity: "warning",
|
||||
time: last.time,
|
||||
selector: last.selector,
|
||||
dataAttributes: {},
|
||||
sourceFile: "index.html",
|
||||
bbox: rectToBbox(rect),
|
||||
rect,
|
||||
message: `Gauge/dial pointer rotates about a point ${Math.round(drift)}px from the dial hub — its center-of-rotation is off the hub, so it points wrong or overshoots the track (check the rotating group's pivot: translate to the hub before rotate, or set svgOrigin/transform-origin to the hub).`,
|
||||
fixHint:
|
||||
"The pointer's center-of-rotation must coincide with the dial hub. Rotate the group about the hub (e.g. transform-origin/svgOrigin set to the hub center, or translate the group so its rotation pivot lands on the hub) rather than about the element's own bbox center.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* off_pivot_rotation: a gauge needle / clock hand / radar sweep / dial pointer
|
||||
* that rotates about the WRONG pivot — its center-of-rotation is far from the
|
||||
* dial's hub, so the pointer aims wrong, runs off the track, or leaves the canvas.
|
||||
*
|
||||
* This is a HUB-REFERENCED check by necessity: a correctly-swept needle's bbox
|
||||
* center orbits identically to a broken one (the pivot sits at the needle's
|
||||
* base/edge either way), so no element-intrinsic measure separates them. The
|
||||
* browser sampler records two fixed MATERIAL points on the pointer per frame; a
|
||||
* circle fit recovers the true center-of-rotation, which is then compared to the
|
||||
* dial's static hub (the screen point shared by the most non-rotating circles).
|
||||
*
|
||||
* FP guards are strict and STRUCTURAL (evaluated across the whole sampled
|
||||
* window, not a single frame): requires real sweep (angle varies), a hub present
|
||||
* for a sustained majority of frames (≥2 concentric static circles), a
|
||||
* rigid-body trajectory (the two tracked points keep a constant separation), an
|
||||
* OVERDETERMINED circle fit (≥5 samples, low RMS residual), and drift exceeding a
|
||||
* fraction of the pointer's own length. When no sustained hub reference resolves
|
||||
* it does not fire, so a lone decorative rotator can't trip it.
|
||||
*/
|
||||
interface IndicatorCandidate {
|
||||
hubKey: string;
|
||||
angleAboutHub: number;
|
||||
drift: number;
|
||||
length: number;
|
||||
finding: AnchoredLayoutIssue | null;
|
||||
}
|
||||
|
||||
/** Distinct angular positions about the hub among a set of candidates (nested
|
||||
* blade parts share one angle; orbiting bodies spread to several). Exported for
|
||||
* direct unit coverage of the multi-body/orbit-atom suppression guard. */
|
||||
export function countAngularBodies(angles: number[]): number {
|
||||
const clusters: number[] = [];
|
||||
for (const angle of angles) {
|
||||
if (
|
||||
!clusters.some((rep) => Math.min(Math.abs(angle - rep), 360 - Math.abs(angle - rep)) <= 25)
|
||||
) {
|
||||
clusters.push(angle);
|
||||
}
|
||||
}
|
||||
return clusters.length;
|
||||
}
|
||||
|
||||
/** The pointer's angular position about the resolved hub, averaged across the
|
||||
* window so the multi-body clustering compares a stable per-body angle rather
|
||||
* than a single-frame snapshot. */
|
||||
function angleAboutHub(group: OffPivotRotationSample[], hub: ResolvedHub): number {
|
||||
const midX = median(group.map((s) => (s.ax + s.bx) / 2));
|
||||
const midY = median(group.map((s) => (s.ay + s.by) / 2));
|
||||
return (Math.atan2(midY - hub.hy, midX - hub.hx) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/** Gate one element's samples into a candidate, applying every structural FP
|
||||
* guard. Returns null when the element is not an off-pivot-able dial pointer. */
|
||||
function buildIndicatorCandidate(group: TimedIndicatorSample[]): IndicatorCandidate | null {
|
||||
if (group.length < INDICATOR_MIN_SAMPLES) return null;
|
||||
if (maxAngleSpread(group.map((s) => s.angle)) <= INDICATOR_MIN_ANGLE_SPREAD_DEG) return null;
|
||||
const hub = resolveHub(group);
|
||||
if (!hub) return null;
|
||||
if (!isRigidBody(group)) return null;
|
||||
const pivot = recoverPivot(group);
|
||||
if (!pivot) return null;
|
||||
const drift = Math.hypot(pivot.cx - hub.hx, pivot.cy - hub.hy);
|
||||
const length = median(group.map((s) => s.len));
|
||||
const eligible = drift > INDICATOR_DRIFT_LENGTH_FRACTION * length;
|
||||
return {
|
||||
// Scoped-known blind spot: hubs bucketed by rounded screen coords, so two
|
||||
// separate dials whose screen hubs land in the same 5px bucket merge into
|
||||
// one multi-body group (rare — distinct dials stacked at ~identical screen
|
||||
// position). Key by owning <svg> identity if that pattern shows up.
|
||||
hubKey: `${Math.round(hub.hx / 5)}:${Math.round(hub.hy / 5)}`,
|
||||
angleAboutHub: angleAboutHub(group, hub),
|
||||
drift,
|
||||
length,
|
||||
finding: eligible ? offPivotRotationFinding(group, drift) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per hub: multiple bodies at distinct angular positions = an orbit/atom/radial
|
||||
* system, not a single dial pointer — suppress. A real pointer (group + nested
|
||||
* blades) collapses to one angular cluster. Otherwise emit one finding per hub,
|
||||
* keeping the longest pointer. */
|
||||
function selectHubFindings(candidates: IndicatorCandidate[]): AnchoredLayoutIssue[] {
|
||||
const byHub = groupBy(candidates, (candidate) => candidate.hubKey);
|
||||
const findings: AnchoredLayoutIssue[] = [];
|
||||
for (const group of byHub.values()) {
|
||||
// Count angular bodies only over ELIGIBLE (off-pivot) candidates. Sibling
|
||||
// hands that are correctly hubbed (finding === null) are not orbiting
|
||||
// bodies, so counting them would falsely suppress a lone off-pivot hand on
|
||||
// a multi-hand clock/dial whose other hands pivot correctly.
|
||||
const eligible = group.filter((c) => c.finding !== null);
|
||||
if (countAngularBodies(eligible.map((c) => c.angleAboutHub)) >= 2) continue;
|
||||
const best = group
|
||||
.filter((c) => c.finding !== null)
|
||||
.reduce<IndicatorCandidate | null>(
|
||||
(max, c) => (!max || c.length > max.length ? c : max),
|
||||
null,
|
||||
);
|
||||
if (best?.finding) findings.push(best.finding);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function detectOffPivotRotation(frames: OffPivotFrame[]): AnchoredLayoutIssue[] {
|
||||
const timed: TimedIndicatorSample[] = frames.flatMap((frame) =>
|
||||
frame.samples.map((sample) => ({ ...sample, time: frame.time })),
|
||||
);
|
||||
const candidates: IndicatorCandidate[] = [];
|
||||
for (const group of groupBySelector(timed).values()) {
|
||||
const candidate = buildIndicatorCandidate(group);
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
return selectHubFindings(candidates);
|
||||
}
|
||||
|
||||
/** Error-severity findings with real geometry become labeled overview boxes.
|
||||
* Contrast failures are annotated separately by the driver itself, since
|
||||
* they're only known once contrast measurement for this sample completes. */
|
||||
@@ -679,6 +984,7 @@ export async function runAuditGrid(
|
||||
collected.rotationSamples,
|
||||
await driver.getCanvas(),
|
||||
);
|
||||
const offPivotFindings = detectOffPivotRotation(collected.indicatorFrames);
|
||||
const contrast = buildContrastResults(collected.contrastEntries);
|
||||
return {
|
||||
duration: grid.duration,
|
||||
@@ -686,7 +992,12 @@ export async function runAuditGrid(
|
||||
transitionSamples: grid.transitionSamples,
|
||||
transitionSamplesDropped: grid.transitionSamplesDropped,
|
||||
runtimeFindings: [],
|
||||
layoutIssues: [...collected.layoutIssues, ...sweepFindings, ...rotationFindings],
|
||||
layoutIssues: [
|
||||
...collected.layoutIssues,
|
||||
...sweepFindings,
|
||||
...rotationFindings,
|
||||
...offPivotFindings,
|
||||
],
|
||||
motionIssues,
|
||||
motionSampleCount: collected.motionFrames.length,
|
||||
contrastSamples: grid.contrastSamples,
|
||||
|
||||
@@ -133,6 +133,34 @@ export interface RotationSample {
|
||||
angle: number;
|
||||
}
|
||||
|
||||
/** One elongated rotating SVG figure's material geometry at a single seeked
|
||||
* sample, produced by `__hyperframesOffPivotRotationSample`. `(ax,ay)`/`(bx,by)` are
|
||||
* two fixed material points on the figure (major-axis endpoints) mapped to
|
||||
* screen space, so their cross-frame trajectory recovers the true center of
|
||||
* rotation; `(hx,hy)` is the dial's static hub and `hr` its radius. The sample
|
||||
* time is hoisted to the enclosing {@link OffPivotFrame}, not repeated here. */
|
||||
export interface OffPivotRotationSample {
|
||||
selector: string;
|
||||
ax: number;
|
||||
ay: number;
|
||||
bx: number;
|
||||
by: number;
|
||||
len: number;
|
||||
angle: number;
|
||||
hx: number | null;
|
||||
hy: number | null;
|
||||
hr: number | null;
|
||||
hubCount: number;
|
||||
}
|
||||
|
||||
/** All elongated rotating figures at one seeked sample. Time is hoisted to the
|
||||
* frame (matching {@link ConnectorFrame}) so the per-figure samples don't repeat
|
||||
* it; frames are accumulated across the grid to detect off_pivot_rotation. */
|
||||
export interface OffPivotFrame {
|
||||
time: number;
|
||||
samples: OffPivotRotationSample[];
|
||||
}
|
||||
|
||||
export type MotionSpecResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "valid"; path: string; spec: MotionSpec }
|
||||
@@ -153,6 +181,10 @@ export interface CheckAuditDriver {
|
||||
/** rotation_pivot_drift: every rotatable element's bbox center/size/angle at
|
||||
* the current seeked state. Accumulated across the grid — see checkPipeline. */
|
||||
collectRotationSample(time: number): Promise<RotationSample[]>;
|
||||
/** off_pivot_rotation: every elongated rotating SVG figure's material-point
|
||||
* geometry + resolved dial hub at the current seeked state, as a time-hoisted
|
||||
* frame envelope. */
|
||||
collectOffPivotRotationSample(time: number): Promise<OffPivotFrame>;
|
||||
collectGeometryCandidates(
|
||||
time: number,
|
||||
request: GeometryCandidateRequest,
|
||||
|
||||
@@ -26,6 +26,9 @@ export type LayoutIssueCode =
|
||||
// Cross-sample rotation finding — a spinning element whose bbox center drifts
|
||||
// because it pivots about the wrong point (bad transformOrigin/svgOrigin).
|
||||
| "rotation_pivot_drift"
|
||||
// Hub-referenced rotation finding — a gauge/clock/radar pointer whose recovered
|
||||
// center-of-rotation sits far from the dial's static hub (wrong pivot point).
|
||||
| "off_pivot_rotation"
|
||||
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
|
||||
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
|
||||
| "sweep_static"
|
||||
|
||||
Reference in New Issue
Block a user