10 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 be5253caa0 feat(solve): step-by-step solver with visible plan
The Solve button now walks the user through the solution one move at a
time. First click computes the cubejs solution and animates move 1; each
subsequent click advances one move. Undo rewinds and steps the solve
cursor back, so the same move can be replayed for review.

The control panel surfaces the upcoming algorithm with the next move
highlighted, the completed prefix struck through, and the button label
shows "Next: <move> (k/N)" while a plan is active.

Any state-changing action outside the plan — drag commit, scramble,
reset, manual keyboard move — drops the queued solution so the cursor
never points at an algorithm that no longer matches the cube state.
2026-04-27 10:53:19 +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