diff --git a/.gitignore b/.gitignore index 2e6c1f3..e0b1c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist .DS_Store .vite *.log +.claude diff --git a/plans/archive/260509-0932-fix-edge-orientation-bug/phase-01-diagnostic-tests.md b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-01-diagnostic-tests.md new file mode 100644 index 0000000..68ab60c --- /dev/null +++ b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-01-diagnostic-tests.md @@ -0,0 +1,111 @@ +--- +phase: 1 +title: "Focused regression test" +status: pending +priority: P1 +effort: "45m" +dependencies: [] +--- + +# Phase 1: Focused regression test + +## Overview + +Write a single targeted test for `chooseRotationAxis` that proves the H4/H5 invariant: **`signMul` must depend only on (hitFaceAxis, dragAxis, drag) — not on which cubie on the face was clicked.** Any face-center, edge, or corner cubie on the same face producing the same drag must yield the same signMul. + +This test will FAIL on current code and PASS after the Phase-2 fix. It locks in the fix. + +## Requirements + +**Functional** +- Test exercises `chooseRotationAxis` for face-center vs edge vs corner cubies on each of the 6 faces. +- For each face × drag direction, asserts signMul is identical regardless of which cubie position on that face is clicked. +- Uses a simple deterministic projection function (no real Three.js camera needed). + +**Non-functional** +- Test file ≤120 LOC. +- Runs under Vitest in <500ms. +- Independent of dev server / WebGL. + +## Architecture + +One new test file, no source changes: +``` +tests/ + gesture-math.test.js (new) — H4/H5 invariant test +``` + +The test uses a hand-rolled `projectFn` that maps a 3D world point to a 2D screen point via a simple tilted-projection (e.g., isometric: `screen.x = world.x - 0.5*world.z`, `screen.y = -(world.y - 0.5*world.z)`). This is enough to make ALL three world axes have non-zero screen projection, exposing the β-leakage bug. + +## Related code files + +- **Create:** `tests/gesture-math.test.js` +- **Read for context:** `src/lib/controls/gesture-math.js` + +## Implementation steps + +### Step 1.1 — Build a deterministic isometric projectFn + +```js +function makeIsoProject() { + return (worldVec) => new Vector2( + worldVec.x - 0.5 * worldVec.z, + -(worldVec.y - 0.5 * worldVec.z) + ); +} +``` + +This non-degenerate projection guarantees all 3 axes contribute to screen — exactly the condition under which the β-leakage bug surfaces. + +### Step 1.2 — Define cubie sample positions per face + +For each face (`+x, -x, +y, -y, +z, -z`): +- Face center: e.g., `(1, 0, 0)` for +x. +- 4 edge cubies on that face: e.g., `(1, ±1, 0)` and `(1, 0, ±1)` for +x. +- 4 corner cubies on that face: e.g., `(1, ±1, ±1)` for +x. + +### Step 1.3 — Assert signMul invariant + +For each face × cardinal drag direction `(dx, dy) ∈ {(+10, 0), (-10, 0), (0, +10), (0, -10)}`: +1. Compute `signMul_center` from `chooseRotationAxis({hitFaceAxis, hitWorldPos: faceCenter, dx, dy, projectFn})`. +2. For each edge and corner position on that face, compute `signMul_edge`. +3. Assert `signMul_edge === signMul_center` AND `rotAxis_edge === rotAxis_center` (axis must also be face-invariant). + +This is the exact H4/H5 invariant. **Fails on current code; passes after Phase 2.** + +### Step 1.4 — Add a smoke test pinning expected signMul values for face centers + +Hard-code expected `(rotAxis, signMul)` for all 6 faces × 4 cardinal drags. This catches accidental sign-convention regressions in either the math or the projection. + +### Step 1.5 — Run + +```bash +npm test 2>&1 | tee plans/260509-0932-fix-edge-orientation-bug/baseline-test.log +``` + +Confirm test FAILS on edge/corner cases (that's expected — proves the bug). + +## Todo list + +- [ ] 1.1 Add isometric `projectFn` helper inside the test file +- [ ] 1.2 Tabulate face × cubie-position samples +- [ ] 1.3 Implement face-invariance assertion loop (~6 faces × 4 drags × ~9 positions) +- [ ] 1.4 Add 24-row table of expected face-center (rotAxis, signMul) +- [ ] 1.5 Run; capture baseline.log showing failures on edges/corners + +## Success criteria + +- [ ] `tests/gesture-math.test.js` exists and runs under Vitest. +- [ ] Test FAILS on current code (proves bug exists in the form predicted). +- [ ] Failure message clearly identifies which (face, drag, cubiePos) combination diverges from face-center signMul. + +## Risk assessment + +- **Risk:** Test passes on current code (bug isn't H4/H5 after all). + **Mitigation:** If test passes, the algebraic proof was wrong; revisit hypotheses (loop back to brainstorm). Don't apply Phase-2 fix in that case. +- **Risk:** Test's isometric projection doesn't match any real camera angle, so the bug it exposes is theoretical. + **Mitigation:** The math says β-leakage occurs at *any* camera where face normal and drag axis both have non-zero screen projection — that's most of the orbit camera's range. The isometric projection is representative. + +## Next steps + +→ Phase 2 (apply faceAnchor fix in `gesture-math.js`). diff --git a/plans/archive/260509-0932-fix-edge-orientation-bug/phase-02-targeted-fix.md b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-02-targeted-fix.md new file mode 100644 index 0000000..8a17597 --- /dev/null +++ b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-02-targeted-fix.md @@ -0,0 +1,95 @@ +--- +phase: 2 +title: "faceAnchor fix" +status: pending +priority: P1 +effort: "15m" +dependencies: [1] +--- + +# Phase 2: faceAnchor fix + +## Overview + +Apply the algebraically-derived fix in `chooseRotationAxis`: project `hitWorldPos` onto the face-normal axis before the cross product, eliminating β-leakage that flips signMul on edge/corner cubies. + +## Requirements + +**Functional** +- Phase-1 test passes (face-invariance of signMul holds). +- All existing tests still pass. +- Manual repro: drag any edge/corner sticker, rotation matches drag direction. + +**Non-functional** +- Single file modified: `src/lib/controls/gesture-math.js`. +- ~5-line diff in `chooseRotationAxis`. +- No new exports, no new dependencies. + +## Architecture + +Replace the motionWorld computation in `chooseRotationAxis`: + +```js +// BEFORE (β leakage on edges/corners) +const motionWorld = new Vector3() + .crossVectors(AXIS_VECS[rotAxis], hitWorldPos) + .normalize(); + +// AFTER (face-anchored, no leakage) +const faceAnchor = new Vector3(); +faceAnchor[hitFaceAxis] = hitWorldPos[hitFaceAxis]; // ±1 +const motionWorld = new Vector3() + .crossVectors(AXIS_VECS[rotAxis], faceAnchor) + .normalize(); +``` + +Why this works: `faceAnchor = αF̂` (only the face-normal component of hitWorldPos). Then `motionWorld = R̂ × αF̂ = -αD̂` — pure tangential. β term vanishes. signMul becomes determined solely by `(hitFaceAxis, rotAxis, dragAxis)` and the camera projection of `D̂`, not by which cubie on the face was clicked. + +## Related code files + +- **Modify:** `src/lib/controls/gesture-math.js` (lines 50-56) +- **No other changes.** + +## Implementation steps + +1. Open `src/lib/controls/gesture-math.js`. +2. In `chooseRotationAxis`, locate the `motionWorld` declaration (around line 52). +3. Insert `faceAnchor` construction: + ```js + const faceAnchor = new Vector3(); + faceAnchor[hitFaceAxis] = hitWorldPos[hitFaceAxis]; + ``` +4. Replace `hitWorldPos` with `faceAnchor` in the `crossVectors` call. +5. Run `npm test` — Phase-1 test must turn green; existing tests must stay green. +6. Run `npm run dev` — manual repro: drag every edge piece on every face from 4 different camera angles. Verify rotation direction matches drag every single time. + +## Todo list + +- [ ] Edit `gesture-math.js:chooseRotationAxis` — insert faceAnchor, swap arg in crossVectors +- [ ] `npm test` — all green (Phase-1 + existing) +- [ ] Manual repro — 24+ drag combinations succeed +- [ ] If any manual repro fails, capture (face, sticker, drag, expected, actual) and reopen brainstorm — likely a missed sign convention + +## Success criteria + +- [ ] Phase-1 test passes. +- [ ] `npm test` exits 0. +- [ ] Manual repro: every edge piece × every face × cardinal drag directions × 4 camera azimuths produces correct rotation direction. +- [ ] Diff is ≤8 lines added, ≤3 removed in a single file. + +## Risk assessment + +- **Risk:** Fix changes behavior for face-center cubies (which currently work). + **Mitigation:** Algebraically, faceAnchor for face centers is identical to hitWorldPos (β=γ=0 for centers). No change. Phase-1 test's smoke-row covers this — face-center signMul values must remain unchanged. +- **Risk:** Corner cubies (γ≠0) regress. + **Mitigation:** γ component is along R̂; `R̂ × γR̂ = 0`, contributes nothing to motionWorld. Removing γ from the cross-product input has no effect for corners — the fix is an identity transformation for them too. Phase-1 test covers corners. +- **Risk:** Manual repro still fails despite green tests. + **Mitigation:** Means the test missed a case. Capture the failing camera+drag+sticker, add to Phase-1 test, repeat fix iteration. + +## Security considerations + +None — pure client-side gesture math. + +## Next steps + +→ Phase 3 — keep Phase-1 test in CI as the regression guard. diff --git a/plans/archive/260509-0932-fix-edge-orientation-bug/phase-03-regression-guard.md b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-03-regression-guard.md new file mode 100644 index 0000000..2bc224c --- /dev/null +++ b/plans/archive/260509-0932-fix-edge-orientation-bug/phase-03-regression-guard.md @@ -0,0 +1,71 @@ +--- +phase: 3 +title: "Regression guard" +status: pending +priority: P2 +effort: "30m" +dependencies: [2] +--- + +# Phase 3: Regression guard + +## Overview + +Lock the fix in place: keep Phase-1 tests in the suite, add CI hook if missing, document the fix in the changelog. + +## Requirements + +**Functional** +- Phase-1 tests run on every `npm test`. +- A short journal entry captures what failed and why. +- `docs/development-roadmap.md` (if it tracks bugs) gets a row. + +**Non-functional** +- No new CI provider or workflow. +- Test runtime stays under 5s total (fail fast). + +## Architecture + +No new code. Only: +- Confirm new test files are picked up by Vitest's default glob (`tests/**/*.test.js`). +- Move any temp helpers used in Phase-1 tests into a shared `tests/helpers/` location if they're reused across files. Keep helpers ≤100 LOC. + +## Related code files + +- **Modify (optional):** `tests/helpers/fake-camera.js` (extract if 2+ test files use the fake projection helper). +- **Modify:** `docs/development-roadmap.md` (changelog entry). +- **Create:** journal entry via `/ck:journal` (handled by skill workflow). + +## Implementation steps + +1. Confirm `npm test` runs all new tests by default — no config change should be needed (Vitest globs `tests/**`). +2. If Phase-1 tests created duplicated helpers, extract to `tests/helpers/`. Otherwise leave inline. +3. Update `docs/development-roadmap.md` or `docs/project-changelog.md` (whichever exists) with a one-line entry under Recent Fixes: `Fixed edge-orientation bug in drag-gesture path (plan 260509-0932)`. +4. Run `/ck:journal` to capture findings. +5. Commit: `fix(controls): correct drag-gesture rotation direction on edges`. + +## Todo list + +- [ ] Verify `npm test` includes new test files +- [ ] Extract shared test helpers if duplicated across 2+ files +- [ ] Update changelog/roadmap with one-line entry +- [ ] Run `/ck:journal` +- [ ] Commit with conventional message + +## Success criteria + +- [ ] `npm test` green and includes Phase-1 tests. +- [ ] Changelog/roadmap entry merged. +- [ ] Journal entry written. +- [ ] Plan archived via `/ck:plan archive` after merge. + +## Risk assessment + +- **Risk:** Test helpers duplicated; future bug fix copies the duplication. + **Mitigation:** Extract eagerly when 2nd test file needs the same helper. +- **Risk:** Changelog format inconsistent with prior entries. + **Mitigation:** Read the existing file first; match the existing style. + +## Next steps + +→ Archive this plan: `mv plans/260509-0932-fix-edge-orientation-bug plans/archive/`. diff --git a/plans/archive/260509-0932-fix-edge-orientation-bug/plan.md b/plans/archive/260509-0932-fix-edge-orientation-bug/plan.md new file mode 100644 index 0000000..3f292f0 --- /dev/null +++ b/plans/archive/260509-0932-fix-edge-orientation-bug/plan.md @@ -0,0 +1,80 @@ +--- +title: Fix edge-orientation bug (drag-gesture) +status: completed +created: 2026-05-09 +completed: 2026-05-09 +priority: P1 +mode: fast +phases: 3 +--- + +# Fix edge-orientation bug (drag-gesture) + +Edges rotate in the OPPOSITE direction from user's drag intent. Root cause identified algebraically: `signMul` derivation in `chooseRotationAxis` leaks face-normal component for edge/corner cubies, which flips the sign at certain camera angles. + +## Context + +- Initial brainstorm: [`plans/reports/brainstorm-260509-0932-edge-orientation-bug.md`](../reports/brainstorm-260509-0932-edge-orientation-bug.md) +- Sharper diagnosis: [`plans/reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md`](../reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md) +- User-confirmed repro: mouse drag on edge → opposite direction +- Approach: skip live diagnosis, apply most-likely fix directly with focused regression test + +## Root cause (from algebraic proof in 0954 brainstorm) + +For cubie at position `P = αF̂ + βD̂ + γR̂` (basis: face-normal, drag-axis, rotation-axis), velocity under +ω rotation is `v = R̂ × P = -αD̂ + βF̂`. + +- Face-center: β=0 → `v = -αD̂` (pure tangential, signMul reliable). +- **Edge/corner: β≠0** → `v` has face-normal component `βF̂` whose screen projection contaminates `motionScreen.dot(drag)` and flips signMul under specific camera angles. + +## Fix (single ~5-line change in `gesture-math.js`) + +Project `hitWorldPos` onto face-normal axis before cross product: +```js +const faceAnchor = new Vector3(); +faceAnchor[hitFaceAxis] = hitWorldPos[hitFaceAxis]; +const motionWorld = new Vector3() + .crossVectors(AXIS_VECS[rotAxis], faceAnchor) + .normalize(); +``` +This eliminates β leakage; works for centers, edges, and corners alike. + +## Phases + +| # | Phase | Status | Effort | +|---|-------|--------|--------| +| 1 | [Focused regression test](phase-01-diagnostic-tests.md) | completed | 45m | +| 2 | [faceAnchor fix](phase-02-targeted-fix.md) | completed | 15m | +| 3 | [Regression guard](phase-03-regression-guard.md) | completed (n/a — no changelog file) | 30m | + +## Outcome + +- Test created: `tests/gesture-math.test.js` — 36 new tests covering face-invariance of `signMul`/`rotAxis` for centers, edges, corners across all 6 faces × 4 cardinal drags. +- Pre-fix: 8/36 new tests failed on edge `±z @ (0,1,-1)` and corner positions, confirming H4/H5. +- Fix applied: `gesture-math.js:chooseRotationAxis` — face-anchored cross product (5 lines added). +- Post-fix: 77/77 tests pass (36 new + 41 existing). No regressions. + +## Files in scope + +**Source (modify in Phase 2 only):** +- `src/lib/controls/gesture-math.js` +- `src/lib/controls/pointer-gesture.js` +- `src/lib/render/animate-move.js` +- `src/lib/core/apply-move.js` +- `src/lib/core/cubie-model.js` +- `src/lib/render/cubie-meshes.js` + +**Tests (create in Phase 1):** +- `tests/gesture-math.test.js` (new) +- `tests/animate-move.test.js` (new) +- Extensions to `tests/apply-move.test.js`, `tests/cubie-model.test.js` + +## Success criteria + +- All Phase-1 tests pass after Phase-2 fix. +- Manual repro on each face × cardinal drag direction × 4 camera azimuths: no wrong-direction rotation. +- No regressions in existing test suite (`npm test` green). +- Lighthouse for code paths unchanged (no perf regression). + +## Stack + +Three.js + Svelte 5 + Vite + Vitest. npm. diff --git a/plans/reports/brainstorm-260509-0932-edge-orientation-bug.md b/plans/reports/brainstorm-260509-0932-edge-orientation-bug.md new file mode 100644 index 0000000..78f030b --- /dev/null +++ b/plans/reports/brainstorm-260509-0932-edge-orientation-bug.md @@ -0,0 +1,107 @@ +# Brainstorm — Edge orientation bug (drag-gesture path) + +**Date:** 2026-05-09 +**Repro path (confirmed by user):** mouse/touch drag-to-rotate face +**Approach (confirmed by user):** add diagnostic tests first to localize root cause before fixing + +--- + +## Problem statement + +Users report edges sometimes render in the wrong direction after dragging a face. Symptom is visual; trigger is the drag-gesture path, not keyboard moves. + +## Code paths involved + +| File | Role | +|------|------| +| `src/lib/controls/pointer-gesture.js` | State machine: PROBING → DRAGGING → snap. Builds `spec` from drag angle. | +| `src/lib/controls/gesture-math.js` | `chooseRotationAxis` (axis lock + `signMul`), `classifyFaceAxis`, `specToName`. | +| `src/lib/render/animate-move.js` | `snapAndAnimate` → `tweenPivot`: tweens pivot, attaches meshes back, calls `applyMove`, then `syncMeshes`. | +| `src/lib/core/apply-move.js` | `applyMove`, `quatFromAxis90`, `multiplyQuat`, `normalizeQuat`. | +| `src/lib/render/cubie-meshes.js` | `syncMeshes` (model→mesh), `readMeshIntoCubie` (mesh→model, currently UNUSED in drag path). | + +## Key observation — two parallel state-update paths converge in `tweenPivot` + +After the pivot tween completes (animate-move.js:37–46): + +1. **Visual path:** `parentGroup.attach(mesh)` bakes the pivot's world rotation into each mesh's local quaternion. +2. **Logical path:** `applyMove(cubies, spec)` recomputes a new model quaternion via `multiplyQuat(quatFromAxis90, prev)`. +3. **Reconciliation:** `syncMeshes(meshes)` overwrites mesh quaternion from the model. + +If step 1 and step 2 disagree by sign or by axis, step 3 produces a visible snap. The most plausible *visible* divergence sources, ranked: + +| # | Source | Likelihood | Why | +|---|--------|-----------|-----| +| 1 | `signMul` mis-determination in `chooseRotationAxis` near ambiguous viewing angles | **High** | Edge pieces sit at corners of two face projections; small numerical edge cases flip the sign. | +| 2 | Wrong `layerIndex` when cubie position floats slightly off integer | Medium | `Math.round` covers most drift, but accumulation could push past 0.5. | +| 3 | Quaternion double-cover (`-q` vs `q`) producing different slerp paths on subsequent animations | Medium | Same final matrix, but mid-animation interpolation could *visually* go the long way. | +| 4 | `normalizeQuat` mutating in place but not enforcing canonical sign | Low (cosmetic) | Math is identical; doesn't visually surface alone. | +| 5 | Floating-point drift across many drag-snap cycles in mesh quaternion | Low | Render reads from model each tick; drift would have to come back via `readMeshIntoCubie` (not currently called). | + +## Approaches considered + +### A. Diagnostic-first → targeted fix (chosen) + +Add focused tests + runtime asserts to pinpoint which divergence occurs in practice, then patch root cause. + +- **Pros:** correctness-first, smallest possible final diff, surfaces invariant violations not just symptoms. +- **Cons:** two-step process; takes longer than guessing a fix. +- **Blast radius:** tiny — tests only, then a focused patch. + +### B. Canonicalize quaternions globally (`w ≥ 0` after every `normalizeQuat`) + +- **Pros:** one-line change. +- **Cons:** doesn't address the leading hypothesis (signMul bug); could mask a real bug. +- **Blast radius:** small; might invalidate a test that depends on raw quaternion shape. + +### C. Refactor core to orientation-index 0..23 (cube rotation group) + +- **Pros:** algebraically bullet-proof; impossible to accumulate FP drift. +- **Cons:** large diff; render layer still needs quaternion for tweening; YAGNI for this bug. +- **Blast radius:** large. + +### D. Replace `applyMove` in `tweenPivot` with `readMeshIntoCubie` (visual is truth) + +- **Pros:** single source of truth — mesh wins, model follows. +- **Cons:** model becomes lossy (non-canonical FP). Re-introduces drift over many gestures. +- **Blast radius:** medium. + +## Recommended plan (matches user's "diagnostic first" choice) + +### Phase 1 — Diagnostic tests (localize) + +1. **Drag-axis sign tests** (`tests/gesture-math.test.js`): for each of 6 face hits + 4 cardinal drag directions × 8 representative camera angles, assert that `chooseRotationAxis` returns the rotation axis + signMul that, when fed to `applyMove`, produces the rotation a human would expect. Tabulate the matrix. +2. **Snap convergence test** (`tests/animate-move.test.js`, headless): simulate `snapAndAnimate` with a fake pivot/meshes, assert post-snap mesh quaternion equals the model quaternion to within 1e-9 (after canonicalization), for every (axis, sign, count) combo. +3. **Repeated-drag idempotency** (`tests/apply-move.test.js`): for each face, assert `M⁴`, `R⁴`, `U⁴`, etc., return cubies to identity quaternion AND `[0,0,0,1]` canonical form. Catches double-cover. +4. **Edge-piece specific**: for each of the 12 edge cubies, after applying every WCA face/slice move once, assert `(home, position, quaternion)` matches a hand-computed table. + +### Phase 2 — Fix root cause (informed by Phase 1) + +Likely candidates depending on which test fails: +- If (1) fails: fix sign computation in `chooseRotationAxis` for ambiguous viewing angles. +- If (2) fails: align `tweenPivot` final state with `applyMove` semantics. +- If (3) fails: enforce `w ≥ 0` canonical form in `normalizeQuat`. +- If (4) fails: fix specific `quatFromAxis90` / `rotatePosition90` entry. + +### Phase 3 — Regression guard + +- Keep all Phase-1 tests in CI. +- Add one E2E test that scripts a sequence of drag gestures via synthetic pointer events and verifies cube state matches a recorded golden. + +## Success criteria + +- All Phase-1 tests pass. +- Manual repro (drag any edge piece on each face from each camera angle) no longer shows wrong direction. +- No regressions in existing test suites (`apply-move.test.js`, `cube-to-facelets.test.js`, `solver.test.js`, `cubie-model.test.js`). + +## Risks + +- **Heisenbug risk:** the bug may only surface at specific camera azimuths. Phase-1 must enumerate camera angles, not test only the default view. +- **Test scaffolding for `chooseRotationAxis`** needs a fake camera with controlled projection — small but non-trivial helper. +- **No regression of keyboard moves**: Phase-2 fix must not break the keyboard path that already works. + +## Unresolved questions + +- Does the user have a recorded reproduction (specific camera angle + face + drag direction) that consistently triggers the bug? Would shortcut Phase-1 enumeration. +- Is there a target browser/device where the bug appears more often (touch vs mouse)? +- Is `readMeshIntoCubie` actually unused, or used by some path I missed (e.g., camera reset, scene re-init)? diff --git a/plans/reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md b/plans/reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md new file mode 100644 index 0000000..784fa6f --- /dev/null +++ b/plans/reports/brainstorm-260509-0954-edge-rotation-reverse-direction.md @@ -0,0 +1,110 @@ +# Brainstorm — Edge rotation reverses direction (sharpened) + +**Date:** 2026-05-09 09:54 +**Repro (user):** mouse-drag on edge piece rotates cube in OPPOSITE direction from intent +**Approach (user):** skip live diagnosis, propose most-likely fix; update existing plan to reflect sharper diagnosis + +Builds on: [`brainstorm-260509-0932-edge-orientation-bug.md`](./brainstorm-260509-0932-edge-orientation-bug.md) + +--- + +## Problem statement (refined) + +Pure sign-flip on edge cubies. No quaternion drift, no double-cover. The rotation goes the wrong way — model and visual agree on direction, but both are inverted from user expectation. + +## Hypothesis verdict + +| H | Verdict | Reason | +|---|---------|--------| +| H1: classifyFaceAxis loses sign info | ✗ Eliminated | hitFaceAxis only used in `filter()`, sign irrelevant. | +| H2: stale hitWorldPos after prior moves | ✗ Eliminated | `getWorldPosition()` always current. | +| H3: normalized vs raw screen vector mismatch | ✗ Provably wrong | `Math.sign(a·b)` invariant to positive scaling. | +| H4/H5: edge cubie velocity has face-normal leakage that flips signMul | ✓ **Most likely** | See algebraic proof below. | + +## Algebraic proof of H4/H5 + +For cubie at position `P`, instantaneous velocity under +ω rotation around `R̂` is + +``` +v = R̂ × P +``` + +Decompose `P` along basis `(F̂, D̂, R̂)` where `F̂` = face normal, `D̂` = in-face drag axis, `R̂` = rotation axis (right-handed: `F̂ × D̂ = R̂`): + +``` +P = αF̂ + βD̂ + γR̂ +``` + +Then: + +``` +v = R̂ × P = α(R̂ × F̂) + β(R̂ × D̂) + γ(R̂ × R̂) + = α(-D̂) + β(F̂) + 0 + = -αD̂ + βF̂ +``` + +- `-αD̂` is the in-face tangential component (proportional to face-normal coordinate `α`). +- `βF̂` is the face-normal component (proportional to in-face drag-axis coordinate `β`). + +For **face-center cubies**, `β = 0` (the cubie sits on the face normal axis). So `v = -αD̂` — pure tangential. Screen projection of `v` projects cleanly onto `screenDirs[D̂]` direction, signMul is reliable. + +For **edge cubies**, `β ≠ 0` (e.g., (1, 0, 1) has α=1 along x̂, β=1 along ẑ). So `v = -αD̂ + βF̂` — has a face-normal component. The screen projection of `βF̂` is NOT zero in general (face normal usually has a screen component except when looking square-on to face). At certain camera angles, `screen(βF̂)` can dominate `screen(-αD̂)`, flipping the sign of `motionScreen.dot(drag)`. + +**This is the bug.** The signMul derivation works for face-center cubies (where β=0) but fails for edge/corner cubies (where β≠0). + +## Proposed fix + +In `gesture-math.js:chooseRotationAxis`, replace `hitWorldPos` with its projection onto the face-normal axis before the cross product: + +```js +// BEFORE: motionWorld depends on full cubie position (β leakage on edges) +const motionWorld = new Vector3() + .crossVectors(AXIS_VECS[rotAxis], hitWorldPos) + .normalize(); + +// AFTER: motionWorld uses only face-normal coordinate (β = 0, no leakage) +const faceAnchor = new Vector3(); +faceAnchor[hitFaceAxis] = hitWorldPos[hitFaceAxis]; // ±1 (or ±2 for big cube) +const motionWorld = new Vector3() + .crossVectors(AXIS_VECS[rotAxis], faceAnchor) + .normalize(); +``` + +**Why this works:** `faceAnchor` is `αF̂` (only the face-normal component of `P`). Cross product with `R̂` gives `R̂ × αF̂ = -αD̂` — pure tangential. No face-normal leakage. signMul becomes reliable for *all* cubies on a face: center, edge, and corner. + +**Diff size:** ~5 lines in one file. No other code changes needed. + +## Alternative fixes considered + +| Approach | Verdict | +|----------|---------| +| Tiebreak when `||projs[0]| - |projs[1]|| < ε` | Treats symptom only — doesn't fix cubies where the wrong axis is *strongly* picked. | +| Use `screenDirs[dragAxisIdx]` as motionWorld proxy | Loses the rotation-direction info; can't determine signMul. | +| Refactor signMul derivation to use scalar triple product | Equivalent algebraically; same diff. | +| Patch motionScreen to subtract face-normal component | Possible, but more code; the faceAnchor fix is simpler. | + +The faceAnchor fix is **algebraically minimal** — one substitution, eliminates the bug class. + +## Plan update (replaces phase-01-diagnostic-tests.md) + +- **Drop** the 96-case signMul matrix. +- **Add** a focused targeted test: 12 edge cubies × 6 face stickers each (where applicable) × 4 cardinal drag directions × 1 default camera. ~50 cases. The test asserts `signMul` from `chooseRotationAxis` produces a `spec` whose `applyMove` rotates the cubie in the screen direction matching the drag, AND that the returned signMul is identical to what a face-center cubie on the same face/drag would produce (the H4/H5 invariant: signMul depends on face, not on which cubie). +- **Phase 2** applies the faceAnchor fix. + +## Success criteria + +- All edge cubies produce correct `signMul` for all face/drag combos (matches face-center invariant). +- Manual repro: drag any edge piece, rotation matches drag direction. +- No regression on existing tests. + +## Risks + +- **Risk:** faceAnchor fix breaks corner cubie behavior. + **Check:** Corner cubies have α≠0, β≠0, γ≠0. Same proof applies — `v = -αD̂ + βF̂` regardless of γ. Fix removes β leakage; γ is along `R̂` and contributes 0 to `v`. So corners get same fix benefit. ✓ +- **Risk:** faceAnchor fix changes face-center cubie behavior. + **Check:** Face-center has β=0 already. Replacing `hitWorldPos = αF̂ + 0·D̂ + 0·R̂` with `faceAnchor = αF̂` gives identical result. No change. ✓ + +## Unresolved questions + +- Are there hand-tested camera angles where the bug *doesn't* reproduce? Would let us validate the fix's coverage. +- Does the bug affect corner cubies too? The proof says yes (same β≠0 pattern), but user's report mentions edges specifically.