diff --git a/src/analysis.rs b/src/analysis.rs index dd0de4c..c03c1f6 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use crate::recording::UseCase; use crate::telemetry_frame::{gear_display, TelemetryFrame}; +use crate::track_model::{Corner, Sector, TrackModel, TrackModelSource}; // --------------------------------------------------------------------------- // Shift Point Analysis @@ -276,7 +277,44 @@ fn lerp_search( // Track Map Analysis // --------------------------------------------------------------------------- -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq)] +pub enum PositionMode { + Spline, + Coordinate, +} + +fn detect_position_mode(frames: &[TelemetryFrame]) -> PositionMode { + if frames.is_empty() { + return PositionMode::Coordinate; + } + let with_spline = frames.iter().filter(|f| f.spline_position > 0.0).count(); + let spline_ratio = with_spline as f32 / frames.len() as f32; + if spline_ratio > 0.5 { + PositionMode::Spline + } else { + PositionMode::Coordinate + } +} + +fn compute_cumulative_distance(points: &mut [TrackPoint]) { + if points.is_empty() { return; } + let mut cumulative = 0.0f32; + points[0].spline = 0.0; + for i in 1..points.len() { + let dx = points[i].x - points[i-1].x; + let dz = points[i].z - points[i-1].z; + cumulative += (dx * dx + dz * dz).sqrt(); + points[i].spline = cumulative; + } + if cumulative > 0.0 { + for p in points.iter_mut() { + p.spline /= cumulative; + } + } +} + + +#[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct TrackPoint { pub x: f32, pub y: f32, @@ -284,7 +322,7 @@ pub struct TrackPoint { pub spline: f32, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct TrackBoundary { pub label: String, pub point_count: usize, @@ -296,26 +334,50 @@ pub struct TrackMapAnalysis { pub boundaries: Vec, pub center_line: Option, pub total_points: usize, + pub position_mode: PositionMode, } pub fn analyze_track_map( runs: &[Vec], labels: &[String], ) -> TrackMapAnalysis { + let all_frames: Vec = runs.iter().flat_map(|r| r.iter()).cloned().collect(); + let mode = detect_position_mode(&all_frames); + let mut boundaries: Vec = Vec::new(); for (run, label) in runs.iter().zip(labels.iter()) { - let mut points: Vec = run - .iter() - .filter(|f| f.spline_position != 0.0) - .map(|f| TrackPoint { - x: f.world_x, - y: f.world_y, - z: f.world_z, - spline: f.spline_position, - }) - .collect(); + let mut points: Vec = match &mode { + PositionMode::Spline => { + run.iter() + .filter(|f| f.spline_position > 0.0) + .map(|f| TrackPoint { + x: f.world_x, + y: f.world_y, + z: f.world_z, + spline: f.spline_position, + }) + .collect() + } + PositionMode::Coordinate => { + let mut pts: Vec = run + .iter() + .filter(|f| f.world_x != 0.0 || f.world_z != 0.0) + .map(|f| TrackPoint { + x: f.world_x, + y: f.world_y, + z: f.world_z, + spline: 0.0, + }) + .collect(); + compute_cumulative_distance(&mut pts); + pts + } + }; + points.sort_by(|a, b| a.spline.partial_cmp(&b.spline).unwrap_or(std::cmp::Ordering::Equal)); + points.dedup_by(|a, b| (a.spline - b.spline).abs() < 0.0001); + let point_count = points.len(); boundaries.push(TrackBoundary { label: label.clone(), @@ -333,6 +395,7 @@ pub fn analyze_track_map( boundaries, center_line, total_points, + position_mode: mode, } } @@ -397,6 +460,263 @@ fn interpolate_boundary(boundary: &TrackBoundary, target_spline: f32) -> Option< }) } + +// --------------------------------------------------------------------------- +// Track Model Builder +// --------------------------------------------------------------------------- + +const NUM_SAMPLES: usize = 2000; +const CORNER_THRESHOLD: f64 = 0.005; + + + + +pub fn build_track_model( + boundaries: &[TrackBoundary], + game: String, + track_name: String, + source: TrackModelSource, +) -> Result { + // Step 1: Validate input + if boundaries.len() != 2 { + return Err(format!( + "Expected exactly 2 boundaries, got {}", + boundaries.len() + )); + } + + let left_idx = boundaries + .iter() + .position(|b| b.label == "left") + .ok_or_else(|| "No boundary labeled 'left' found".to_string())?; + let right_idx = boundaries + .iter() + .position(|b| b.label == "right") + .ok_or_else(|| "No boundary labeled 'right' found".to_string())?; + + let left = &boundaries[left_idx]; + let right = &boundaries[right_idx]; + + if left.points.len() < 10 { + return Err(format!( + "Left boundary needs at least 10 points, has {}", + left.points.len() + )); + } + if right.points.len() < 10 { + return Err(format!( + "Right boundary needs at least 10 points, has {}", + right.points.len() + )); + } + + // Step 2: Resample both boundaries + let left_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES) + .map(|i| { + let s = i as f32 / (NUM_SAMPLES - 1) as f32; + let p = interpolate_boundary(left, s).expect("interpolation within range"); + (p.x as f64, p.z as f64) + }) + .collect(); + + let right_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES) + .map(|i| { + let s = i as f32 / (NUM_SAMPLES - 1) as f32; + let p = interpolate_boundary(right, s).expect("interpolation within range"); + (p.x as f64, p.z as f64) + }) + .collect(); + + // Step 3: Compute center line + let center_line: Vec<(f64, f64)> = left_resampled + .iter() + .zip(right_resampled.iter()) + .map(|((lx, lz), (rx, rz))| ((lx + rx) / 2.0, (lz + rz) / 2.0)) + .collect(); + + // Step 4: Compute track width + let track_width: Vec = left_resampled + .iter() + .zip(right_resampled.iter()) + .map(|((lx, lz), (rx, rz))| { + let dx = rx - lx; + let dz = rz - lz; + (dx * dx + dz * dz).sqrt() + }) + .collect(); + + // Step 5: Compute heading + let mut heading: Vec = Vec::with_capacity(NUM_SAMPLES); + for i in 0..center_line.len() - 1 { + let dx = center_line[i + 1].0 - center_line[i].0; + let dz = center_line[i + 1].1 - center_line[i].1; + heading.push(dz.atan2(dx)); + } + heading.push(*heading.last().unwrap_or(&0.0)); + + // Step 6: Compute curvature + let mut curvature: Vec = Vec::with_capacity(NUM_SAMPLES); + for i in 0..center_line.len() - 1 { + let dx = center_line[i + 1].0 - center_line[i].0; + let dz = center_line[i + 1].1 - center_line[i].1; + let ds = (dx * dx + dz * dz).sqrt().max(1e-10); + let mut dtheta = heading[i + 1] - heading[i]; + // Normalize to [-π, π] + while dtheta > std::f64::consts::PI { + dtheta -= 2.0 * std::f64::consts::PI; + } + while dtheta < -std::f64::consts::PI { + dtheta += 2.0 * std::f64::consts::PI; + } + curvature.push(dtheta / ds); + } + curvature.push(0.0); + + // Step 7: Smooth curvature + let window = (curvature.len() / 50).min(21).max(3); + let smoothed_curvature = smooth_f64(&curvature, window); + + // Step 8: Auto-detect corners + let corners = detect_corners(&smoothed_curvature); + + // Step 9: Define sectors + let sectors = vec![ + Sector { + index: 0, + start_s: 0.0, + end_s: 1.0 / 3.0, + }, + Sector { + index: 1, + start_s: 1.0 / 3.0, + end_s: 2.0 / 3.0, + }, + Sector { + index: 2, + start_s: 2.0 / 3.0, + end_s: 1.0, + }, + ]; + + // Step 10: Normalize coordinates using bounds from all arrays + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut min_z = f64::INFINITY; + let mut max_z = f64::NEG_INFINITY; + + for (x, z) in center_line.iter().chain(left_resampled.iter()).chain(right_resampled.iter()) { + min_x = min_x.min(*x); + max_x = max_x.max(*x); + min_z = min_z.min(*z); + max_z = max_z.max(*z); + } + + let scale = (max_x - min_x).max(max_z - min_z).max(1e-6); + + let normalize = |px: f64, pz: f64| -> (f64, f64) { ((px - min_x) / scale, (pz - min_z) / scale) }; + + let normalized_center: Vec<(f64, f64)> = center_line + .into_iter() + .map(|(x, z)| normalize(x, z)) + .collect(); + let normalized_left: Vec<(f64, f64)> = left_resampled + .into_iter() + .map(|(x, z)| normalize(x, z)) + .collect(); + let normalized_right: Vec<(f64, f64)> = right_resampled + .into_iter() + .map(|(x, z)| normalize(x, z)) + .collect(); + + // Step 11: Build and return TrackModel + Ok(TrackModel { + game, + track_name, + center_line: normalized_center, + left_boundary: normalized_left, + right_boundary: normalized_right, + track_width, + heading, + curvature: smoothed_curvature, + corners, + sectors, + source, + created_at: chrono::Utc::now().to_rfc3339(), + }) +} + +fn smooth_f64(values: &[f64], window: usize) -> Vec { + let half = window / 2; + let n = values.len(); + let mut result = Vec::with_capacity(n); + for i in 0..n { + let start = if i >= half { i - half } else { 0 }; + let end = (i + half + 1).min(n); + let sum: f64 = values[start..end].iter().sum(); + result.push(sum / (end - start) as f64); + } + result +} + +fn detect_corners(curvature: &[f64]) -> Vec { + let mut corners = Vec::new(); + let mut in_corner = false; + let mut corner_start = 0usize; + let mut corner_start_s = 0.0f64; + let mut peak_idx = 0usize; + let mut peak_kappa = 0.0f64; + + let len = curvature.len(); + let denom = (len - 1).max(1) as f64; + + for (i, &k) in curvature.iter().enumerate() { + let abs_k = k.abs(); + if abs_k > CORNER_THRESHOLD && !in_corner { + in_corner = true; + corner_start = i.saturating_sub(5); + corner_start_s = corner_start as f64 / denom; + peak_idx = i; + peak_kappa = abs_k; + } else if abs_k > peak_kappa && in_corner { + peak_idx = i; + peak_kappa = abs_k; + } else if abs_k <= CORNER_THRESHOLD * 0.5 && in_corner { + in_corner = false; + let corner_end = (i + 5).min(len - 1); + let apex_s = peak_idx as f64 / denom; + let exit_s = corner_end as f64 / denom; + + if exit_s - corner_start_s > 0.005 { + corners.push(Corner { + index: peak_idx, + number: 0, + entry_s: corner_start_s, + apex_s, + exit_s, + curvature_peak: peak_kappa, + }); + } + } + } + + if in_corner { + let apex_s = peak_idx as f64 / denom; + corners.push(Corner { + index: peak_idx, + number: 0, + entry_s: corner_start_s, + apex_s, + exit_s: 1.0, + curvature_peak: peak_kappa, + }); + } + + for (i, corner) in corners.iter_mut().enumerate() { + corner.number = (i + 1) as u32; + } + + corners +} // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- @@ -415,8 +735,10 @@ pub fn analyze( .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})) } } - UseCase::TrackMap => serde_json::to_value(analyze_track_map(frames_by_run, run_labels)) - .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})), + UseCase::TrackMap | UseCase::TrackMapping => { + serde_json::to_value(analyze_track_map(frames_by_run, run_labels)) + .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})) + } _ => serde_json::json!({ "status": "not_implemented", "use_case": format!("{:?}", use_case), diff --git a/src/api.rs b/src/api.rs index 98ca245..808aeee 100644 --- a/src/api.rs +++ b/src/api.rs @@ -24,6 +24,7 @@ pub struct AppState { pub recording_manager: Arc, pub feed_displays: Vec<(FeedDisplayInfo, Arc>)>, pub session_manager: Arc>, + pub track_model_store: Arc, } #[derive(Serialize)] @@ -89,6 +90,14 @@ struct StartRunRequest { merge_feeds: Vec, } + +#[derive(Deserialize)] +struct BuildTrackRequest { + session_id: String, + game: Option, + track_name: Option, +} + async fn create_session( State(state): State>, Json(req): Json, @@ -520,6 +529,139 @@ struct UseCaseInfo { guidance: String, } +async fn list_tracks(State(state): State>) -> 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>, + 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>, + 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>, + 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>, + Json(req): Json, +) -> 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::>(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 = UseCase::all() .into_iter() @@ -566,6 +708,10 @@ pub fn build_router(state: Arc) -> 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()) diff --git a/src/main.rs b/src/main.rs index a9fad8b..08ae89f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod parser_ksudp; mod parser_pcars; mod parser_telemetry_tool; mod recording; +mod track_model; mod telemetry_frame; mod ui; mod warnings; @@ -386,6 +387,7 @@ async fn main() { } let recording_manager = Arc::new(recording::RecordingManager::new(&data_dir)); + let track_model_store = Arc::new(crate::track_model::TrackModelStore::new(&data_dir)); let session_manager = Arc::new(std::sync::Mutex::new( SessionManager::new(recording_manager.clone()) )); @@ -492,6 +494,7 @@ async fn main() { recording_manager: recording_manager.clone(), feed_displays: feed_displays.clone(), session_manager: session_manager.clone(), + track_model_store, }); let api_addr = api_bind_addr(); warnings.info(&format!("REST API server will listen on {}", api_addr)); diff --git a/src/parser_pcars.rs b/src/parser_pcars.rs index f0bacc8..680a911 100644 --- a/src/parser_pcars.rs +++ b/src/parser_pcars.rs @@ -166,6 +166,21 @@ fn parse_v1_telemetry(data: &[u8]) -> Result { // Track length let track_length = read_f32(data, 1360); + // V1 position data from viewed participant + let viewed_idx = read_i8_at(data, 4) as usize; + let participant_offset = 464 + viewed_idx * 16; + let v1_world_x = read_i16(data, participant_offset) as f32; + let v1_world_y = read_i16(data, participant_offset + 2) as f32; + let v1_world_z = read_i16(data, participant_offset + 4) as f32; + let current_lap_distance = read_u16(data, participant_offset + 6); + let spline_pos = if track_length > 0.0 { + (current_lap_distance as f32 / track_length).clamp(0.0, 1.0) + } else { + 0.0 + }; + let laps_completed = (read_u8_at(data, participant_offset + 9) & 0x7F) as i32; + let _current_lap = read_u8_at(data, participant_offset + 10) as i32; + let frame = TelemetryFrame { source: FeedSource::Pcars, raw_size: data.len(), @@ -185,12 +200,12 @@ fn parse_v1_telemetry(data: &[u8]) -> Result { lap_time_ms: (current_lap_s * 1000.0) as i32, last_lap_ms: (last_lap_s * 1000.0) as i32, best_lap_ms: (best_lap_s * 1000.0) as i32, - lap_count: 0, // Not directly in V1 telemetry packet + lap_count: laps_completed, - world_x: 0.0, - world_y: 0.0, - world_z: 0.0, - spline_position: 0.0, + world_x: v1_world_x, + world_y: v1_world_y, + world_z: v1_world_z, + spline_position: spline_pos, acc_g_vertical: acc_vert, acc_g_horizontal: acc_horiz, @@ -237,7 +252,8 @@ fn parse_v1_telemetry(data: &[u8]) -> Result { track_temperature: Some(track_temp), rain_density: Some(rain_density), track_length: Some(track_length), - full_position: None, // V1 does not have full 3D position + full_position: Some([v1_world_x, v1_world_y, v1_world_z]), + current_lap_distance: Some(current_lap_distance), tyre_wear: Some(read_u8_4(data, 316)), brake_temp_celsius: Some(read_i16_4(data, 328)), suspension_damage: Some(read_u8_4(data, 324)), @@ -359,6 +375,7 @@ fn parse_v2_car_physics(data: &[u8]) -> Result tyre_wear: Some(read_u8_4(data, 196)), brake_temp_celsius: Some(read_i16_4(data, 208)), suspension_damage: Some(read_u8_4(data, 204)), + current_lap_distance: None, ..Default::default() }; @@ -394,6 +411,15 @@ mod tests { // Current time at 20 = 42.5 seconds packet[20..24].copy_from_slice(&42.5f32.to_le_bytes()); + // World position for participant 0 at offset 464: x=100, y=5, z=-200 + packet[464..466].copy_from_slice(&100i16.to_le_bytes()); + packet[466..468].copy_from_slice(&5i16.to_le_bytes()); + packet[468..470].copy_from_slice(&(-200i16).to_le_bytes()); + // Current lap distance at offset 464+6 = 470: 2500 meters + packet[470..472].copy_from_slice(&2500u16.to_le_bytes()); + // Track length at offset 1360: 5000.0 meters + packet[1360..1364].copy_from_slice(&5000.0f32.to_le_bytes()); + let result = parse_pcars_packet(&packet).unwrap(); match result { PcarsPacket::V1Telemetry(frame) => { @@ -407,7 +433,13 @@ mod tests { assert_eq!(frame.best_lap_ms, 78123); assert_eq!(frame.last_lap_ms, 80456); assert_eq!(frame.lap_time_ms, 42500); - assert_eq!(frame.oil_temp_celsius, Some(0)); // default + assert!((frame.world_x - 100.0).abs() < 0.1); + assert!((frame.world_y - 5.0).abs() < 0.1); + assert!((frame.world_z - (-200.0)).abs() < 0.1); + assert!((frame.spline_position - 0.5).abs() < 0.01); + assert!(frame.full_position.is_some()); + assert_eq!(frame.current_lap_distance, Some(2500)); + assert_eq!(frame.oil_temp_celsius, Some(0)); assert!(frame.aero_damage.is_some()); } _ => panic!("Expected V1Telemetry"), @@ -492,4 +524,35 @@ mod tests { let result = parse_pcars_packet(&packet); assert!(matches!(result, Ok(PcarsPacket::V2Other))); } + + #[test] + fn test_v1_position_nonzero_viewed_participant() { + let mut packet = vec![0u8; V1_TELEMETRY_SIZE]; + packet[0..2].copy_from_slice(&1104u16.to_le_bytes()); + packet[2] = 0x00; + + packet[4] = 3i8.to_le_bytes()[0]; + + packet[120..124].copy_from_slice(&30.0f32.to_le_bytes()); + + packet[1360..1364].copy_from_slice(&6000.0f32.to_le_bytes()); + + let p_offset = 464 + 3 * 16; + packet[p_offset..p_offset+2].copy_from_slice(&300i16.to_le_bytes()); + packet[p_offset+2..p_offset+4].copy_from_slice(&10i16.to_le_bytes()); + packet[p_offset+4..p_offset+6].copy_from_slice(&(-400i16).to_le_bytes()); + packet[p_offset+6..p_offset+8].copy_from_slice(&3000u16.to_le_bytes()); + + let result = parse_pcars_packet(&packet).unwrap(); + match result { + PcarsPacket::V1Telemetry(frame) => { + assert!((frame.world_x - 300.0).abs() < 0.1); + assert!((frame.world_y - 10.0).abs() < 0.1); + assert!((frame.world_z - (-400.0)).abs() < 0.1); + assert!((frame.spline_position - 0.5).abs() < 0.01); + assert_eq!(frame.current_lap_distance, Some(3000)); + } + _ => panic!("Expected V1Telemetry"), + } + } } diff --git a/src/recording.rs b/src/recording.rs index d0fa718..0376cdd 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -133,6 +133,7 @@ pub enum UseCase { LapTimes, SectorAnalysis, TrackMap, + TrackMapping, General, } @@ -145,6 +146,7 @@ impl UseCase { UseCase::LapTimes, UseCase::SectorAnalysis, UseCase::TrackMap, + UseCase::TrackMapping, UseCase::General, ] } @@ -157,6 +159,7 @@ impl UseCase { UseCase::SectorAnalysis => "Record one or more complete laps.", UseCase::RacingLine => "Record multiple laps with different racing line approaches.", UseCase::General => "Record freely - no specific procedure required.", + UseCase::TrackMapping => "Step-by-step boundary recording for track model creation.\n\nStep 1: Start Run 1 — Drive a slow, clean lap hugging the LEFT edge of the track. Label this run 'left'.\nStep 2: Start Run 2 — Drive a slow, clean lap hugging the RIGHT edge of the track. Label this run 'right'.\n\nAfter both runs, complete the session. The system will automatically build a TrackModel from your boundary data.", } } } @@ -181,6 +184,16 @@ impl Game { Game::ProjectCars2, ] } + /// Returns a filesystem-safe string identifier for the game. + pub fn to_track_dir_name(&self) -> &'static str { + match self { + Game::AssettoCorsa => "assetto_corsa", + Game::AssettoCorsaCompetizione => "assetto_corsa_competizione", + Game::ProjectCars1 => "project_cars_1", + Game::ProjectCars2 => "project_cars_2", + Game::Unknown => "unknown", + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/session.rs b/src/session.rs index 7bf6e87..d6742d3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -103,7 +103,9 @@ pub struct SessionManager { } impl SessionManager { - pub fn new(recording_manager: Arc) -> Self { + pub fn new( + recording_manager: Arc, + ) -> Self { Self { sessions: HashMap::new(), recording_manager, diff --git a/src/telemetry_frame.rs b/src/telemetry_frame.rs index 49d9b4e..d3c7d2c 100644 --- a/src/telemetry_frame.rs +++ b/src/telemetry_frame.rs @@ -121,6 +121,7 @@ pub struct TelemetryFrame { pub rain_density: Option, pub track_length: Option, pub full_position: Option<[f32; 3]>, + pub current_lap_distance: Option, pub tyre_wear: Option<[u8; 4]>, pub brake_temp_celsius: Option<[i16; 4]>, pub suspension_damage: Option<[u8; 4]>, @@ -211,6 +212,7 @@ impl Default for TelemetryFrame { rain_density: None, track_length: None, full_position: None, + current_lap_distance: None, tyre_wear: None, brake_temp_celsius: None, suspension_damage: None, diff --git a/src/track_model.rs b/src/track_model.rs new file mode 100644 index 0000000..875d3c6 --- /dev/null +++ b/src/track_model.rs @@ -0,0 +1,213 @@ +//! Persistent track model data structure for track mapping and analysis. +//! +//! Stores the track geometry (center line, boundaries, corners, sectors) +//! keyed by (game, track_name). Files are stored at data/tracks//.json. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// A corner detected from curvature analysis. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Corner { + /// Index into the center_line array where this corner starts. + pub index: usize, + /// Corner number (1-based, sequential around the track). + pub number: u32, + /// Spline distance at corner entry. + pub entry_s: f64, + /// Spline distance at the apex (peak curvature). + pub apex_s: f64, + /// Spline distance at corner exit. + pub exit_s: f64, + /// Peak curvature value at the apex. + pub curvature_peak: f64, +} + +/// A track sector (typically 3 per track). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sector { + /// Sector number (0-based). + pub index: u32, + /// Spline distance at sector start. + pub start_s: f64, + /// Spline distance at sector end. + pub end_s: f64, +} + +/// How the track model was created. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TrackModelSource { + /// Created from deliberate boundary recording (2 slow laps). + BoundaryRecording, + /// Auto-estimated from normal driving data. + AutoEstimated, +} + +/// A persisted track model with full geometry, corners, and sectors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackModel { + /// Game identifier (e.g., "assetto_corsa", "project_cars_2"). + pub game: String, + /// Track name (e.g., "ks_nordschleife", "monza"). + pub track_name: String, + /// Center line points as normalized (x, z) pairs. + /// These are 2D coordinates suitable for Canvas rendering. + pub center_line: Vec<(f64, f64)>, + /// Left boundary points. + pub left_boundary: Vec<(f64, f64)>, + /// Right boundary points. + pub right_boundary: Vec<(f64, f64)>, + /// Track width at each center_line point (meters or normalized units). + pub track_width: Vec, + /// Heading direction at each center_line point (radians, 0=east, pi/2=north). + pub heading: Vec, + /// Curvature κ(s) at each center_line point (1/radius). + pub curvature: Vec, + /// Detected corners. + pub corners: Vec, + /// Track sectors. + pub sectors: Vec, + /// How the model was created. + pub source: TrackModelSource, + /// ISO 8601 timestamp when the model was created. + pub created_at: String, +} + +/// Summary info for listing track models (without full geometry). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackSummary { + pub game: String, + pub track_name: String, + pub source: TrackModelSource, + pub created_at: String, + pub corner_count: usize, +} + +fn is_safe_path_component(s: &str) -> bool { + !s.is_empty() && !s.contains('/') && !s.contains('\\') && !s.contains("..") && s != "." +} + +/// Manages persistent TrackModel storage on disk. +pub struct TrackModelStore { + tracks_dir: PathBuf, +} + +impl TrackModelStore { + /// Create a new store. The tracks directory will be at `data_dir/tracks/`. + pub fn new(data_dir: &Path) -> Self { + Self { + tracks_dir: data_dir.join("tracks"), + } + } + + /// Get the file path for a track model. + fn track_path(&self, game: &str, track_name: &str) -> std::io::Result { + if !is_safe_path_component(game) || !is_safe_path_component(track_name) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid game or track_name: contains path traversal characters", + )); + } + Ok(self.tracks_dir.join(game).join(format!("{}.json", track_name))) + } + + /// List all stored track models. + pub async fn list(&self) -> std::io::Result> { + let mut summaries = Vec::new(); + if !self.tracks_dir.exists() { + return Ok(summaries); + } + + let mut game_entries = tokio::fs::read_dir(&self.tracks_dir).await?; + while let Some(game_entry) = game_entries.next_entry().await? { + let game_path = game_entry.path(); + if !game_path.is_dir() { + continue; + } + let game = game_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + let mut track_entries = tokio::fs::read_dir(&game_path).await?; + while let Some(track_entry) = track_entries.next_entry().await? { + let track_path = track_entry.path(); + if track_path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let track_name = track_path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + match tokio::fs::read_to_string(&track_path).await { + Ok(json) => { + if let Ok(model) = serde_json::from_str::(&json) { + summaries.push(TrackSummary { + game: model.game.clone(), + track_name: model.track_name.clone(), + source: model.source, + created_at: model.created_at, + corner_count: model.corners.len(), + }); + } + } + Err(e) => { + eprintln!( + "[WARN] Failed to read track model {}: {}", + track_path.display(), + e + ); + } + } + } + } + + summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(summaries) + } + + /// Load a specific track model. + pub async fn load(&self, game: &str, track_name: &str) -> std::io::Result { + let path = self.track_path(game, track_name)?; + let json = tokio::fs::read_to_string(&path).await?; + serde_json::from_str(&json).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse track model: {}", e), + ) + }) + } + + /// Save a track model. Creates parent directories as needed. + pub async fn save(&self, model: &TrackModel) -> std::io::Result<()> { + let path = self.track_path(&model.game, &model.track_name)?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let json = serde_json::to_string_pretty(model)?; + tokio::fs::write(&path, json).await + } + + /// Delete a track model. + pub async fn delete(&self, game: &str, track_name: &str) -> std::io::Result<()> { + let path = self.track_path(game, track_name)?; + if !path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Track model not found", + )); + } + tokio::fs::remove_file(&path).await + } + + /// Check if a track model exists. + pub async fn exists(&self, game: &str, track_name: &str) -> bool { + self.track_path(game, track_name) + .map(|p| p.exists()) + .unwrap_or(false) + } +}