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

@ -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<T>(
// 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<TrackBoundary>,
pub center_line: Option<TrackBoundary>,
pub total_points: usize,
pub position_mode: PositionMode,
}
pub fn analyze_track_map(
runs: &[Vec<TelemetryFrame>],
labels: &[String],
) -> TrackMapAnalysis {
let all_frames: Vec<TelemetryFrame> = runs.iter().flat_map(|r| r.iter()).cloned().collect();
let mode = detect_position_mode(&all_frames);
let mut boundaries: Vec<TrackBoundary> = Vec::new();
for (run, label) in runs.iter().zip(labels.iter()) {
let mut points: Vec<TrackPoint> = run
.iter()
.filter(|f| f.spline_position != 0.0)
let mut points: Vec<TrackPoint> = 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<TrackPoint> = 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<TrackModel, String> {
// 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<f64> = 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<f64> = 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<f64> = 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<f64> {
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<Corner> {
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),

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())

View file

@ -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));

View file

@ -166,6 +166,21 @@ fn parse_v1_telemetry(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
// 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<TelemetryFrame, PcarsParseError> {
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<TelemetryFrame, PcarsParseError> {
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<TelemetryFrame, PcarsParseError>
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"),
}
}
}

View file

@ -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)]

View file

@ -103,7 +103,9 @@ pub struct SessionManager {
}
impl SessionManager {
pub fn new(recording_manager: Arc<RecordingManager>) -> Self {
pub fn new(
recording_manager: Arc<RecordingManager>,
) -> Self {
Self {
sessions: HashMap::new(),
recording_manager,

View file

@ -121,6 +121,7 @@ pub struct TelemetryFrame {
pub rain_density: Option<u8>,
pub track_length: Option<f32>,
pub full_position: Option<[f32; 3]>,
pub current_lap_distance: Option<u16>,
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,

213
src/track_model.rs Normal file
View file

@ -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/<game>/<track_name>.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<f64>,
/// Heading direction at each center_line point (radians, 0=east, pi/2=north).
pub heading: Vec<f64>,
/// Curvature κ(s) at each center_line point (1/radius).
pub curvature: Vec<f64>,
/// Detected corners.
pub corners: Vec<Corner>,
/// Track sectors.
pub sectors: Vec<Sector>,
/// 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<PathBuf> {
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<Vec<TrackSummary>> {
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::<TrackModel>(&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<TrackModel> {
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)
}
}