From 6b007d658f0ba559414a3cb1a3c315b355fd45ac Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 10:57:21 +0400 Subject: [PATCH] fix(terrain): use xy ground coordinates --- crates/fparkan-terrain/src/lib.rs | 72 +++++++++++++++---------------- docs/tomes/05-render.md | 8 ++++ 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index d539577..65146b9 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -18,7 +18,7 @@ clippy::unwrap_used ) )] -//! Validated terrain runtime queries. +//! Validated terrain runtime queries using XY ground coordinates and Z height. use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; use std::collections::VecDeque; @@ -36,7 +36,7 @@ pub struct TerrainWorld { /// Surface hit. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SurfaceHit { - /// Height. + /// Source Z height. pub height: f32, /// Hit position. pub position: [f32; 3], @@ -135,7 +135,7 @@ impl std::error::Error for TerrainError {} /// Surface query. pub trait SurfaceQuery { - /// Height at position. + /// Source Z height at an XY ground position. /// /// # Errors /// @@ -157,7 +157,7 @@ pub trait SurfaceQuery { /// Navigation query. pub trait NavigationQuery { - /// Locate areal. + /// Locates an areal from the XY components of a world position. /// /// # Errors /// @@ -195,7 +195,7 @@ impl TerrainWorld { polygon: areal .vertices .iter() - .map(|vertex| [vertex[0], vertex[2]]) + .map(|vertex| [vertex[0], vertex[1]]) .collect(), }); } @@ -302,7 +302,7 @@ impl TerrainWorld { position: [f32; 3], candidates: &[ArealId], ) -> Result, TerrainError> { - let point = [position[0], position[2]]; + let point = [position[0], position[1]]; for candidate in candidates { let Some(areal) = usize::try_from(candidate.0) .ok() @@ -427,7 +427,7 @@ impl SurfaceQuery for TerrainWorld { origin[2] + direction[2] * distance, ]; best = Some(SurfaceHit { - height: position[1], + height: position[2], position, distance, face: triangle.face, @@ -468,18 +468,18 @@ struct RuntimeTriangle { impl RuntimeTriangle { fn height_at(&self, position: [f32; 2]) -> Option { - let a = [self.vertices[0][0], self.vertices[0][2]]; - let b = [self.vertices[1][0], self.vertices[1][2]]; - let c = [self.vertices[2][0], self.vertices[2][2]]; + let a = [self.vertices[0][0], self.vertices[0][1]]; + let b = [self.vertices[1][0], self.vertices[1][1]]; + let c = [self.vertices[2][0], self.vertices[2][1]]; let weights = barycentric_2d(position, a, b, c)?; if weights .iter() .all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001) { Some( - weights[0] * self.vertices[0][1] - + weights[1] * self.vertices[1][1] - + weights[2] * self.vertices[2][1], + weights[0] * self.vertices[0][2] + + weights[1] * self.vertices[1][2] + + weights[2] * self.vertices[2][2], ) } else { None @@ -570,9 +570,9 @@ impl RuntimeGrid { for areal in &map.areals { for vertex in &areal.vertices { bounds_min[0] = bounds_min[0].min(vertex[0]); - bounds_min[1] = bounds_min[1].min(vertex[2]); + bounds_min[1] = bounds_min[1].min(vertex[1]); bounds_max[0] = bounds_max[0].max(vertex[0]); - bounds_max[1] = bounds_max[1].max(vertex[2]); + bounds_max[1] = bounds_max[1].max(vertex[1]); } } if !bounds_min[0].is_finite() @@ -622,7 +622,7 @@ impl RuntimeGrid { if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() { return None; } - let point = [position[0], position[2]]; + let point = [position[0], position[1]]; if point[0] < self.min[0] || point[0] > self.max[0] || point[1] < self.min[1] @@ -770,18 +770,18 @@ mod tests { assert_eq!(world.areal_count(), 2); assert_eq!( - world.locate_areal([0.25, 0.0, 0.25]).expect("locate"), + world.locate_areal([0.25, 0.25, 41.0]).expect("locate"), Some(ArealId(0)) ); assert_eq!( - world.locate_areal([1.75, 0.0, 0.25]).expect("locate"), + world.locate_areal([1.75, 0.25, -12.0]).expect("locate"), Some(ArealId(1)) ); assert_eq!( world .route(RouteRequest { - start: [0.25, 0.0, 0.25], - goal: [1.75, 0.0, 0.25], + start: [0.25, 0.25, 0.0], + goal: [1.75, 0.25, 0.0], }) .expect("route"), Some(ArealRoute { @@ -797,8 +797,8 @@ mod tests { assert_eq!( world .route(RouteRequest { - start: [10.0, 0.0, 10.0], - goal: [1.75, 0.0, 0.25], + start: [10.0, 10.0, 0.0], + goal: [1.75, 0.25, 0.0], }) .expect("route"), None @@ -815,8 +815,8 @@ mod tests { let hit = world .raycast( - [0.25, 2.0, 0.25], - [0.0, -1.0, 0.0], + [0.25, 0.25, 2.0], + [0.0, 0.0, -1.0], FullSurfaceMask(0x0000_0001), ) .expect("raycast") @@ -828,8 +828,8 @@ mod tests { assert_eq!( world .raycast( - [0.25, 2.0, 0.25], - [0.0, -1.0, 0.0], + [0.25, 0.25, 2.0], + [0.0, 0.0, -1.0], FullSurfaceMask(0x8000_0000) ) .expect("raycast"), @@ -942,9 +942,9 @@ mod tests { }, positions: vec![ [0.0, 0.0, 0.0], - [1.0, 1.0, 0.0], + [1.0, 0.0, 1.0], [0.0, 1.0, 1.0], - [1.0, 2.0, 1.0], + [1.0, 1.0, 2.0], ], normals: Vec::new(), uv0: Vec::new(), @@ -987,10 +987,10 @@ mod tests { areals: vec![ Areal { prefix_raw: [0; 56], - anchor: [0.5, 0.0, 0.5], + anchor: [0.5, 0.5, 0.0], reserved_12: 0.0, area_metric: 1.0, - normal: [0.0, 1.0, 0.0], + normal: [0.0, 0.0, 1.0], logic_flag: 0, reserved_36: 0, class_id: 0, @@ -998,8 +998,8 @@ mod tests { vertices: vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], - [1.0, 0.0, 1.0], - [0.0, 0.0, 1.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], ], links: vec![ EdgeLink { @@ -1031,10 +1031,10 @@ mod tests { }, Areal { prefix_raw: [0; 56], - anchor: [1.5, 0.0, 0.5], + anchor: [1.5, 0.5, 0.0], reserved_12: 0.0, area_metric: 1.0, - normal: [0.0, 1.0, 0.0], + normal: [0.0, 0.0, 1.0], logic_flag: 0, reserved_36: 0, class_id: 0, @@ -1042,8 +1042,8 @@ mod tests { vertices: vec![ [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], - [2.0, 0.0, 1.0], - [1.0, 0.0, 1.0], + [2.0, 1.0, 0.0], + [1.0, 1.0, 0.0], ], links: vec![ EdgeLink { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 744fa88..1c8c4ec 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1038,6 +1038,14 @@ to 120 seconds but remained at the existing `Graph` loading checkpoint and were terminated without a native frame/report; therefore no new readback hash or GPU acceptance result is claimed for the corrected projection yet. +The same source-axis proof now applies to `TerrainWorld`: its `Land.msh` surface +height query and `Land.map` areal/grid lookup use XY ground coordinates, return +source Z height, and leave raycasts as full 3D intersections. The Part 1 and +Part 2 licensed `Land.map` gate successfully locates sampled polygon vertices +under that contract. This closes the former XZ mismatch in terrain queries; it +does not establish object orientation, physics/gravity, original culling or the +camera matrix. + ### Camera ownership boundary from the GOG renderer The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import