fix(check): elongated pivot drift + counterfactual connector_detached (#2819)

This commit is contained in:
Xuanru Li
2026-07-26 21:43:42 -07:00
committed by GitHub
parent 45b458c007
commit 75ed99e1d4
4 changed files with 531 additions and 121 deletions
@@ -5,19 +5,34 @@ import type { RotationSample } from "./checkTypes.js";
const CANVAS = { width: 1000, height: 1000 };
/** One rotation sample; defaults describe a large, size-stable element. */
/** AABB of an unrotated (elemW×elemH) box after CSS rotation — mirrors the detector model. */
function rotatedAabb(elemW: number, elemH: number, angleDeg: number): { w: number; h: number } {
const rad = (angleDeg * Math.PI) / 180;
const cosAbs = Math.abs(Math.cos(rad));
const sinAbs = Math.abs(Math.sin(rad));
return { w: elemW * cosAbs + elemH * sinAbs, h: elemW * sinAbs + elemH * cosAbs };
}
/** One rotation sample; defaults describe a large square. */
function sample(overrides: Partial<RotationSample> = {}): RotationSample {
return { time: 0, selector: "#spokes", cx: 250, cy: 250, w: 200, h: 200, angle: 0, ...overrides };
}
/** A group that SHOULD fire: spins (0→90→180), size-stable, sizable, and its
* bbox center travels 50px — the wrong-pivot signature. threshold here is
* max(0.1*200, 0.02*1000) = 20px, so 50px drift clears it. */
/** Rigid rectangle sample: AABB is derived from unrotated size + angle. */
function rigidSample(
elemW: number,
elemH: number,
overrides: Partial<RotationSample> & { angle: number },
): RotationSample {
return sample({ ...rotatedAabb(elemW, elemH, overrides.angle), ...overrides });
}
/** A group that SHOULD fire: rigid square spin with bbox center traveling 50px. */
function driftingSpinner(): RotationSample[] {
return [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, cx: 250, cy: 280 }),
sample({ time: 2, angle: 180, cx: 250, cy: 300 }),
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(200, 200, { time: 1, angle: 90, cx: 250, cy: 280 }),
rigidSample(200, 200, { time: 2, angle: 180, cx: 250, cy: 300 }),
];
}
@@ -42,9 +57,9 @@ describe("detectRotationPivotDrift", () => {
// Stage 2 — real spin. A translating-but-not-spinning element is not our bug.
it("does not fire when the element barely rotates (fixed tilt, not spinning)", () => {
const group = [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 2, cx: 250, cy: 280 }),
sample({ time: 2, angle: 4, cx: 250, cy: 300 }),
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(200, 200, { time: 1, angle: 2, cx: 250, cy: 280 }),
rigidSample(200, 200, { time: 2, angle: 4, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
@@ -52,16 +67,14 @@ describe("detectRotationPivotDrift", () => {
// Stage 3 — size stability, WIDTH axis (scale/entrance, not pivot drift).
it("does not fire when width scales across samples", () => {
const group = [
sample({ time: 0, angle: 0, w: 100, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, w: 200, cx: 250, cy: 280 }),
sample({ time: 2, angle: 180, w: 300, cx: 250, cy: 300 }),
sample({ time: 0, angle: 0, w: 100, h: 200, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, w: 200, h: 200, cx: 250, cy: 280 }),
sample({ time: 2, angle: 180, w: 300, h: 200, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
// Stage 3 — size stability, HEIGHT axis. Regression for the width-only guard:
// fixed width, top-anchored height growth (top=100 → cy = 100 + h/2) drifts
// the AABB center 50px on its own. Must NOT be reported as pivot drift.
// Stage 3 — single-axis scale: fixed width + growing height moves the AABB center without a bad pivot.
it("does not fire when height scales (top-anchored) even though the AABB center moves", () => {
const group = [
sample({ time: 0, angle: 0, w: 100, h: 50, cx: 250, cy: 125 }),
@@ -71,6 +84,73 @@ describe("detectRotationPivotDrift", () => {
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
// Elongated rotators: every AABB fits one 400×80 rectangle; center drifts → fire.
it("fires on an elongated spinner whose long side is stable but per-axis AABB swings", () => {
const group = [
rigidSample(400, 80, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(400, 80, { time: 1, angle: 45, cx: 250, cy: 310 }),
rigidSample(400, 80, { time: 2, angle: 90, cx: 250, cy: 370 }),
];
const findings = detectRotationPivotDrift(group, CANVAS);
expect(findings).toHaveLength(1);
expect(findings[0]?.code).toBe("rotation_pivot_drift");
});
// Partial arc with no 90° pair: still one rigid 400×80 rectangle across 0°→20°→40°.
it("fires on a rigid elongated partial arc without a 90-degree sample pair", () => {
const group = [
rigidSample(400, 80, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(400, 80, { time: 1, angle: 20, cx: 250, cy: 310 }),
rigidSample(400, 80, { time: 2, angle: 40, cx: 250, cy: 370 }),
];
const findings = detectRotationPivotDrift(group, CANVAS);
expect(findings).toHaveLength(1);
expect(findings[0]?.code).toBe("rotation_pivot_drift");
});
// All samples singular (|cos2θ|<0.15): no invertible estimator — near-square AABB agreement is the fallback.
it("fires on a rigid elongated spin sampled only at singular 45-degree-class phases", () => {
const group = [
rigidSample(400, 80, { time: 0, angle: 45, cx: 250, cy: 250 }),
rigidSample(400, 80, { time: 1, angle: 135, cx: 250, cy: 310 }),
rigidSample(400, 80, { time: 2, angle: 225, cx: 250, cy: 370 }),
];
const findings = detectRotationPivotDrift(group, CANVAS);
expect(findings).toHaveLength(1);
expect(findings[0]?.code).toBe("rotation_pivot_drift");
expect(findings[0]?.message).toContain("120px");
});
// Two-axis scale/entrance: AABBs are not one rotated rectangle.
it("does not fire on two-axis scale/entrance that shrinks the long side while growing the short", () => {
const group = [
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
sample({ time: 1, angle: 45, w: 340, h: 200, cx: 170, cy: 100 }),
sample({ time: 2, angle: 90, w: 300, h: 300, cx: 150, cy: 150 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
// Scale pulse returns to the start size mid-trajectory — still not one rigid rectangle.
it("does not fire on a scale pulse that returns to the start AABB", () => {
const group = [
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
sample({ time: 1, angle: 45, w: 340, h: 300, cx: 170, cy: 150 }),
sample({ time: 2, angle: 90, w: 400, h: 100, cx: 200, cy: 50 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
// One valid 90° swap then a later scale — must not over-admit via pairwise swap.
it("does not fire when an early axis-swap is followed by a non-rigid scale sample", () => {
const group = [
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
sample({ time: 1, angle: 90, w: 100, h: 400, cx: 150, cy: 150 }),
sample({ time: 2, angle: 180, w: 400, h: 300, cx: 200, cy: 250 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
it("does not fire when a sample has a degenerate zero dimension", () => {
const group = driftingSpinner().map((s, i) => (i === 0 ? { ...s, w: 0 } : s));
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
@@ -78,27 +158,31 @@ describe("detectRotationPivotDrift", () => {
// Stage 4 — sizable. Tiny decorative spinners are ignored (area < 2500px²).
it("does not fire on a tiny element below the median-area floor", () => {
const group = driftingSpinner().map((s) => ({ ...s, w: 40, h: 40 }));
const group = [
rigidSample(40, 40, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(40, 40, { time: 1, angle: 90, cx: 250, cy: 280 }),
rigidSample(40, 40, { time: 2, angle: 180, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
// Stage 5 — center drift. A correctly-centered spinner holds its bbox center.
it("does not fire on a spinner whose bbox center stays put", () => {
const group = [
sample({ time: 0, angle: 0 }),
sample({ time: 1, angle: 120 }),
sample({ time: 2, angle: 240 }),
rigidSample(200, 200, { time: 0, angle: 0 }),
rigidSample(200, 200, { time: 1, angle: 120 }),
rigidSample(200, 200, { time: 2, angle: 240 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
it("uses the viewport floor when the element is small relative to a large canvas", () => {
// medianSize=200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
// medianSize200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
// A 40px drift is below 60 → clean; the same group fired on CANVAS above.
const group = [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, cx: 250, cy: 270 }),
sample({ time: 2, angle: 180, cx: 250, cy: 290 }),
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
rigidSample(200, 200, { time: 1, angle: 90, cx: 250, cy: 270 }),
rigidSample(200, 200, { time: 2, angle: 180, cx: 250, cy: 290 }),
];
expect(detectRotationPivotDrift(group, { width: 3000, height: 3000 })).toHaveLength(0);
});
@@ -114,9 +198,9 @@ describe("detectRotationPivotDrift", () => {
// as a fast spin and (with a drifting center) fire falsely.
it("does not treat a ±180° boundary wobble as spinning", () => {
const group = [
sample({ time: 0, angle: -175, cx: 250, cy: 250 }),
sample({ time: 1, angle: 175, cx: 250, cy: 280 }),
sample({ time: 2, angle: -172, cx: 250, cy: 300 }),
rigidSample(200, 200, { time: 0, angle: -175, cx: 250, cy: 250 }),
rigidSample(200, 200, { time: 1, angle: 175, cx: 250, cy: 280 }),
rigidSample(200, 200, { time: 2, angle: -172, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});
@@ -124,9 +208,9 @@ describe("detectRotationPivotDrift", () => {
it("still detects a real spin that crosses the ±180° boundary", () => {
// 170° → -100° → -10° is ~270° of genuine travel across the seam.
const group = [
sample({ time: 0, angle: 170, cx: 250, cy: 250 }),
sample({ time: 1, angle: -100, cx: 250, cy: 280 }),
sample({ time: 2, angle: -10, cx: 250, cy: 300 }),
rigidSample(200, 200, { time: 0, angle: 170, cx: 250, cy: 250 }),
rigidSample(200, 200, { time: 1, angle: -100, cx: 250, cy: 280 }),
rigidSample(200, 200, { time: 2, angle: -10, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(1);
});
+91 -46
View File
@@ -502,19 +502,16 @@ function detectSweepStatic(
];
}
// rotation_pivot_drift thresholds. A spinning element's bbox center should be
// fixed; drift beyond this signals a wrong pivot (transformOrigin/svgOrigin).
// rotation_pivot_drift: bbox center should stay fixed while the element spins.
const ROTATION_MIN_SAMPLES = 3;
// Degrees of angle spread that count as "actually spinning" (vs a static tilt).
// Minimum angle spread that counts as spinning rather than a static tilt.
const ROTATION_MIN_ANGLE_SPREAD_DEG = 20;
// Max bbox-width ratio across samples — above this it is scaling/entrancing,
// not spinning in place. A rigid anisotropic shape's axis-aligned bbox
// oscillates under rotation on its own (a plain square already swings 1.41x
// between flat and 45deg), so this must sit above that; 1.6 admits rotating
// squares/mild rectangles while still excluding gross scale/entrance growth
// and thin bars/lines whose bbox swings many-fold (e.g. a rotating reference
// arm). The bbox-CENTER drift below is the real spin-in-place discriminator.
// Long-axis AABB growth ceiling (square@45° ≈ 1.41×); rejects non-rigid blow-ups before the model fit.
const ROTATION_MAX_SIZE_RATIO = 1.6;
// Relative slack when matching observed AABB to one rigid unrotated rectangle.
const ROTATION_RIGID_AABB_RATIO = 1.15;
// |cos2θ| below this → 45°-class sample; skip as an unrotated-size estimator (singular).
const ROTATION_RIGID_ESTIMATE_MIN_DET = 0.15;
// Skip tiny decorative spinners; only sizable rotating figures matter.
const ROTATION_MIN_MEDIAN_AREA_PX = 2500;
const ROTATION_DRIFT_SIZE_FRACTION = 0.1;
@@ -616,30 +613,98 @@ function isActuallySpinning(group: RotationSample[]): boolean {
return maxAngleSpread(group.map((s) => s.angle)) > ROTATION_MIN_ANGLE_SPREAD_DEG;
}
/** Rigid bbox size in BOTH dimensions. A scale/entrance animation is not pivot
* drift; in particular top-anchored height scaling (fixed width, growing height)
* moves the AABB center on its own — the earlier width-only guard let that through
* as a false positive. */
function isRotationSizeStable(group: RotationSample[]): boolean {
const widths = group.map((s) => s.w);
const heights = group.map((s) => s.h);
const minWidth = Math.min(...widths);
const minHeight = Math.min(...heights);
if (minWidth <= 0 || minHeight <= 0) return false;
/** AABB of an axis-aligned rectangle of size (elemW, elemH) after CSS rotation `angleDeg`. */
function aabbForRotatedRect(
elemW: number,
elemH: number,
angleDeg: number,
): { w: number; h: number } {
const rad = (angleDeg * Math.PI) / 180;
const cosAbs = Math.abs(Math.cos(rad));
const sinAbs = Math.abs(Math.sin(rad));
return { w: elemW * cosAbs + elemH * sinAbs, h: elemW * sinAbs + elemH * cosAbs };
}
/** Invert one sample to unrotated (elemW, elemH); null when θ is near 45° (singular). */
function unrotatedSizeFromSample(sample: RotationSample): { w: number; h: number } | null {
const rad = (sample.angle * Math.PI) / 180;
const cosAbs = Math.abs(Math.cos(rad));
const sinAbs = Math.abs(Math.sin(rad));
const det = cosAbs * cosAbs - sinAbs * sinAbs; // cos(2θ)
if (Math.abs(det) < ROTATION_RIGID_ESTIMATE_MIN_DET) return null;
const elemW = (cosAbs * sample.w - sinAbs * sample.h) / det;
const elemH = (cosAbs * sample.h - sinAbs * sample.w) / det;
if (!(elemW > 0) || !(elemH > 0)) return null;
return { w: elemW, h: elemH };
}
function aabbMatchesSample(expected: { w: number; h: number }, sample: RotationSample): boolean {
if (expected.w <= 0 || expected.h <= 0 || sample.w <= 0 || sample.h <= 0) return false;
return (
Math.max(...widths) / minWidth <= ROTATION_MAX_SIZE_RATIO &&
Math.max(...heights) / minHeight <= ROTATION_MAX_SIZE_RATIO
Math.max(expected.w, sample.w) / Math.min(expected.w, sample.w) <= ROTATION_RIGID_AABB_RATIO &&
Math.max(expected.h, sample.h) / Math.min(expected.h, sample.h) <= ROTATION_RIGID_AABB_RATIO
);
}
function isSingularRotationAngle(angleDeg: number): boolean {
const rad = (angleDeg * Math.PI) / 180;
const cosAbs = Math.abs(Math.cos(rad));
const sinAbs = Math.abs(Math.sin(rad));
return Math.abs(cosAbs * cosAbs - sinAbs * sinAbs) < ROTATION_RIGID_ESTIMATE_MIN_DET;
}
function isNearSquareAabb(sample: RotationSample): boolean {
if (sample.w <= 0 || sample.h <= 0) return false;
return Math.max(sample.w, sample.h) / Math.min(sample.w, sample.h) <= ROTATION_RIGID_AABB_RATIO;
}
/**
* All samples are 45°-class (no invertible estimator): a rigid rectangle projects to one
* near-square AABB size at every such phase, so mutual AABB agreement is the rigidity proof.
*/
function fitsSingularPhaseRigidProjection(group: RotationSample[]): boolean {
if (group.length === 0 || !group.every((s) => isSingularRotationAngle(s.angle))) return false;
if (!group.every(isNearSquareAabb)) return false;
const ref = group[0];
if (!ref) return false;
return group.every((sample) => aabbMatchesSample({ w: ref.w, h: ref.h }, sample));
}
/**
* Every sample's AABB matches one fixed unrotated rectangle spun by that sample's angle.
* Scale/entrance fails; partial-arc and all-singular (45°-class) rigid spins still pass.
*/
function fitsOneRigidRectangle(group: RotationSample[]): boolean {
for (const ref of group) {
const size = unrotatedSizeFromSample(ref);
if (!size) continue;
if (
group.every((sample) =>
aabbMatchesSample(aabbForRotatedRect(size.w, size.h, sample.angle), sample),
)
) {
return true;
}
}
return fitsSingularPhaseRigidProjection(group);
}
/** Rigid spin: long AABB side stays bounded, and all samples fit one rotated rectangle. */
function isRotationSizeStable(group: RotationSample[]): boolean {
if (group.some((s) => s.w <= 0 || s.h <= 0)) return false;
const longSides = group.map((s) => Math.max(s.w, s.h));
const minLong = Math.min(...longSides);
if (minLong <= 0) return false;
if (Math.max(...longSides) / minLong > ROTATION_MAX_SIZE_RATIO) return false;
return fitsOneRigidRectangle(group);
}
/** Skip tiny decorative spinners; only sizable rotating figures matter. */
function isSizableRotation(group: RotationSample[]): boolean {
return median(group.map((s) => s.w * s.h)) >= ROTATION_MIN_MEDIAN_AREA_PX;
}
/** The size/motion gates a selector group must clear before the (viewport-
* dependent) center-drift test. Each is a strict FP guard, deliberately so:
* a false positive feeds destructive downstream auto-fixes. */
/** Size/motion FP gates before the viewport-dependent center-drift test. */
function isRotationDriftCandidate(group: RotationSample[]): boolean {
return (
hasEnoughRotationSamples(group) &&
@@ -649,27 +714,7 @@ function isRotationDriftCandidate(group: RotationSample[]): boolean {
);
}
/**
* rotation_pivot_drift: an element that visibly SPINS (its rotation angle varies
* across the seek grid) while its bounding-box CENTER travels is pivoting about
* the wrong point — the classic symptom of a transformOrigin/svgOrigin authored
* as hardcoded pixels against a coordinate space the element was later resized
* out of (e.g. spokes set to `250px 250px` inside a 460px-rendered 500-viewBox
* SVG). A correctly centered spinner holds its bbox center fixed.
*
* Cross-sample by necessity — one frame can't distinguish spin-in-place from
* pivot drift. FP guards are deliberately strict because a false positive feeds
* destructive downstream auto-fixes: requires real rotation, a stable bbox size
* in both axes (excludes scale/entrance animations), a sizable element, and
* honors `[data-layout-allow-orbit]` opt-outs (applied browser-side).
*
* Invariant: samples are grouped by `selector`, assumed stable across seeks.
* An element without a stable id/class can fall back to a `nth-of-type(N)`
* selector whose N shifts as siblings enter/exit — so in that fringe it may be
* mis-grouped (a missed detection, or in a rare exit-then-enter aliasing case a
* spurious one). Author-crafted rotating figures effectively always carry stable
* anchors; a stable-anchor gate on the browser sampler is the structural fix.
*/
/** Flags a spinning element whose bbox center drifts — wrong transformOrigin/svgOrigin (elongated rotators included). */
export function detectRotationPivotDrift(
samples: RotationSample[],
canvas: Canvas,