Skip to content

Commit e710a16

Browse files
authored
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)
1 parent e7f9918 commit e710a16

8 files changed

Lines changed: 878 additions & 13 deletions

File tree

.fallowrc.jsonc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,23 @@
593593
// makes non-trivial.
594594
"packages/studio-server/src/helpers/screenshotClip.ts",
595595
"packages/studio/vite.browser.ts",
596+
// off_pivot_rotation Kåsa circle fit (feat/needle-pivot-offset-check):
597+
// fitCirclePoints in layout-audit.browser.js and fitCircle in
598+
// checkPipeline.ts are the same least-squares circle fit, but the browser
599+
// copy is injected as a raw string via page.addScriptTag and cannot import
600+
// the Node-side module across puppeteer's serialization boundary. The two
601+
// copies carry matching "KEEP IN SYNC" headers; the duplication is
602+
// intentional and per-language, so it's exempted here rather than faked
603+
// away with cosmetic divergence.
604+
"packages/cli/src/commands/layout-audit.browser.js",
605+
"packages/cli/src/utils/checkPipeline.ts",
606+
// check.test.ts: the fakeDriver-based command tests share a pre-existing
607+
// arrange/act/assert scaffold (runScenario + vi.fn runPipeline + spy +
608+
// createCheckCommand). Adding the required collectOffPivotRotationSample
609+
// stub to the CheckAuditDriver fake shifts line numbers and re-flags that
610+
// inherited clone; consistent with the norm above of leaving parallel
611+
// command-test cases unabstracted.
612+
"packages/cli/src/commands/check.test.ts",
596613
],
597614
},
598615
"health": {

packages/cli/src/commands/check.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
150150
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
151151
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
152152
collectRotationSample: vi.fn(async (_time: number) => []),
153+
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
153154
collectGeometryCandidates: vi.fn(async () => []),
154155
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
155156
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,4 +1531,197 @@
15311531
}
15321532
return samples;
15331533
};
1534+
1535+
// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
1536+
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
1537+
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
1538+
// center orbits either way), so this records two MATERIAL points on each
1539+
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
1540+
// rendered transform is honored regardless of svgOrigin/transform-origin — and
1541+
// the dial's static hub (the point shared by the most non-rotating circles).
1542+
// The pipeline fits a rotation to the material-point trajectories to recover
1543+
// the real center-of-rotation and flags it when it drifts off that hub.
1544+
function ctmRotationDeg(ctm) {
1545+
if (!ctm) return null;
1546+
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
1547+
}
1548+
1549+
function ctmScale(ctm) {
1550+
return Math.hypot(ctm.a, ctm.b);
1551+
}
1552+
1553+
function mapPoint(svg, ctm, x, y) {
1554+
const point = svg.createSVGPoint();
1555+
point.x = x;
1556+
point.y = y;
1557+
const mapped = point.matrixTransform(ctm);
1558+
return { x: mapped.x, y: mapped.y };
1559+
}
1560+
1561+
// Walks up to (and including) the composition root, NOT just the owner <svg>:
1562+
// an element spun by a div ancestor above its svg must not be mistaken for a
1563+
// static hub anchor (else a lone rotating arc becomes its own dial center).
1564+
function hasRotatedAncestor(element, root) {
1565+
let node = element;
1566+
while (node) {
1567+
const angle = rotationAngleDeg(getComputedStyle(node).transform);
1568+
if (angle !== null && Math.abs(angle) > 1) return true;
1569+
if (node === root) break;
1570+
node = node.parentElement;
1571+
}
1572+
return false;
1573+
}
1574+
1575+
// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
1576+
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
1577+
// string (no import across the puppeteer boundary), so the Kåsa math is
1578+
// intentionally duplicated per-language. Any change must land in both copies.
1579+
function fitCirclePoints(points) {
1580+
const count = points.length;
1581+
if (count < 3) return null;
1582+
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
1583+
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
1584+
let suu = 0,
1585+
svv = 0,
1586+
suv = 0,
1587+
suuu = 0,
1588+
svvv = 0,
1589+
suvv = 0,
1590+
svuu = 0;
1591+
for (const point of points) {
1592+
const u = point.x - meanX;
1593+
const v = point.y - meanY;
1594+
suu += u * u;
1595+
svv += v * v;
1596+
suv += u * v;
1597+
suuu += u * u * u;
1598+
svvv += v * v * v;
1599+
suvv += u * v * v;
1600+
svuu += v * u * u;
1601+
}
1602+
const det = suu * svv - suv * suv;
1603+
if (Math.abs(det) < 1e-6) return null;
1604+
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
1605+
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
1606+
const cx = uc + meanX;
1607+
const cy = vc + meanY;
1608+
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
1609+
let squaredError = 0;
1610+
for (const point of points) {
1611+
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
1612+
squaredError += delta * delta;
1613+
}
1614+
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
1615+
}
1616+
1617+
// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
1618+
// the largest static, near-circular path and recover its arc center.
1619+
function arcHubForSvg(svg, root) {
1620+
let best = null;
1621+
for (const path of Array.from(svg.querySelectorAll("path"))) {
1622+
if (hasRotatedAncestor(path, root)) continue;
1623+
if (typeof path.getTotalLength !== "function") continue;
1624+
const total = path.getTotalLength();
1625+
if (total < 200) continue;
1626+
const ctm = path.getScreenCTM();
1627+
if (!ctm) continue;
1628+
const points = [];
1629+
for (let i = 0; i <= 16; i++) {
1630+
const local = path.getPointAtLength((total * i) / 16);
1631+
points.push(mapPoint(svg, ctm, local.x, local.y));
1632+
}
1633+
const fit = fitCirclePoints(points);
1634+
if (!fit || fit.radius < 40) continue;
1635+
if (fit.residual > 0.05 * fit.radius) continue;
1636+
if (!best || fit.radius > best.radius) best = fit;
1637+
}
1638+
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
1639+
}
1640+
1641+
function dialHubForSvg(svg, root) {
1642+
const centers = [];
1643+
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
1644+
if (hasRotatedAncestor(circle, root)) continue;
1645+
const ctm = circle.getScreenCTM();
1646+
if (!ctm) continue;
1647+
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
1648+
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
1649+
const center = mapPoint(svg, ctm, cx, cy);
1650+
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
1651+
centers.push({ x: center.x, y: center.y, radius });
1652+
}
1653+
let best = null;
1654+
for (const anchor of centers) {
1655+
const cluster = centers.filter(
1656+
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
1657+
);
1658+
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
1659+
}
1660+
if (best && best.cluster.length >= 2) {
1661+
const count = best.cluster.length;
1662+
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
1663+
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
1664+
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
1665+
return { hx, hy, hr, count };
1666+
}
1667+
return arcHubForSvg(svg, root);
1668+
}
1669+
1670+
window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
1671+
const root =
1672+
document.querySelector("[data-composition-id][data-width][data-height]") ||
1673+
document.querySelector("[data-composition-id]") ||
1674+
document.body;
1675+
const samples = [];
1676+
const hubCache = new Map();
1677+
const CANDIDATE_CAP = 60;
1678+
for (const element of Array.from(
1679+
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
1680+
)) {
1681+
if (samples.length >= CANDIDATE_CAP) break;
1682+
const svg = element.ownerSVGElement;
1683+
if (!svg || typeof element.getBBox !== "function") continue;
1684+
if (element.closest("[data-layout-allow-orbit]")) continue;
1685+
if (!isVisibleElement(element, 0.05)) continue;
1686+
const ctm = element.getScreenCTM();
1687+
const angle = ctmRotationDeg(ctm);
1688+
if (ctm === null || angle === null) continue;
1689+
let bbox;
1690+
try {
1691+
bbox = element.getBBox();
1692+
} catch {
1693+
continue;
1694+
}
1695+
const long = Math.max(bbox.width, bbox.height);
1696+
const short = Math.min(bbox.width, bbox.height);
1697+
if (short <= 0 || long / short < 3 || long < 40) continue;
1698+
const vertical = bbox.height >= bbox.width;
1699+
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
1700+
const a = vertical
1701+
? mapPoint(svg, ctm, midMajor, bbox.y)
1702+
: mapPoint(svg, ctm, bbox.x, midMajor);
1703+
const b = vertical
1704+
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
1705+
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
1706+
let hub = hubCache.get(svg);
1707+
if (hub === undefined) {
1708+
hub = dialHubForSvg(svg, root);
1709+
hubCache.set(svg, hub);
1710+
}
1711+
samples.push({
1712+
selector: selectorFor(element),
1713+
ax: round(a.x),
1714+
ay: round(a.y),
1715+
bx: round(b.x),
1716+
by: round(b.y),
1717+
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
1718+
angle: round(angle),
1719+
hx: hub ? round(hub.hx) : null,
1720+
hy: hub ? round(hub.hy) : null,
1721+
hr: hub ? round(hub.hr) : null,
1722+
hubCount: hub ? hub.count : 0,
1723+
});
1724+
}
1725+
return samples;
1726+
};
15341727
})();

packages/cli/src/utils/checkBrowser.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import type {
4242
ContrastCapture,
4343
GeometryCandidateRequest,
4444
MotionSpecResolution,
45+
OffPivotFrame,
46+
OffPivotRotationSample,
4547
RotationSample,
4648
RunAuditGrid,
4749
} from "./checkTypes.js";
@@ -349,6 +351,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
349351
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
350352
collectLayoutGeometry: () => collectLayoutGeometry(page),
351353
collectRotationSample: (time) => collectRotationSample(page, time),
354+
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
352355
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
353356
collectMotionFrame: (time, selectors, scopes) =>
354357
collectMotionFrame(page, time, selectors, scopes),
@@ -470,13 +473,20 @@ async function collectLayoutGeometry(page: Page): Promise<string> {
470473
});
471474
}
472475

473-
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
474-
const raw = await page.evaluate(() => {
475-
const sample = Reflect.get(window, "__hyperframesRotationSample");
476+
/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
477+
* and return its array result (or [] when absent / non-array). Shared by the
478+
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
479+
async function evaluateSampler(page: Page, globalName: string): Promise<unknown[]> {
480+
return page.evaluate((name) => {
481+
const sample = Reflect.get(window, name);
476482
if (typeof sample !== "function") return [];
477483
const result = Reflect.apply(sample, window, []);
478484
return Array.isArray(result) ? result : [];
479-
});
485+
}, globalName);
486+
}
487+
488+
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
489+
const raw = await evaluateSampler(page, "__hyperframesRotationSample");
480490
return raw.flatMap((value) => parseRotationSample(value, time));
481491
}
482492

