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
350
src/analysis.rs
350
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<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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue