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:
parent
9fae8ba9d8
commit
937090681f
8 changed files with 786 additions and 22 deletions
146
src/api.rs
146
src/api.rs
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue