mirror of
https://github.com/tiennm99/rubik.git
synced 2026-08-06 20:25:52 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user