@@ -494,6 +504,51 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
494504
return [{ time, selector, cx, cy, w, h, angle }];
495505
}
496506

507+
async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
508+
const raw = await evaluateSampler(page, "__hyperframesOffPivotRotationSample");
509+
return { time, samples: raw.flatMap(parseOffPivotRotationSample) };
510+
}
511+
512+
/** Read every named key as a finite number; null if ANY is missing/non-finite.
513+
* The mapped return type keeps each field a plain `number` (not `number |
514+
* undefined`) so callers read `nums.ax` without re-narrowing. */
515+
function requiredNumbers<K extends string>(
516+
value: Record<string, unknown>,
517+
keys: readonly K[],
518+
): { [P in K]: number } | null {
519+
const out = {} as { [P in K]: number };
520+
for (const key of keys) {
521+
const num = numberValue(value, key);
522+
if (num === null) return null;
523+
out[key] = num;
524+
}
525+
return out;
526+
}
527+
528+
const OFF_PIVOT_REQUIRED_NUMBERS = ["ax", "ay", "bx", "by", "len", "angle", "hubCount"] as const;
529+
530+
function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
531+
if (!isRecord(value)) return [];
532+
const selector = stringValue(value, "selector");
533+
const nums = requiredNumbers(value, OFF_PIVOT_REQUIRED_NUMBERS);
534+
if (!selector || !nums) return [];
535+
return [
536+
{
537+
selector,
538+
ax: nums.ax,
539+
ay: nums.ay,
540+
bx: nums.bx,
541+
by: nums.by,
542+
len: nums.len,
543+
angle: nums.angle,
544+
hx: numberValue(value, "hx"),
545+
hy: numberValue(value, "hy"),
546+
hr: numberValue(value, "hr"),
547+
hubCount: nums.hubCount,
548+
},
549+
];
550+
}
551+
497552
async function collectGeometryCandidates(
498553
page: Page,
499554
time: number,
@@ -1078,6 +1133,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
10781133
"panel_out_of_canvas",
10791134
"connector_detached",
10801135
"rotation_pivot_drift",
1136+
"off_pivot_rotation",
10811137
"motion_appears_late",
10821138
"motion_out_of_order",
10831139
"motion_off_frame",

0 commit comments

Comments
 (0)