9 Commits

Author SHA1 Message Date
tiennm99 edcb95c4a6 fix(controls): compare motionScreen against pixelsAxis (not drag) so signMul matches the dragPx reference frame
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.
2026-05-09 12:42:51 +07:00
tiennm99 4447397f28 debug: add gesture diagnostic logs gated behind globalThis.RUBIK_DEBUG
Temporary instrumentation to diagnose residual edge-rotation reversal
that survives the face-anchor fix. Logs hit context, gesture math, and
the committed move spec so we can trace any remaining sign mismatch.

Disabled by default — set `RUBIK_DEBUG = true` in the browser console to
enable. Will be removed once the residual bug is localized.
2026-05-09 12:16:02 +07:00
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
tiennm99 d19a9246d5 refactor: simplifier pass — collapse duplicate code paths
- animate-move: factored shared promise/Tween body into tweenPivot;
  animateMove builds the pivot from spec, snapAndAnimate accepts a
  caller-built pivot. 96 → 80 LOC, no behavior change.
- pointer-gesture: merged abortGesture / finishGesture / dispose busy
  release into a single idempotent endGesture; ownedBusy already
  encoded the per-gesture semantics. 205 → 190 LOC.
- App: dropped the redundant `solving` state; ControlsPanel derives
  it as `busy && !solveActive`. runSolveStep collapsed to a one-line
  delegate since the busy gate now lives in CubeView.
- CubeView: extracted cubejs lazy-load + parse into computeSolvePlan
  and dropped the dead bracket-undo guard.

41 tests still green; build clean. No deps, features, or behavior
changes. Refs: plans/reports/code-simplifier-260427-1848-rubik-pass.md
2026-04-27 18:54:17 +07:00
tiennm99 338b65fa54 fix: address code-review findings (busy-gate races, dispose leaks, a11y)
Critical:
- Solver lazy-load + table-init window now gates `busy=true` so the
  user can't mutate cubies during the 4–5 s compute window and have a
  stale algorithm played on a new state.
- Pointer gesture tracks busy ownership (`ownedBusy`) so the
  busy-during-PROBING bail path doesn't release a flag set by an
  unrelated keyboard-driven animation in flight.

High:
- animate-move onComplete wraps work in try/finally so the awaiter is
  never left hanging with `busy=true` if a frame throws.
- New disposeCubieMeshes / clearTweens release per-cubie materials,
  the singleton geometry, and any in-flight tweens on CubeView
  unmount, fixing a GPU + scene-graph leak across HMR / route changes.
- ControlsPanel move log no longer keys with Math.random() — Svelte
  was tearing down + rebuilding every span every render.
- solveStep wraps the cubejs await in try/catch so a thrown solver
  error is logged instead of leaking an unhandled rejection.

Medium / minor:
- pointer-gesture filters foreign pointerIds in onPointerMove /
  onPointerUp (multitouch hardening).
- ControlsPanel buttons disable while `busy` so users see why their
  click does nothing during animations.
- gesture-math.chooseRotationAxis falls back to raw screen-delta when
  both in-plane axes project to a near-zero screen vector (camera
  looking straight down the face normal).
- Dropped dead `[scramble]` undo guard, dead `onSolved` export, and
  unused `isAnimating` state.
- Solver init promise resets on rejection so transient failures can
  retry instead of being sticky forever.
- Canvas gets an aria-label describing its drag interaction.

Refs review: plans/reports/code-review-260427-1148-rubik-full-pass.md
2026-04-27 14:16:07 +07:00
tiennm99 0c9a160608 fix(input): gate keyboard moves behind drag state to avoid races
Pointer drag and keyboard input were independent. Pressing R, Space, Z,
or Esc mid-drag started a second animation that fought with the gesture
for the same meshes — crash or scrambled state.

- Pointer gesture flips the shared `busy` flag through a setBusy callback
  on entering DRAGGING and clears it from cleanup() (after the snap
  animation resolves), so triggerMove / scramble / undo / solve already
  bail via their existing `if (busy) return` checks.
- Reset is gated the same way; it now returns a boolean so App.svelte
  knows whether to clear the move log and timer.
- Lock-axis transition re-checks isBusy: if a keyboard animation
  started during PROBING, the gesture aborts cleanly instead of racing.
2026-04-27 10:37:06 +07:00
tiennm99 12151357a5 fix(animation): register tweens to an explicit group (tween.js v25)
In @tweenjs/tween.js v25 new Tween() instances are no longer auto-added
to a default group, so the module-level update() does not advance them.
The drag-to-rotate gesture relied on this: on pointer release the snap
tween never advanced, leaving the dragged face frozen at its drag angle
until the user clicked again.

Wire animate-move.js to a private Group, pass it to every Tween, and
drive it from tickTweens(). Add a regression test that exercises both
the broken (no-group) and fixed (group) code paths so a future tween
upgrade or refactor will fail loud.
2026-04-27 10:30:40 +07:00
tiennm99 eb592e5c98 feat: add kociemba solver and vitest unit tests
- Solver: cubejs-backed two-phase solver, lazy-loaded chunk so the ~80 KB
  table-init cost stays out of the main bundle. New cube-to-facelets
  converter (3D model -> 54-char URFDLB string) verified bit-for-bit
  against cubejs's own move() output.
- Solve button in ControlsPanel with disabled "Solving..." state, wired
  through the CubeView controller; animates each move sequentially.
- Rewrite solved-check to the WCA face-uniformity definition. The old
  strict "identity quaternion per cubie" check rejected center spins
  (invisible) and whole-cube rotations, both of which are still solved
  per WCA / Kociemba.
- Vitest specs under tests/ cover cubie-model, move-definitions,
  move-parser, apply-move (4x turns, inverses, sune order=6, R2 == R R),
  scrambler, solved-check, algorithm-runner, cube-to-facelets, solver.
  39 tests, ~3 s. Adds npm test / npm run test:watch scripts.
2026-04-27 10:25:28 +07:00
tiennm99 996a07a7bf feat: initial 3x3 rubik cube simulator (svelte + three.js) 2026-04-27 09:13:43 +07:00