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)
.map(|f| TrackPoint {
x: f.world_x,
y: f.world_y,
z: f.world_z,
spline: f.spline_position,
})
.collect();
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),