feat: add track map implementation — persistent TrackModel, dual position mode, boundary recording, track API endpoints

Backend tasks A→B→C→F→E→D from the Track Map Implementation plan:

A. PCARS V1 position data: read world_position (3×i16) and current_lap_distance
   from viewed participant entry, compute derived spline_position, add unit tests.

B. Dual position mode: auto-detect spline vs coordinate mode in track map analysis,
   compute cumulative 2D distance as pseudo-spline for PCARS/future games.

C. Persistent TrackModel: Corner, Sector, TrackModel data structures with serde,
   TrackModelStore with async CRUD at data/tracks/<game>/<track_name>.json.

F. Track model building algorithm: resample boundaries, compute center line,
   track width, heading, curvature, auto-detect corners (κ > 0.005), 3 sectors,
   coordinate normalization to [0,1]².

E. TrackMapping use case: new boundary recording workflow with step-by-step
   guidance, session integration.

D. Track API endpoints: GET/DELETE /api/tracks/{game}/{track_name},
   GET /api/tracks, GET .../corners, POST /api/tracks/build.
This commit is contained in:
Jan-Peter von Hunnius 2026-06-08 14:17:39 +02:00
parent 9fae8ba9d8
commit 937090681f
8 changed files with 786 additions and 22 deletions

View file

@ -24,6 +24,7 @@ pub struct AppState {
pub recording_manager: Arc<RecordingManager>,
pub feed_displays: Vec<(FeedDisplayInfo, Arc<Mutex<FeedState>>)>,
pub session_manager: Arc<std::sync::Mutex<SessionManager>>,
pub track_model_store: Arc<crate::track_model::TrackModelStore>,
}
#[derive(Serialize)]
@ -89,6 +90,14 @@ struct StartRunRequest {
merge_feeds: Vec<String>,
}
#[derive(Deserialize)]
struct BuildTrackRequest {
session_id: String,
game: Option<String>,
track_name: Option<String>,
}
async fn create_session(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateSessionRequest>,
@ -520,6 +529,139 @@ struct UseCaseInfo {
guidance: String,
}
async fn list_tracks(State(state): State<Arc<AppState>>) -> Response {
match state.track_model_store.list().await {
Ok(summaries) => ok_json(summaries),
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to list tracks: {}", e),
),
}
}
async fn get_track(
State(state): State<Arc<AppState>>,
Path((game, track_name)): Path<(String, String)>,
) -> Response {
match state.track_model_store.load(&game, &track_name).await {
Ok(model) => ok_json(model),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
err(StatusCode::NOT_FOUND, "Track model not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to load track model: {}", e),
),
}
}
async fn delete_track(
State(state): State<Arc<AppState>>,
Path((game, track_name)): Path<(String, String)>,
) -> Response {
match state.track_model_store.delete(&game, &track_name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
err(StatusCode::NOT_FOUND, "Track model not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete track model: {}", e),
),
}
}
async fn get_track_corners(
State(state): State<Arc<AppState>>,
Path((game, track_name)): Path<(String, String)>,
) -> Response {
match state.track_model_store.load(&game, &track_name).await {
Ok(model) => ok_json(&model.corners),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
err(StatusCode::NOT_FOUND, "Track model not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to load track model: {}", e),
),
}
}
async fn build_track(
State(state): State<Arc<AppState>>,
Json(req): Json<BuildTrackRequest>,
) -> Response {
let rec_id = {
let mgr = lock_sessions(&state);
let session = match mgr.get_session(&req.session_id) {
Some(s) => s,
None => return err(StatusCode::NOT_FOUND, "Session not found"),
};
match session.recording_id.clone() {
Some(id) => id,
None => return err(StatusCode::BAD_REQUEST, "Session has no associated recording"),
}
};
let manifest = match state.recording_manager.get_recording(&rec_id).await {
Ok(m) => m,
Err(_) => return err(StatusCode::NOT_FOUND, "Recording not found"),
};
let game = req.game.unwrap_or_else(|| manifest.game.to_track_dir_name().to_string());
let track_name = req.track_name.unwrap_or(manifest.track);
let boundaries = {
let mgr = lock_sessions(&state);
let session = match mgr.get_session(&req.session_id) {
Some(s) => s,
None => return err(StatusCode::NOT_FOUND, "Session not found"),
};
if session.use_case != UseCase::TrackMapping {
return err(StatusCode::BAD_REQUEST, "Session is not a track_mapping session");
}
if session.status != crate::session::SessionStatus::Completed {
return err(StatusCode::BAD_REQUEST, "Session must be completed first");
}
let analysis = match &session.analysis {
Some(a) => a.clone(),
None => return err(StatusCode::BAD_REQUEST, "Session has no analysis results"),
};
let boundaries_val = match analysis.get("boundaries") {
Some(v) => v.clone(),
None => return err(StatusCode::BAD_REQUEST, "No boundaries in analysis"),
};
match serde_json::from_value::<Vec<crate::analysis::TrackBoundary>>(boundaries_val) {
Ok(b) => b,
Err(e) => return err(StatusCode::BAD_REQUEST, &format!("Failed to parse boundaries: {}", e)),
}
};
let source = crate::track_model::TrackModelSource::BoundaryRecording;
let track_model = match analysis::build_track_model(&boundaries, game.clone(), track_name.clone(), source) {
Ok(m) => m,
Err(e) => return err(StatusCode::BAD_REQUEST, &format!("Failed to build track model: {}", e)),
};
match state.track_model_store.save(&track_model).await {
Ok(()) => (StatusCode::CREATED, Json(track_model)).into_response(),
Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &format!("Failed to save track model: {}", e)),
}
}
async fn list_use_cases() -> Response {
let use_cases: Vec<UseCaseInfo> = UseCase::all()
.into_iter()
@ -566,6 +708,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
get(get_feed_data),
)
.route("/api/live", get(get_live))
.route("/api/tracks", get(list_tracks))
.route("/api/tracks/{game}/{track_name}", get(get_track).delete(delete_track))
.route("/api/tracks/{game}/{track_name}/corners", get(get_track_corners))
.route("/api/tracks/build", post(build_track))
.route("/api/use-cases", get(list_use_cases))
.route("/api/games", get(list_games))
.layer(CorsLayer::permissive())