mirror of
https://github.com/tiennm99/rubik.git
synced 2026-06-02 00:15:04 +00:00
edcb95c4a6
Drag-down on +z front-face cubies still inverted direction after the face-anchor fix. Root cause was a dual-reference-frame mismatch in chooseRotationAxis: curAngle = (drag · pixelsAxis) * signMul ← measured along pixelsAxis signMul = sign(motionScreen · drag) ← measured against drag When pixelsAxis and the drag vector are anti-aligned (e.g. dragAxis='y' where pixelsAxis points screen-UP since world +y projects to screen -y, but the user drags screen-DOWN), the two reference frames disagree and sign(curAngle) ends up opposite sign(motionScreen · drag) — so positive rotation moves the cubie opposite the user's drag. Fix: compare motionScreen against pixelsAxis (= screenDirs[dragAxisIdx]) so both halves of the curAngle formula share the same reference frame. Adds 24 correctness tests asserting sign(curAngle) == sign(motionScreen · drag) for every face × cardinal drag — fails on the previous code, passes after the fix. Also removes the temporary RUBIK_DEBUG diagnostic logs.
154 lines
6.5 KiB
JavaScript
154 lines
6.5 KiB
JavaScript
// 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 correctness: visual rotation matches drag direction', () => {
|
||
// For a small drag in direction d, the cubie at hitWorldPos must move on
|
||
// screen in approximately direction d. Equivalently, sign(curAngle) must
|
||
// equal sign(motionScreen · drag), where motionScreen is screen
|
||
// displacement under +1 rad rotation around rotAxis.
|
||
//
|
||
// Caller computes curAngle = (drag · pixelsAxis) * signMul, with
|
||
// pixelsAxis = screenDirs[dragAxisIdx]. So the correctness condition is:
|
||
// sign((drag · pixelsAxis) * signMul) == sign(motionScreen · drag)
|
||
//
|
||
// This catches the dual-reference-frame bug where signMul was computed
|
||
// against `drag` but dragPx was measured against `pixelsAxis` — produces
|
||
// OPPOSITE rotation when drag and pixelsAxis are anti-aligned (e.g. drag
|
||
// DOWN with dragAxis='y', since pixelsAxis points screen UP).
|
||
for (const faceAxis of FACE_AXES) {
|
||
for (const faceSign of SIDES) {
|
||
const sideName = `${faceSign > 0 ? '+' : '-'}${faceAxis}`;
|
||
const center = vecOnFace(faceAxis, faceSign, {});
|
||
for (const drag of DRAGS) {
|
||
it(`${sideName} face center, drag ${drag.name}: pivot rotation moves cubie in drag direction`, () => {
|
||
const r = callChoose(faceAxis, center, drag);
|
||
const dragVec = new Vector2(drag.dx, drag.dy);
|
||
const dragPx = r.screenDragDir.dot(dragVec);
|
||
const curAngleSign = Math.sign(dragPx * r.signMul);
|
||
|
||
// Compute screen motion for +1 rad rotation around rotAxis.
|
||
const axisVec = { x: [1,0,0], y: [0,1,0], z: [0,0,1] }[r.rotAxis];
|
||
const P = new Vector3(...center);
|
||
const v = new Vector3().crossVectors(new Vector3(...axisVec), P);
|
||
const motionScreen = isoProject(P.clone().add(v)).sub(isoProject(P));
|
||
const expectedSign = Math.sign(motionScreen.dot(dragVec));
|
||
|
||
expect(curAngleSign, `pivot rotation direction must match drag direction`).toBe(expectedSign);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
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);
|
||
});
|
||
}
|
||
});
|