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.
This commit is contained in:
2026-05-09 11:28:29 +07:00
parent 5f019d0749
commit c615138853
2 changed files with 125 additions and 1 deletions
+11 -1
View File
@@ -49,8 +49,18 @@ export function chooseRotationAxis({ hitFaceAxis, hitWorldPos, dx, dy, projectFn
// Determine sign so that positive screen-drag along screenDirs[dragAxisIdx]
// produces a rotation whose induced motion at hitWorldPos points the same way.
//
// Anchor the cross product to the face-normal axis only (drop in-plane
// components of hitWorldPos). For edge/corner cubies the in-plane
// components leak into motionWorld as a face-normal velocity (β·F̂),
// which projects onto screen and can flip signMul under tilted cameras —
// making drag-to-rotate go the OPPOSITE direction. Face-anchored motion
// is purely tangential, so signMul is identical for every cubie on the
// same face (the physically correct invariant).
const faceAnchor = new Vector3();
faceAnchor[hitFaceAxis] = hitWorldPos[hitFaceAxis];
const motionWorld = new Vector3()
.crossVectors(AXIS_VECS[rotAxis], hitWorldPos)
.crossVectors(AXIS_VECS[rotAxis], faceAnchor)
.normalize();
const motionScreen = projectFn(hitWorldPos.clone().add(motionWorld)).sub(screenOrigin);
const signMul = motionScreen.dot(drag) >= 0 ? 1 : -1;
+114
View File
@@ -0,0 +1,114 @@
// 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);
});
}
});