rubiks-cube-3x3-solver / src/gui.rs

raw file 5534 bytes
//! Minimal web GUI for entering a cube state and solving it.

use anyhow::Result;
use axum::{
    extract::Json,
    response::Html,
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;

const HTML: &str = r#"<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Rubik's Cube Solver</title>
<style>
body { font-family: sans-serif; margin: 20px auto; max-width: 760px; }
.scene { width: 360px; height: 360px; perspective: 900px; margin: 20px auto; overflow: visible; }
.cube { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; transform: rotateX(-20deg) rotateY(30deg); cursor: grab; }
.face { position: absolute; width: 240px; height: 240px; display: grid; grid-template-columns: repeat(3, 80px); grid-template-rows: repeat(3, 80px); border: 1px solid #222; box-sizing: border-box; }
.face.front  { transform: translateZ(120px); }
.face.back   { transform: rotateY(180deg) translateZ(120px); }
.face.right  { transform: rotateY(90deg) translateZ(120px); }
.face.left   { transform: rotateY(-90deg) translateZ(120px); }
.face.top    { transform: rotateX(90deg) translateZ(120px); }
.face.bottom { transform: rotateX(-90deg) translateZ(120px); }
.sticker { border: 1px solid #111; cursor: pointer; box-sizing: border-box; }
.U { background: #fff; }
.R { background: #f00; }
.F { background: #0f0; }
.D { background: #ff0; }
.L { background: #fa0; }
.B { background: #00f; }
button { margin: 6px 4px; padding: 6px 12px; }
#out { font-family: monospace; margin-top: 10px; white-space: pre-wrap; }
#str { font-family: monospace; word-break: break-all; }
.hint { max-width: 640px; }
</style>
</head>
<body>
<h1>Rubik's Cube Solver</h1>
<p class="hint"><b>How to enter:</b> Drag to rotate the cube. Click any sticker to cycle its color (U R F D L B). Start from a solved cube and set colors to match your cube, holding U on top and F in front as you rotate. Click Solve.</p>
<div class="scene"><div class="cube" id="cube"></div></div>
<button onclick="solve()">Solve</button>
<button onclick="reset()">Reset</button>
<p>State: <code id="str"></code></p>
<p>Paste state: <input id="manual" size="60"><button onclick="applyStr()">Apply</button></p>
<div id="out"></div>
<script>
const colors = ['U','R','F','D','L','B'];
let state = Array(54).fill('U');
const faceDefs = [
  {cls:'front', start:18},
  {cls:'back', start:45},
  {cls:'right', start:9},
  {cls:'left', start:36},
  {cls:'top', start:0},
  {cls:'bottom', start:27},
];
const cube = document.getElementById('cube');
faceDefs.forEach(fd => {
  const face = document.createElement('div');
  face.className = 'face '+fd.cls;
  for (let i=0;i<9;i++){
    const idx = fd.start+i;
    const s = document.createElement('div');
    s.className = 'sticker '+state[idx];
    s.dataset.idx = idx;
    s.onclick = (e) => { e.stopPropagation();
      const cur = colors.indexOf(state[idx]);
      state[idx] = colors[(cur+1)%colors.length];
      s.className = 'sticker '+state[idx];
      updateStr();
    };
    face.appendChild(s);
  }
  cube.appendChild(face);
});
function updateStr(){ document.getElementById('str').textContent = state.join(''); }
updateStr();
// rotation
let rotX=-20, rotY=30, dragging=false, lastX=0,lastY=0;
cube.addEventListener('mousedown', e=>{ dragging=true; lastX=e.clientX; lastY=e.clientY; cube.style.cursor='grabbing'; });
window.addEventListener('mousemove', e=>{
  if(!dragging) return;
  const dx=e.clientX-lastX, dy=e.clientY-lastY;
  rotY += dx*0.5; rotX -= dy*0.5;
  cube.style.transform = `rotateX(${rotX}deg) rotateY(${rotY}deg)`;
  lastX=e.clientX; lastY=e.clientY;
});
window.addEventListener('mouseup', ()=>{ dragging=false; cube.style.cursor='grab'; });
function reset(){ state.fill('U'); document.querySelectorAll('.sticker').forEach((s,i)=>{ /* need map */}); location.reload(); }
function applyStr(){
  const v = document.getElementById('manual').value.trim();
  if (v.length !== 54) { alert('need 54 chars'); return; }
  for (let i=0;i<54;i++){ state[i]=v[i]; }
  document.querySelectorAll('.sticker').forEach(s=>{ const idx=+s.dataset.idx; s.className='sticker '+state[idx]; });
  updateStr();
}
async function solve(){
  const str = state.join('');
  const res = await fetch('/solve', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({state: str})});
  const j = await res.json();
  document.getElementById('out').textContent = j.solution || j.error || 'no solution';
}
</script>
</body>
</html>"#;

#[derive(Deserialize)]
struct SolveReq {
    state: String,
}

#[derive(Serialize)]
struct SolveRes {
    solution: String,
}

#[derive(Serialize)]
struct ErrRes {
    error: String,
}

pub async fn run(port: u16) -> Result<()> {
    let app = Router::new()
        .route("/", get(|| async { Html(HTML) }))
        .route("/solve", post(solve_handler));

    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    println!("GUI running at http://{}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn solve_handler(Json(req): Json<SolveReq>) -> Result<Json<SolveRes>, Json<ErrRes>> {
    let state = req.state;
    let sol = tokio::task::spawn_blocking(move || {
        crate::solver::solve_facelet(&state, 20, 5.0)
    })
    .await
    .map_err(|e| Json(ErrRes { error: e.to_string() }))?
    .map_err(|e| Json(ErrRes { error: e.to_string() }))?;
    Ok(Json(SolveRes {
        solution: crate::notation::format_moves(&sol),
    }))
}