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::recording::UseCase;
|
||||||
use crate::telemetry_frame::{gear_display, TelemetryFrame};
|
use crate::telemetry_frame::{gear_display, TelemetryFrame};
|
||||||
|
use crate::track_model::{Corner, Sector, TrackModel, TrackModelSource};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shift Point Analysis
|
// Shift Point Analysis
|
||||||
|
|
@ -276,7 +277,44 @@ fn lerp_search<T>(
|
||||||
// Track Map Analysis
|
// 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 struct TrackPoint {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
|
|
@ -284,7 +322,7 @@ pub struct TrackPoint {
|
||||||
pub spline: f32,
|
pub spline: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||||
pub struct TrackBoundary {
|
pub struct TrackBoundary {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub point_count: usize,
|
pub point_count: usize,
|
||||||
|
|
@ -296,26 +334,50 @@ pub struct TrackMapAnalysis {
|
||||||
pub boundaries: Vec<TrackBoundary>,
|
pub boundaries: Vec<TrackBoundary>,
|
||||||
pub center_line: Option<TrackBoundary>,
|
pub center_line: Option<TrackBoundary>,
|
||||||
pub total_points: usize,
|
pub total_points: usize,
|
||||||
|
pub position_mode: PositionMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze_track_map(
|
pub fn analyze_track_map(
|
||||||
runs: &[Vec<TelemetryFrame>],
|
runs: &[Vec<TelemetryFrame>],
|
||||||
labels: &[String],
|
labels: &[String],
|
||||||
) -> TrackMapAnalysis {
|
) -> 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();
|
let mut boundaries: Vec<TrackBoundary> = Vec::new();
|
||||||
|
|
||||||
for (run, label) in runs.iter().zip(labels.iter()) {
|
for (run, label) in runs.iter().zip(labels.iter()) {
|
||||||
let mut points: Vec<TrackPoint> = run
|
let mut points: Vec<TrackPoint> = match &mode {
|
||||||
.iter()
|
PositionMode::Spline => {
|
||||||
.filter(|f| f.spline_position != 0.0)
|
run.iter()
|
||||||
.map(|f| TrackPoint {
|
.filter(|f| f.spline_position > 0.0)
|
||||||
x: f.world_x,
|
.map(|f| TrackPoint {
|
||||||
y: f.world_y,
|
x: f.world_x,
|
||||||
z: f.world_z,
|
y: f.world_y,
|
||||||
spline: f.spline_position,
|
z: f.world_z,
|
||||||
})
|
spline: f.spline_position,
|
||||||
.collect();
|
})
|
||||||
|
.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.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();
|
let point_count = points.len();
|
||||||
boundaries.push(TrackBoundary {
|
boundaries.push(TrackBoundary {
|
||||||
label: label.clone(),
|
label: label.clone(),
|
||||||
|
|
@ -333,6 +395,7 @@ pub fn analyze_track_map(
|
||||||
boundaries,
|
boundaries,
|
||||||
center_line,
|
center_line,
|
||||||
total_points,
|
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
|
// Dispatch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -415,8 +735,10 @@ pub fn analyze(
|
||||||
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UseCase::TrackMap => serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
|
UseCase::TrackMap | UseCase::TrackMapping => {
|
||||||
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})),
|
serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
||||||
|
}
|
||||||
_ => serde_json::json!({
|
_ => serde_json::json!({
|
||||||
"status": "not_implemented",
|
"status": "not_implemented",
|
||||||
"use_case": format!("{:?}", use_case),
|
"use_case": format!("{:?}", use_case),
|
||||||
|
|
|
||||||
146
src/api.rs
146
src/api.rs
|
|
@ -24,6 +24,7 @@ pub struct AppState {
|
||||||
pub recording_manager: Arc<RecordingManager>,
|
pub recording_manager: Arc<RecordingManager>,
|
||||||
pub feed_displays: Vec<(FeedDisplayInfo, Arc<Mutex<FeedState>>)>,
|
pub feed_displays: Vec<(FeedDisplayInfo, Arc<Mutex<FeedState>>)>,
|
||||||
pub session_manager: Arc<std::sync::Mutex<SessionManager>>,
|
pub session_manager: Arc<std::sync::Mutex<SessionManager>>,
|
||||||
|
pub track_model_store: Arc<crate::track_model::TrackModelStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -89,6 +90,14 @@ struct StartRunRequest {
|
||||||
merge_feeds: Vec<String>,
|
merge_feeds: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BuildTrackRequest {
|
||||||
|
session_id: String,
|
||||||
|
game: Option<String>,
|
||||||
|
track_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_session(
|
async fn create_session(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<CreateSessionRequest>,
|
Json(req): Json<CreateSessionRequest>,
|
||||||
|
|
@ -520,6 +529,139 @@ struct UseCaseInfo {
|
||||||
guidance: String,
|
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 {
|
async fn list_use_cases() -> Response {
|
||||||
let use_cases: Vec<UseCaseInfo> = UseCase::all()
|
let use_cases: Vec<UseCaseInfo> = UseCase::all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -566,6 +708,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
get(get_feed_data),
|
get(get_feed_data),
|
||||||
)
|
)
|
||||||
.route("/api/live", get(get_live))
|
.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/use-cases", get(list_use_cases))
|
||||||
.route("/api/games", get(list_games))
|
.route("/api/games", get(list_games))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mod parser_ksudp;
|
||||||
mod parser_pcars;
|
mod parser_pcars;
|
||||||
mod parser_telemetry_tool;
|
mod parser_telemetry_tool;
|
||||||
mod recording;
|
mod recording;
|
||||||
|
mod track_model;
|
||||||
mod telemetry_frame;
|
mod telemetry_frame;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod warnings;
|
mod warnings;
|
||||||
|
|
@ -386,6 +387,7 @@ async fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let recording_manager = Arc::new(recording::RecordingManager::new(&data_dir));
|
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(
|
let session_manager = Arc::new(std::sync::Mutex::new(
|
||||||
SessionManager::new(recording_manager.clone())
|
SessionManager::new(recording_manager.clone())
|
||||||
));
|
));
|
||||||
|
|
@ -492,6 +494,7 @@ async fn main() {
|
||||||
recording_manager: recording_manager.clone(),
|
recording_manager: recording_manager.clone(),
|
||||||
feed_displays: feed_displays.clone(),
|
feed_displays: feed_displays.clone(),
|
||||||
session_manager: session_manager.clone(),
|
session_manager: session_manager.clone(),
|
||||||
|
track_model_store,
|
||||||
});
|
});
|
||||||
let api_addr = api_bind_addr();
|
let api_addr = api_bind_addr();
|
||||||
warnings.info(&format!("REST API server will listen on {}", api_addr));
|
warnings.info(&format!("REST API server will listen on {}", api_addr));
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,21 @@ fn parse_v1_telemetry(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
|
||||||
// Track length
|
// Track length
|
||||||
let track_length = read_f32(data, 1360);
|
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 {
|
let frame = TelemetryFrame {
|
||||||
source: FeedSource::Pcars,
|
source: FeedSource::Pcars,
|
||||||
raw_size: data.len(),
|
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,
|
lap_time_ms: (current_lap_s * 1000.0) as i32,
|
||||||
last_lap_ms: (last_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,
|
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_x: v1_world_x,
|
||||||
world_y: 0.0,
|
world_y: v1_world_y,
|
||||||
world_z: 0.0,
|
world_z: v1_world_z,
|
||||||
spline_position: 0.0,
|
spline_position: spline_pos,
|
||||||
|
|
||||||
acc_g_vertical: acc_vert,
|
acc_g_vertical: acc_vert,
|
||||||
acc_g_horizontal: acc_horiz,
|
acc_g_horizontal: acc_horiz,
|
||||||
|
|
@ -237,7 +252,8 @@ fn parse_v1_telemetry(data: &[u8]) -> Result<TelemetryFrame, PcarsParseError> {
|
||||||
track_temperature: Some(track_temp),
|
track_temperature: Some(track_temp),
|
||||||
rain_density: Some(rain_density),
|
rain_density: Some(rain_density),
|
||||||
track_length: Some(track_length),
|
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)),
|
tyre_wear: Some(read_u8_4(data, 316)),
|
||||||
brake_temp_celsius: Some(read_i16_4(data, 328)),
|
brake_temp_celsius: Some(read_i16_4(data, 328)),
|
||||||
suspension_damage: Some(read_u8_4(data, 324)),
|
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)),
|
tyre_wear: Some(read_u8_4(data, 196)),
|
||||||
brake_temp_celsius: Some(read_i16_4(data, 208)),
|
brake_temp_celsius: Some(read_i16_4(data, 208)),
|
||||||
suspension_damage: Some(read_u8_4(data, 204)),
|
suspension_damage: Some(read_u8_4(data, 204)),
|
||||||
|
current_lap_distance: None,
|
||||||
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
@ -394,6 +411,15 @@ mod tests {
|
||||||
// Current time at 20 = 42.5 seconds
|
// Current time at 20 = 42.5 seconds
|
||||||
packet[20..24].copy_from_slice(&42.5f32.to_le_bytes());
|
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();
|
let result = parse_pcars_packet(&packet).unwrap();
|
||||||
match result {
|
match result {
|
||||||
PcarsPacket::V1Telemetry(frame) => {
|
PcarsPacket::V1Telemetry(frame) => {
|
||||||
|
|
@ -407,7 +433,13 @@ mod tests {
|
||||||
assert_eq!(frame.best_lap_ms, 78123);
|
assert_eq!(frame.best_lap_ms, 78123);
|
||||||
assert_eq!(frame.last_lap_ms, 80456);
|
assert_eq!(frame.last_lap_ms, 80456);
|
||||||
assert_eq!(frame.lap_time_ms, 42500);
|
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());
|
assert!(frame.aero_damage.is_some());
|
||||||
}
|
}
|
||||||
_ => panic!("Expected V1Telemetry"),
|
_ => panic!("Expected V1Telemetry"),
|
||||||
|
|
@ -492,4 +524,35 @@ mod tests {
|
||||||
let result = parse_pcars_packet(&packet);
|
let result = parse_pcars_packet(&packet);
|
||||||
assert!(matches!(result, Ok(PcarsPacket::V2Other)));
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ pub enum UseCase {
|
||||||
LapTimes,
|
LapTimes,
|
||||||
SectorAnalysis,
|
SectorAnalysis,
|
||||||
TrackMap,
|
TrackMap,
|
||||||
|
TrackMapping,
|
||||||
General,
|
General,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +146,7 @@ impl UseCase {
|
||||||
UseCase::LapTimes,
|
UseCase::LapTimes,
|
||||||
UseCase::SectorAnalysis,
|
UseCase::SectorAnalysis,
|
||||||
UseCase::TrackMap,
|
UseCase::TrackMap,
|
||||||
|
UseCase::TrackMapping,
|
||||||
UseCase::General,
|
UseCase::General,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +159,7 @@ impl UseCase {
|
||||||
UseCase::SectorAnalysis => "Record one or more complete laps.",
|
UseCase::SectorAnalysis => "Record one or more complete laps.",
|
||||||
UseCase::RacingLine => "Record multiple laps with different racing line approaches.",
|
UseCase::RacingLine => "Record multiple laps with different racing line approaches.",
|
||||||
UseCase::General => "Record freely - no specific procedure required.",
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,9 @@ pub struct SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
pub fn new(recording_manager: Arc<RecordingManager>) -> Self {
|
pub fn new(
|
||||||
|
recording_manager: Arc<RecordingManager>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sessions: HashMap::new(),
|
sessions: HashMap::new(),
|
||||||
recording_manager,
|
recording_manager,
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ pub struct TelemetryFrame {
|
||||||
pub rain_density: Option<u8>,
|
pub rain_density: Option<u8>,
|
||||||
pub track_length: Option<f32>,
|
pub track_length: Option<f32>,
|
||||||
pub full_position: Option<[f32; 3]>,
|
pub full_position: Option<[f32; 3]>,
|
||||||
|
pub current_lap_distance: Option<u16>,
|
||||||
pub tyre_wear: Option<[u8; 4]>,
|
pub tyre_wear: Option<[u8; 4]>,
|
||||||
pub brake_temp_celsius: Option<[i16; 4]>,
|
pub brake_temp_celsius: Option<[i16; 4]>,
|
||||||
pub suspension_damage: Option<[u8; 4]>,
|
pub suspension_damage: Option<[u8; 4]>,
|
||||||
|
|
@ -211,6 +212,7 @@ impl Default for TelemetryFrame {
|
||||||
rain_density: None,
|
rain_density: None,
|
||||||
track_length: None,
|
track_length: None,
|
||||||
full_position: None,
|
full_position: None,
|
||||||
|
current_lap_distance: None,
|
||||||
tyre_wear: None,
|
tyre_wear: None,
|
||||||
brake_temp_celsius: None,
|
brake_temp_celsius: None,
|
||||||
suspension_damage: None,
|
suspension_damage: None,
|
||||||
|
|
|
||||||
213
src/track_model.rs
Normal file
213
src/track_model.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue