aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-terrain/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-terrain/src
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-terrain/src')
-rw-r--r--crates/fparkan-terrain/src/lib.rs1079
1 files changed, 1079 insertions, 0 deletions
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
new file mode 100644
index 0000000..b28fca6
--- /dev/null
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -0,0 +1,1079 @@
+#![forbid(unsafe_code)]
+//! Validated terrain runtime queries.
+
+use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
+use std::collections::VecDeque;
+
+/// Terrain world.
+#[derive(Clone, Debug, Default)]
+pub struct TerrainWorld {
+ areals: Vec<RuntimeAreal>,
+ grid: RuntimeGrid,
+ adjacency: Vec<Vec<ArealId>>,
+ surfaces: Vec<RuntimeTriangle>,
+}
+
+/// Surface hit.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SurfaceHit {
+ /// Height.
+ pub height: f32,
+ /// Hit position.
+ pub position: [f32; 3],
+ /// Ray distance parameter.
+ pub distance: f32,
+ /// Source face index.
+ pub face: usize,
+}
+
+/// Areal id.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct ArealId(pub u32);
+
+/// Route request.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct RouteRequest {
+ /// Start.
+ pub start: [f32; 3],
+ /// Goal.
+ pub goal: [f32; 3],
+}
+
+/// Areal route.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ArealRoute {
+ /// Areas.
+ pub areas: Vec<ArealId>,
+}
+
+/// Terrain error.
+#[derive(Debug)]
+pub enum TerrainError {
+ /// Query is not supported by current data.
+ Unsupported,
+ /// Area count exceeds runtime id range.
+ TooManyAreals {
+ /// Area count.
+ count: usize,
+ },
+ /// Grid references an out-of-range area.
+ InvalidGridReference {
+ /// Referenced area.
+ area: u32,
+ /// Area count.
+ area_count: usize,
+ },
+ /// Areal graph references an out-of-range area.
+ InvalidArealReference {
+ /// Source area.
+ source: usize,
+ /// Referenced area.
+ target: u32,
+ /// Area count.
+ area_count: usize,
+ },
+ /// Terrain face references an out-of-range vertex.
+ InvalidSurfaceVertex {
+ /// Source face.
+ face: usize,
+ /// Referenced vertex.
+ vertex: u16,
+ /// Position count.
+ position_count: usize,
+ },
+}
+
+impl std::fmt::Display for TerrainError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Unsupported => write!(f, "terrain query unsupported by current data"),
+ Self::TooManyAreals { count } => write!(f, "too many areals: {count}"),
+ Self::InvalidGridReference { area, area_count } => {
+ write!(f, "grid references area {area} outside {area_count} areas")
+ }
+ Self::InvalidArealReference {
+ source,
+ target,
+ area_count,
+ } => write!(
+ f,
+ "area {source} references area {target} outside {area_count} areas"
+ ),
+ Self::InvalidSurfaceVertex {
+ face,
+ vertex,
+ position_count,
+ } => write!(
+ f,
+ "terrain face {face} references vertex {vertex} outside {position_count} positions"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for TerrainError {}
+
+/// Surface query.
+pub trait SurfaceQuery {
+ /// Height at position.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when the current world lacks surface geometry.
+ fn height_at(&self, position: [f32; 2]) -> Result<Option<f32>, TerrainError>;
+
+ /// Raycast.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when the current world lacks surface geometry.
+ fn raycast(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ mask: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError>;
+}
+
+/// Navigation query.
+pub trait NavigationQuery {
+ /// Locate areal.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when runtime indexes are invalid.
+ fn locate_areal(&self, position: [f32; 3]) -> Result<Option<ArealId>, TerrainError>;
+
+ /// Route.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when runtime indexes are invalid.
+ fn route(&self, request: RouteRequest) -> Result<Option<ArealRoute>, TerrainError>;
+}
+
+impl TerrainWorld {
+ /// Builds navigation runtime data from a decoded `Land.map`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if ids or references cannot be represented by
+ /// runtime indexes.
+ pub fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
+ let areal_count = map.areals.len();
+ if u32::try_from(areal_count).is_err() {
+ return Err(TerrainError::TooManyAreals { count: areal_count });
+ }
+ let mut areals = Vec::with_capacity(areal_count);
+ for (index, areal) in map.areals.iter().enumerate() {
+ let id = ArealId(
+ u32::try_from(index)
+ .map_err(|_| TerrainError::TooManyAreals { count: areal_count })?,
+ );
+ areals.push(RuntimeAreal {
+ id,
+ polygon: areal
+ .vertices
+ .iter()
+ .map(|vertex| [vertex[0], vertex[2]])
+ .collect(),
+ });
+ }
+
+ let mut adjacency = vec![Vec::new(); areal_count];
+ for (source_index, areal) in map.areals.iter().enumerate() {
+ for link in &areal.links {
+ let Some(target) = link.area_ref else {
+ continue;
+ };
+ let target_index =
+ usize::try_from(target).map_err(|_| TerrainError::InvalidArealReference {
+ source: source_index,
+ target,
+ area_count: areal_count,
+ })?;
+ if target_index >= areal_count {
+ return Err(TerrainError::InvalidArealReference {
+ source: source_index,
+ target,
+ area_count: areal_count,
+ });
+ }
+ let id = ArealId(target);
+ if !adjacency[source_index].contains(&id) {
+ adjacency[source_index].push(id);
+ }
+ }
+ adjacency[source_index].sort_by_key(|id| id.0);
+ }
+
+ let grid = RuntimeGrid::from_land_map(map)?;
+ Ok(Self {
+ areals,
+ grid,
+ adjacency,
+ surfaces: Vec::new(),
+ })
+ }
+
+ /// Builds surface runtime data from a decoded `Land.msh`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if a face cannot be represented by runtime
+ /// indexes.
+ pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
+ Ok(Self {
+ surfaces: build_surfaces(mesh)?,
+ ..Self::default()
+ })
+ }
+
+ /// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if surface or navigation runtime indexes are
+ /// invalid.
+ pub fn from_land_assets(
+ mesh: &LandMeshDocument,
+ map: &LandMapDocument,
+ ) -> Result<Self, TerrainError> {
+ let mut world = Self::from_land_map(map)?;
+ world.surfaces = build_surfaces(mesh)?;
+ Ok(world)
+ }
+
+ /// Returns the number of navigation areas.
+ #[must_use]
+ pub fn areal_count(&self) -> usize {
+ self.areals.len()
+ }
+
+ /// Returns the number of surface triangles.
+ #[must_use]
+ pub fn surface_count(&self) -> usize {
+ self.surfaces.len()
+ }
+
+ fn locate_by_candidates(
+ &self,
+ position: [f32; 3],
+ candidates: &[ArealId],
+ ) -> Result<Option<ArealId>, TerrainError> {
+ let point = [position[0], position[2]];
+ for candidate in candidates {
+ let Some(areal) = usize::try_from(candidate.0)
+ .ok()
+ .and_then(|index| self.areals.get(index))
+ else {
+ return Err(TerrainError::InvalidGridReference {
+ area: candidate.0,
+ area_count: self.areals.len(),
+ });
+ };
+ if areal.contains(point) {
+ return Ok(Some(areal.id));
+ }
+ }
+ Ok(None)
+ }
+
+ fn route_ids(&self, start: ArealId, goal: ArealId) -> Result<Option<ArealRoute>, TerrainError> {
+ let start_index =
+ usize::try_from(start.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: 0,
+ target: start.0,
+ area_count: self.areals.len(),
+ })?;
+ let goal_index =
+ usize::try_from(goal.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: start_index,
+ target: goal.0,
+ area_count: self.areals.len(),
+ })?;
+ if start_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: start_index,
+ target: start.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if goal_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: start_index,
+ target: goal.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if start == goal {
+ return Ok(Some(ArealRoute { areas: vec![start] }));
+ }
+
+ let mut previous = vec![None; self.areals.len()];
+ let mut visited = vec![false; self.areals.len()];
+ let mut queue = VecDeque::new();
+ visited[start_index] = true;
+ queue.push_back(start_index);
+
+ while let Some(current) = queue.pop_front() {
+ for next in &self.adjacency[current] {
+ let next_index =
+ usize::try_from(next.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: current,
+ target: next.0,
+ area_count: self.areals.len(),
+ })?;
+ if next_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: current,
+ target: next.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if visited[next_index] {
+ continue;
+ }
+ visited[next_index] = true;
+ previous[next_index] = Some(current);
+ if next_index == goal_index {
+ return Ok(Some(reconstruct_route(&previous, start_index, goal_index)));
+ }
+ queue.push_back(next_index);
+ }
+ }
+ Ok(None)
+ }
+}
+
+impl SurfaceQuery for TerrainWorld {
+ fn height_at(&self, position: [f32; 2]) -> Result<Option<f32>, TerrainError> {
+ if self.surfaces.is_empty() {
+ return Err(TerrainError::Unsupported);
+ }
+ let mut best = None;
+ for triangle in &self.surfaces {
+ if let Some(height) = triangle.height_at(position) {
+ best = Some(best.map_or(height, |current: f32| current.max(height)));
+ }
+ }
+ Ok(best)
+ }
+
+ fn raycast(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ mask: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
+ if self.surfaces.is_empty() {
+ return Err(TerrainError::Unsupported);
+ }
+ let mut best: Option<SurfaceHit> = None;
+ for triangle in &self.surfaces {
+ if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
+ continue;
+ }
+ let Some(distance) = triangle.raycast(origin, direction) else {
+ continue;
+ };
+ if best.is_some_and(|hit| hit.distance <= distance) {
+ continue;
+ }
+ let position = [
+ origin[0] + direction[0] * distance,
+ origin[1] + direction[1] * distance,
+ origin[2] + direction[2] * distance,
+ ];
+ best = Some(SurfaceHit {
+ height: position[1],
+ position,
+ distance,
+ face: triangle.face,
+ });
+ }
+ Ok(best)
+ }
+}
+
+impl NavigationQuery for TerrainWorld {
+ fn locate_areal(&self, position: [f32; 3]) -> Result<Option<ArealId>, TerrainError> {
+ if let Some(candidates) = self.grid.candidates(position) {
+ if let Some(id) = self.locate_by_candidates(position, candidates)? {
+ return Ok(Some(id));
+ }
+ }
+ let all: Vec<ArealId> = self.areals.iter().map(|areal| areal.id).collect();
+ self.locate_by_candidates(position, &all)
+ }
+
+ fn route(&self, request: RouteRequest) -> Result<Option<ArealRoute>, TerrainError> {
+ let Some(start) = self.locate_areal(request.start)? else {
+ return Ok(None);
+ };
+ let Some(goal) = self.locate_areal(request.goal)? else {
+ return Ok(None);
+ };
+ self.route_ids(start, goal)
+ }
+}
+
+#[derive(Clone, Debug)]
+struct RuntimeTriangle {
+ face: usize,
+ mask: FullSurfaceMask,
+ vertices: [[f32; 3]; 3],
+}
+
+impl RuntimeTriangle {
+ fn height_at(&self, position: [f32; 2]) -> Option<f32> {
+ 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 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],
+ )
+ } else {
+ None
+ }
+ }
+
+ fn raycast(&self, origin: [f32; 3], direction: [f32; 3]) -> Option<f32> {
+ let edge1 = sub3(self.vertices[1], self.vertices[0]);
+ let edge2 = sub3(self.vertices[2], self.vertices[0]);
+ let pvec = cross3(direction, edge2);
+ let det = dot3(edge1, pvec);
+ if det.abs() <= 1.0e-6 {
+ return None;
+ }
+ let inv_det = 1.0 / det;
+ let tvec = sub3(origin, self.vertices[0]);
+ let u = dot3(tvec, pvec) * inv_det;
+ if !(-1.0e-5..=1.00001).contains(&u) {
+ return None;
+ }
+ let qvec = cross3(tvec, edge1);
+ let v = dot3(direction, qvec) * inv_det;
+ if v < -1.0e-5 || u + v > 1.00001 {
+ return None;
+ }
+ let distance = dot3(edge2, qvec) * inv_det;
+ (distance >= 0.0).then_some(distance)
+ }
+}
+
+#[derive(Clone, Debug)]
+struct RuntimeAreal {
+ id: ArealId,
+ polygon: Vec<[f32; 2]>,
+}
+
+impl RuntimeAreal {
+ fn contains(&self, point: [f32; 2]) -> bool {
+ if self.polygon.len() < 3 {
+ return false;
+ }
+ if self.on_boundary(point) {
+ return true;
+ }
+
+ let mut inside = false;
+ let mut prev = self.polygon[self.polygon.len() - 1];
+ for current in &self.polygon {
+ let crosses = (current[1] > point[1]) != (prev[1] > point[1]);
+ if crosses {
+ let x_intersect = (prev[0] - current[0]) * (point[1] - current[1])
+ / (prev[1] - current[1])
+ + current[0];
+ if point[0] < x_intersect {
+ inside = !inside;
+ }
+ }
+ prev = *current;
+ }
+ inside
+ }
+
+ fn on_boundary(&self, point: [f32; 2]) -> bool {
+ let mut prev = self.polygon[self.polygon.len() - 1];
+ for current in &self.polygon {
+ if point_on_segment(point, prev, *current) {
+ return true;
+ }
+ prev = *current;
+ }
+ false
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+struct RuntimeGrid {
+ cells_x: u32,
+ cells_y: u32,
+ min: [f32; 2],
+ max: [f32; 2],
+ cells: Vec<Vec<ArealId>>,
+}
+
+impl RuntimeGrid {
+ fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
+ let mut min = [f32::INFINITY, f32::INFINITY];
+ let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
+ for areal in &map.areals {
+ for vertex in &areal.vertices {
+ min[0] = min[0].min(vertex[0]);
+ min[1] = min[1].min(vertex[2]);
+ max[0] = max[0].max(vertex[0]);
+ max[1] = max[1].max(vertex[2]);
+ }
+ }
+ if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
+ {
+ min = [0.0, 0.0];
+ max = [1.0, 1.0];
+ }
+ if (min[0] - max[0]).abs() <= f32::EPSILON {
+ max[0] += 1.0;
+ }
+ if (min[1] - max[1]).abs() <= f32::EPSILON {
+ max[1] += 1.0;
+ }
+
+ let mut cells = Vec::with_capacity(map.grid.cells.len());
+ for cell in &map.grid.cells {
+ let mut ids = Vec::with_capacity(cell.area_ids.len());
+ for area in &cell.area_ids {
+ let index =
+ usize::try_from(*area).map_err(|_| TerrainError::InvalidGridReference {
+ area: *area,
+ area_count: map.areals.len(),
+ })?;
+ if index >= map.areals.len() {
+ return Err(TerrainError::InvalidGridReference {
+ area: *area,
+ area_count: map.areals.len(),
+ });
+ }
+ ids.push(ArealId(*area));
+ }
+ cells.push(ids);
+ }
+ Ok(Self {
+ cells_x: map.grid.cells_x,
+ cells_y: map.grid.cells_y,
+ min,
+ max,
+ cells,
+ })
+ }
+
+ fn candidates(&self, position: [f32; 3]) -> Option<&[ArealId]> {
+ if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
+ return None;
+ }
+ let point = [position[0], position[2]];
+ if point[0] < self.min[0]
+ || point[0] > self.max[0]
+ || point[1] < self.min[1]
+ || point[1] > self.max[1]
+ {
+ return None;
+ }
+ let nx = normalized_cell(point[0], self.min[0], self.max[0], self.cells_x);
+ let ny = normalized_cell(point[1], self.min[1], self.max[1], self.cells_y);
+ let index_u32 = ny.checked_mul(self.cells_x)?.checked_add(nx)?;
+ let index = usize::try_from(index_u32).ok()?;
+ self.cells.get(index).map(Vec::as_slice)
+ }
+}
+
+fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, TerrainError> {
+ let mut triangles = Vec::with_capacity(mesh.faces.len());
+ for (face_index, face) in mesh.faces.iter().enumerate() {
+ let vertices = [
+ surface_vertex(mesh, face_index, face.vertices[0])?,
+ surface_vertex(mesh, face_index, face.vertices[1])?,
+ surface_vertex(mesh, face_index, face.vertices[2])?,
+ ];
+ triangles.push(RuntimeTriangle {
+ face: face_index,
+ mask: face.flags,
+ vertices,
+ });
+ }
+ Ok(triangles)
+}
+
+fn surface_vertex(
+ mesh: &LandMeshDocument,
+ face: usize,
+ vertex: u16,
+) -> Result<[f32; 3], TerrainError> {
+ mesh.positions
+ .get(usize::from(vertex))
+ .copied()
+ .ok_or(TerrainError::InvalidSurfaceVertex {
+ face,
+ vertex,
+ position_count: mesh.positions.len(),
+ })
+}
+
+fn barycentric_2d(
+ point: [f32; 2],
+ first: [f32; 2],
+ second: [f32; 2],
+ third: [f32; 2],
+) -> Option<[f32; 3]> {
+ let edge_second = [second[0] - first[0], second[1] - first[1]];
+ let edge_third = [third[0] - first[0], third[1] - first[1]];
+ let point_delta = [point[0] - first[0], point[1] - first[1]];
+ let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1];
+ if denom.abs() <= 1.0e-6 {
+ return None;
+ }
+ let inv = 1.0 / denom;
+ let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) * inv;
+ let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) * inv;
+ let first_weight = 1.0 - second_weight - third_weight;
+ Some([first_weight, second_weight, third_weight])
+}
+
+fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+}
+
+fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn normalized_cell(value: f32, min: f32, max: f32, cells: u32) -> u32 {
+ let span = max - min;
+ if span <= 0.0 {
+ return 0;
+ }
+ if value <= min {
+ return 0;
+ }
+ if value >= max {
+ return cells.saturating_sub(1);
+ }
+ let value = f64::from(value);
+ let min = f64::from(min);
+ let span = f64::from(span);
+ for cell in 0..cells {
+ let upper = min + span * f64::from(cell + 1) / f64::from(cells);
+ if value <= upper {
+ return cell;
+ }
+ }
+ cells.saturating_sub(1)
+}
+
+fn point_on_segment(point: [f32; 2], a: [f32; 2], b: [f32; 2]) -> bool {
+ let cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]);
+ if cross.abs() > 1.0e-4 {
+ return false;
+ }
+ let min_x = a[0].min(b[0]) - 1.0e-4;
+ let max_x = a[0].max(b[0]) + 1.0e-4;
+ let min_y = a[1].min(b[1]) - 1.0e-4;
+ let max_y = a[1].max(b[1]) + 1.0e-4;
+ point[0] >= min_x && point[0] <= max_x && point[1] >= min_y && point[1] <= max_y
+}
+
+fn reconstruct_route(previous: &[Option<usize>], start: usize, goal: usize) -> ArealRoute {
+ let mut route = Vec::new();
+ let mut current = goal;
+ route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX)));
+ while current != start {
+ let Some(prev) = previous[current] else {
+ break;
+ };
+ current = prev;
+ route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX)));
+ }
+ route.reverse();
+ ArealRoute { areas: route }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+
+ #[test]
+ fn locates_areal_and_routes_synthetic_neighbors() {
+ let map = synthetic_land_map();
+ let world = TerrainWorld::from_land_map(&map).expect("world");
+
+ assert_eq!(world.areal_count(), 2);
+ assert_eq!(
+ world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
+ Some(ArealId(0))
+ );
+ assert_eq!(
+ world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
+ Some(ArealId(1))
+ );
+ assert_eq!(
+ world
+ .route(RouteRequest {
+ start: [0.25, 0.0, 0.25],
+ goal: [1.75, 0.0, 0.25],
+ })
+ .expect("route"),
+ Some(ArealRoute {
+ areas: vec![ArealId(0), ArealId(1)]
+ })
+ );
+ }
+
+ #[test]
+ fn missing_start_or_goal_returns_no_route() {
+ let world = TerrainWorld::from_land_map(&synthetic_land_map()).expect("world");
+
+ assert_eq!(
+ world
+ .route(RouteRequest {
+ start: [10.0, 0.0, 10.0],
+ goal: [1.75, 0.0, 0.25],
+ })
+ .expect("route"),
+ None
+ );
+ }
+
+ #[test]
+ fn synthetic_surface_height_and_raycast_work() {
+ let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");
+
+ assert_eq!(world.surface_count(), 2);
+ assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.5));
+ assert_eq!(world.height_at([10.0, 10.0]).expect("height"), None);
+
+ let hit = world
+ .raycast(
+ [0.25, 2.0, 0.25],
+ [0.0, -1.0, 0.0],
+ FullSurfaceMask(0x0000_0001),
+ )
+ .expect("raycast")
+ .expect("hit");
+ assert_eq!(hit.face, 0);
+ assert!((hit.height - 0.5).abs() < 1.0e-5);
+ assert!((hit.distance - 1.5).abs() < 1.0e-5);
+
+ assert_eq!(
+ world
+ .raycast(
+ [0.25, 2.0, 0.25],
+ [0.0, -1.0, 0.0],
+ FullSurfaceMask(0x8000_0000)
+ )
+ .expect("raycast"),
+ None
+ );
+ }
+
+ #[test]
+ fn licensed_corpus_land_maps_build_navigation_worlds() {
+ for (corpus, expected_files, expected_areals) in [
+ ("IS", 33_usize, 34_662_usize),
+ ("IS2", 32_usize, 18_984_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut areals = 0usize;
+ let mut located_centers = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.map"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.map");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let map = fparkan_terrain_format::decode_land_map(&nres)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let world = TerrainWorld::from_land_map(&map)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ areals += world.areal_count();
+ for (index, areal) in map.areals.iter().take(8).enumerate() {
+ if let Some(point) = polygon_probe_point(&areal.vertices) {
+ let located = world
+ .locate_areal([point[0], point[1], point[2]])
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ assert!(
+ located.is_some(),
+ "{corpus} {path:?} area {index} probe point was not located"
+ );
+ located_centers += 1;
+ }
+ }
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.map count");
+ assert_eq!(areals, expected_areals, "{corpus} areal count");
+ assert!(
+ located_centers >= expected_files,
+ "{corpus} located center coverage"
+ );
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_land_meshes_build_surface_worlds() {
+ for (corpus, expected_files, expected_faces) in [
+ ("IS", 33_usize, 275_882_usize),
+ ("IS2", 32_usize, 184_454_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut faces = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.msh"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.msh");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let mesh = fparkan_terrain_format::decode_land_msh(&nres)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let world = TerrainWorld::from_land_msh(&mesh)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ faces += world.surface_count();
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.msh count");
+ assert_eq!(faces, expected_faces, "{corpus} surface face count");
+ }
+ }
+
+ fn synthetic_land_mesh() -> LandMeshDocument {
+ use fparkan_terrain_format::{TerrainSlotTable, TerrainStream};
+
+ let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]);
+ let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]);
+ LandMeshDocument {
+ streams: Vec::<TerrainStream>::new(),
+ nodes_raw: Vec::new(),
+ slots: TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 1.0, 0.0],
+ [0.0, 1.0, 1.0],
+ [1.0, 2.0, 1.0],
+ ],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![face0, face1],
+ }
+ }
+
+ fn terrain_face(
+ flags: FullSurfaceMask,
+ vertices: [u16; 3],
+ ) -> fparkan_terrain_format::TerrainFace28 {
+ use fparkan_terrain_format::TerrainFace28;
+
+ TerrainFace28 {
+ flags,
+ material_tag: 0,
+ aux_tag: 0,
+ vertices,
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }
+ }
+
+ fn synthetic_land_map() -> LandMapDocument {
+ use fparkan_terrain_format::{
+ Areal, ArealGrid, ArealGridCell, EdgeLink, TerrainStream, TerrainStreamAttributes,
+ };
+
+ LandMapDocument {
+ entry: TerrainStream {
+ type_id: 12,
+ attributes: TerrainStreamAttributes::default(),
+ size: 0,
+ },
+ areal_count: 2,
+ areals: vec![
+ Areal {
+ prefix_raw: [0; 56],
+ anchor: [0.5, 0.0, 0.5],
+ reserved_12: 0.0,
+ area_metric: 1.0,
+ normal: [0.0, 1.0, 0.0],
+ logic_flag: 0,
+ reserved_36: 0,
+ class_id: 0,
+ reserved_44: 0,
+ 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],
+ ],
+ links: vec![
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: 1,
+ raw_edge_ref: 3,
+ area_ref: Some(1),
+ edge_ref: Some(3),
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ ],
+ polygon_blocks: Vec::new(),
+ },
+ Areal {
+ prefix_raw: [0; 56],
+ anchor: [1.5, 0.0, 0.5],
+ reserved_12: 0.0,
+ area_metric: 1.0,
+ normal: [0.0, 1.0, 0.0],
+ logic_flag: 0,
+ reserved_36: 0,
+ class_id: 0,
+ reserved_44: 0,
+ 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],
+ ],
+ links: vec![
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: 0,
+ raw_edge_ref: 1,
+ area_ref: Some(0),
+ edge_ref: Some(1),
+ },
+ ],
+ polygon_blocks: Vec::new(),
+ },
+ ],
+ grid: ArealGrid {
+ cells_x: 2,
+ cells_y: 1,
+ cells: vec![
+ ArealGridCell { area_ids: vec![0] },
+ ArealGridCell { area_ids: vec![1] },
+ ],
+ candidate_pool: vec![0, 1],
+ compact_cells: vec![0x0040_0000, 0x0040_0001],
+ },
+ }
+ }
+
+ fn polygon_probe_point(vertices: &[[f32; 3]]) -> Option<[f32; 3]> {
+ vertices.first().copied()
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}