fix(terrain): use xy ground coordinates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m10s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 10:57:21 +04:00
parent 265d118387
commit 6b007d658f
2 changed files with 44 additions and 36 deletions
+36 -36
View File
@@ -18,7 +18,7 @@
clippy::unwrap_used 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 fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -36,7 +36,7 @@ pub struct TerrainWorld {
/// Surface hit. /// Surface hit.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceHit { pub struct SurfaceHit {
/// Height. /// Source Z height.
pub height: f32, pub height: f32,
/// Hit position. /// Hit position.
pub position: [f32; 3], pub position: [f32; 3],
@@ -135,7 +135,7 @@ impl std::error::Error for TerrainError {}
/// Surface query. /// Surface query.
pub trait SurfaceQuery { pub trait SurfaceQuery {
/// Height at position. /// Source Z height at an XY ground position.
/// ///
/// # Errors /// # Errors
/// ///
@@ -157,7 +157,7 @@ pub trait SurfaceQuery {
/// Navigation query. /// Navigation query.
pub trait NavigationQuery { pub trait NavigationQuery {
/// Locate areal. /// Locates an areal from the XY components of a world position.
/// ///
/// # Errors /// # Errors
/// ///
@@ -195,7 +195,7 @@ impl TerrainWorld {
polygon: areal polygon: areal
.vertices .vertices
.iter() .iter()
.map(|vertex| [vertex[0], vertex[2]]) .map(|vertex| [vertex[0], vertex[1]])
.collect(), .collect(),
}); });
} }
@@ -302,7 +302,7 @@ impl TerrainWorld {
position: [f32; 3], position: [f32; 3],
candidates: &[ArealId], candidates: &[ArealId],
) -> Result<Option<ArealId>, TerrainError> { ) -> Result<Option<ArealId>, TerrainError> {
let point = [position[0], position[2]]; let point = [position[0], position[1]];
for candidate in candidates { for candidate in candidates {
let Some(areal) = usize::try_from(candidate.0) let Some(areal) = usize::try_from(candidate.0)
.ok() .ok()
@@ -427,7 +427,7 @@ impl SurfaceQuery for TerrainWorld {
origin[2] + direction[2] * distance, origin[2] + direction[2] * distance,
]; ];
best = Some(SurfaceHit { best = Some(SurfaceHit {
height: position[1], height: position[2],
position, position,
distance, distance,
face: triangle.face, face: triangle.face,
@@ -468,18 +468,18 @@ struct RuntimeTriangle {
impl RuntimeTriangle { impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> { fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][2]]; let a = [self.vertices[0][0], self.vertices[0][1]];
let b = [self.vertices[1][0], self.vertices[1][2]]; let b = [self.vertices[1][0], self.vertices[1][1]];
let c = [self.vertices[2][0], self.vertices[2][2]]; let c = [self.vertices[2][0], self.vertices[2][1]];
let weights = barycentric_2d(position, a, b, c)?; let weights = barycentric_2d(position, a, b, c)?;
if weights if weights
.iter() .iter()
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001) .all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
{ {
Some( Some(
weights[0] * self.vertices[0][1] weights[0] * self.vertices[0][2]
+ weights[1] * self.vertices[1][1] + weights[1] * self.vertices[1][2]
+ weights[2] * self.vertices[2][1], + weights[2] * self.vertices[2][2],
) )
} else { } else {
None None
@@ -570,9 +570,9 @@ impl RuntimeGrid {
for areal in &map.areals { for areal in &map.areals {
for vertex in &areal.vertices { for vertex in &areal.vertices {
bounds_min[0] = bounds_min[0].min(vertex[0]); 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[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() 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() { if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
return None; return None;
} }
let point = [position[0], position[2]]; let point = [position[0], position[1]];
if point[0] < self.min[0] if point[0] < self.min[0]
|| point[0] > self.max[0] || point[0] > self.max[0]
|| point[1] < self.min[1] || point[1] < self.min[1]
@@ -770,18 +770,18 @@ mod tests {
assert_eq!(world.areal_count(), 2); assert_eq!(world.areal_count(), 2);
assert_eq!( 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)) Some(ArealId(0))
); );
assert_eq!( 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)) Some(ArealId(1))
); );
assert_eq!( assert_eq!(
world world
.route(RouteRequest { .route(RouteRequest {
start: [0.25, 0.0, 0.25], start: [0.25, 0.25, 0.0],
goal: [1.75, 0.0, 0.25], goal: [1.75, 0.25, 0.0],
}) })
.expect("route"), .expect("route"),
Some(ArealRoute { Some(ArealRoute {
@@ -797,8 +797,8 @@ mod tests {
assert_eq!( assert_eq!(
world world
.route(RouteRequest { .route(RouteRequest {
start: [10.0, 0.0, 10.0], start: [10.0, 10.0, 0.0],
goal: [1.75, 0.0, 0.25], goal: [1.75, 0.25, 0.0],
}) })
.expect("route"), .expect("route"),
None None
@@ -815,8 +815,8 @@ mod tests {
let hit = world let hit = world
.raycast( .raycast(
[0.25, 2.0, 0.25], [0.25, 0.25, 2.0],
[0.0, -1.0, 0.0], [0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0001), FullSurfaceMask(0x0000_0001),
) )
.expect("raycast") .expect("raycast")
@@ -828,8 +828,8 @@ mod tests {
assert_eq!( assert_eq!(
world world
.raycast( .raycast(
[0.25, 2.0, 0.25], [0.25, 0.25, 2.0],
[0.0, -1.0, 0.0], [0.0, 0.0, -1.0],
FullSurfaceMask(0x8000_0000) FullSurfaceMask(0x8000_0000)
) )
.expect("raycast"), .expect("raycast"),
@@ -942,9 +942,9 @@ mod tests {
}, },
positions: vec![ positions: vec![
[0.0, 0.0, 0.0], [0.0, 0.0, 0.0],
[1.0, 1.0, 0.0], [1.0, 0.0, 1.0],
[0.0, 1.0, 1.0], [0.0, 1.0, 1.0],
[1.0, 2.0, 1.0], [1.0, 1.0, 2.0],
], ],
normals: Vec::new(), normals: Vec::new(),
uv0: Vec::new(), uv0: Vec::new(),
@@ -987,10 +987,10 @@ mod tests {
areals: vec![ areals: vec![
Areal { Areal {
prefix_raw: [0; 56], prefix_raw: [0; 56],
anchor: [0.5, 0.0, 0.5], anchor: [0.5, 0.5, 0.0],
reserved_12: 0.0, reserved_12: 0.0,
area_metric: 1.0, area_metric: 1.0,
normal: [0.0, 1.0, 0.0], normal: [0.0, 0.0, 1.0],
logic_flag: 0, logic_flag: 0,
reserved_36: 0, reserved_36: 0,
class_id: 0, class_id: 0,
@@ -998,8 +998,8 @@ mod tests {
vertices: vec![ vertices: vec![
[0.0, 0.0, 0.0], [0.0, 0.0, 0.0],
[1.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[1.0, 0.0, 1.0], [1.0, 1.0, 0.0],
[0.0, 0.0, 1.0], [0.0, 1.0, 0.0],
], ],
links: vec![ links: vec![
EdgeLink { EdgeLink {
@@ -1031,10 +1031,10 @@ mod tests {
}, },
Areal { Areal {
prefix_raw: [0; 56], prefix_raw: [0; 56],
anchor: [1.5, 0.0, 0.5], anchor: [1.5, 0.5, 0.0],
reserved_12: 0.0, reserved_12: 0.0,
area_metric: 1.0, area_metric: 1.0,
normal: [0.0, 1.0, 0.0], normal: [0.0, 0.0, 1.0],
logic_flag: 0, logic_flag: 0,
reserved_36: 0, reserved_36: 0,
class_id: 0, class_id: 0,
@@ -1042,8 +1042,8 @@ mod tests {
vertices: vec![ vertices: vec![
[1.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[2.0, 0.0, 0.0], [2.0, 0.0, 0.0],
[2.0, 0.0, 1.0], [2.0, 1.0, 0.0],
[1.0, 0.0, 1.0], [1.0, 1.0, 0.0],
], ],
links: vec![ links: vec![
EdgeLink { EdgeLink {
+8
View File
@@ -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 terminated without a native frame/report; therefore no new readback hash or GPU
acceptance result is claimed for the corrected projection yet. 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 ### Camera ownership boundary from the GOG renderer
The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import