Files
rubik/tests/gesture-math.test.js
T
tiennm99 c615138853 fix(controls): face-anchor cross product to stop edge/corner rotation reversing direction
Drag-to-rotate sent edge and corner cubies the opposite direction at tilted
camera angles. Root cause: motionWorld = R̂ × hitWorldPos picks up an in-plane
component of P (β·F̂) for non-center cubies; under projection this can
dominate motionScreen and flip signMul.

Anchor the cross product to the face-normal axis only — drops the leakage
so signMul is identical for every cubie on a face.

Adds tests/gesture-math.test.js with face-invariance assertions across
centers, edges, and corners on all 6 faces × 4 cardinal drags.
2026-05-09 11:28:29 +07:00

115 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Face-invariance regression test for chooseRotationAxis.
//
// Bug: motionWorld = R̂ × hitWorldPos leaks a face-normal component (β·F̂)
// for edge/corner cubies. Under a tilted projection, this leakage can flip
// signMul, causing drag-to-rotate to go the OPPOSITE direction.
//
// Invariant: signMul and rotAxis must depend only on (hitFaceAxis, drag),
// not on which cubie on the face was clicked. Face-center, edge, and corner
// cubies on the same face MUST agree.
//
// See: plans/reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md
import { describe, it, expect } from 'vitest';
import { Vector2, Vector3 } from 'three';
import { chooseRotationAxis } from '../src/lib/controls/gesture-math.js';
// Affine isometric-ish projection. Every world axis maps to a non-zero
// screen component, exposing β-leakage. (If F̂ projected to zero on screen,
// the bug would be invisible — but real orbit cameras tilt this way.)
function isoProject(worldVec) {
return new Vector2(
worldVec.x - 0.5 * worldVec.z,
-(worldVec.y - 0.5 * worldVec.z)
);
}
const FACE_AXES = ['x', 'y', 'z'];
const SIDES = [-1, 1];
// Build cubie positions on a given face: center, 4 edges, 4 corners.
function cubiesOnFace(faceAxis, faceSign) {
const inFaceAxes = FACE_AXES.filter((a) => a !== faceAxis);
const out = [];
const center = vecOnFace(faceAxis, faceSign, {});
out.push({ pos: center, kind: 'center' });
for (const a of inFaceAxes) {
for (const s of SIDES) {
out.push({ pos: vecOnFace(faceAxis, faceSign, { [a]: s }), kind: `edge±${a}` });
}
}
const [a, b] = inFaceAxes;
for (const sa of SIDES) {
for (const sb of SIDES) {
out.push({ pos: vecOnFace(faceAxis, faceSign, { [a]: sa, [b]: sb }), kind: `corner` });
}
}
return out;
}
function vecOnFace(faceAxis, faceSign, others) {
const o = { x: 0, y: 0, z: 0, ...others };
o[faceAxis] = faceSign;
return [o.x, o.y, o.z];
}
const DRAGS = [
{ dx: 10, dy: 0, name: 'right' },
{ dx: -10, dy: 0, name: 'left' },
{ dx: 0, dy: 10, name: 'down' },
{ dx: 0, dy: -10, name: 'up' }
];
function callChoose(faceAxis, pos, drag) {
return chooseRotationAxis({
hitFaceAxis: faceAxis,
hitWorldPos: new Vector3(...pos),
dx: drag.dx,
dy: drag.dy,
projectFn: isoProject
});
}
describe('chooseRotationAxis face-invariance: edges and corners agree with face-center', () => {
for (const faceAxis of FACE_AXES) {
for (const faceSign of SIDES) {
const sideName = `${faceSign > 0 ? '+' : '-'}${faceAxis}`;
const cubies = cubiesOnFace(faceAxis, faceSign);
const center = cubies[0];
for (const drag of DRAGS) {
it(`${sideName} face, drag ${drag.name}: 8 non-center cubies match center`, () => {
const c = callChoose(faceAxis, center.pos, drag);
for (const cubie of cubies.slice(1)) {
const r = callChoose(faceAxis, cubie.pos, drag);
const ctx = `${cubie.kind} @ (${cubie.pos.join(',')})`;
expect(r.rotAxis, `rotAxis mismatch for ${ctx}`).toBe(c.rotAxis);
expect(r.signMul, `signMul mismatch for ${ctx}`).toBe(c.signMul);
}
});
}
}
}
});
describe('chooseRotationAxis: face-center sign convention smoke test', () => {
// Pin expected rotAxis for each face × cardinal drag at the face center.
// Locks in the convention so a future refactor of signMul math doesn't
// silently flip rotation direction.
const cases = [
['x', 1, 'right', 'y'], ['x', 1, 'up', 'z'],
['x', -1, 'right', 'y'], ['x', -1, 'up', 'z'],
['y', 1, 'right', 'z'], ['y', 1, 'up', 'x'],
['y', -1, 'right', 'z'], ['y', -1, 'up', 'x'],
['z', 1, 'right', 'y'], ['z', 1, 'up', 'x'],
['z', -1, 'right', 'y'], ['z', -1, 'up', 'x']
];
for (const [faceAxis, faceSign, dragName, expectedRotAxis] of cases) {
it(`${faceSign > 0 ? '+' : '-'}${faceAxis} face, drag ${dragName} → rotAxis=${expectedRotAxis}`, () => {
const drag = DRAGS.find((d) => d.name === dragName);
const r = callChoose(faceAxis, vecOnFace(faceAxis, faceSign, {}), drag);
expect(r.rotAxis).toBe(expectedRotAxis);
});
}
});