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

@ -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"),
}
}
}