aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-terrain/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-terrain/src')
-rw-r--r--crates/fparkan-terrain/src/lib.rs161
1 files changed, 151 insertions, 10 deletions
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index db55506..54946e7 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -341,7 +341,7 @@ impl TerrainWorld {
let mut surfaces = Vec::with_capacity(foundation.fragments.len());
let mut generated = 0_usize;
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
- if !valid_foundation_triangle(fragment.positions) {
+ if !valid_foundation_triangle(fragment.positions, fragment.source_face.is_none()) {
return Err(TerrainError::InvalidFoundationFragment {
fragment: fragment_index,
});
@@ -1031,7 +1031,7 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra
Ok(triangles)
}
-fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool {
+fn valid_foundation_triangle(vertices: [[f32; 3]; 3], allow_zero_xy_area: bool) -> bool {
if !vertices.iter().flatten().all(|value| value.is_finite()) {
return false;
}
@@ -1043,7 +1043,9 @@ fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool {
f64::from(vertices[2][0]) - f64::from(vertices[0][0]),
f64::from(vertices[2][1]) - f64::from(vertices[0][1]),
];
- first[0] * second[1] - first[1] * second[0] != 0.0
+ let cross = first[0] * second[1] - first[1] * second[0];
+ // Native vertex welding can collapse generated band faces; query math naturally misses zero area.
+ cross != 0.0 || allow_zero_xy_area
}
fn surface_vertex(
@@ -1270,6 +1272,49 @@ mod tests {
}
#[test]
+ fn surface_queries_use_lod0_and_reject_missing_node_metadata() {
+ let mut mesh = synthetic_land_mesh();
+ mesh.positions = vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 100.0],
+ [1.0, 0.0, 100.0],
+ [0.0, 1.0, 100.0],
+ ];
+ mesh.faces = vec![
+ terrain_face(FullSurfaceMask(1), [0, 1, 2]),
+ terrain_face(FullSurfaceMask(2), [3, 4, 5]),
+ ];
+ assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 1], [1, 1]]);
+
+ let world = TerrainWorld::from_land_msh(&mesh).expect("LOD0 terrain");
+ assert_eq!(world.surface_count(), 1);
+ assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.0));
+ assert_eq!(
+ world
+ .raycast([0.25, 0.25, 101.0], [0.0, 0.0, -1.0], FullSurfaceMask(2),)
+ .expect("LOD1-only mask"),
+ None
+ );
+
+ mesh.nodes_raw.clear();
+ assert!(matches!(
+ TerrainWorld::from_land_msh(&mesh),
+ Err(TerrainError::InvalidLodMapping(
+ fparkan_terrain_format::TerrainLodError::MissingNodes { face_count: 2 }
+ ))
+ ));
+ mesh.faces.clear();
+ assert_eq!(
+ TerrainWorld::from_land_msh(&mesh)
+ .expect("empty scene without Node38")
+ .surface_count(),
+ 0
+ );
+ }
+
+ #[test]
fn foundation_world_queries_use_the_canonical_cut_and_generated_band() {
use crate::{build_basement_foundations, BasementFootprint};
@@ -1365,7 +1410,55 @@ mod tests {
}
#[test]
- fn foundation_install_rejects_invalid_source_identity_masks_and_generated_flags() {
+ fn foundation_install_keeps_finite_welded_zero_area_band_for_queries() {
+ let mesh = synthetic_land_mesh();
+ let positions = [
+ [1644.398, 1118.5295, 132.54382],
+ [1644.4183, 1118.5342, 132.54755],
+ [1644.4183, 1118.5342, 132.54755],
+ ];
+ let foundation = BasementFoundation {
+ fragments: vec![BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions,
+ }],
+ placements: Vec::new(),
+ };
+ let world = TerrainWorld::from_land_msh(&mesh)
+ .expect("source world")
+ .with_basement_foundation(foundation)
+ .expect("finite generated fragment retained after native vertex welding");
+
+ assert_eq!(world.surface_count(), 1);
+ assert_eq!(
+ world
+ .basement_foundation()
+ .expect("installed foundation")
+ .fragments[0]
+ .positions,
+ positions
+ );
+ assert_eq!(
+ world
+ .height_at([positions[1][0], positions[1][1]])
+ .expect("degenerate height query"),
+ None
+ );
+ assert_eq!(
+ world
+ .raycast(
+ [positions[1][0], positions[1][1], 200.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0),
+ )
+ .expect("degenerate ray query"),
+ None
+ );
+ }
+
+ #[test]
+ fn foundation_install_rejects_invalid_source_identity_masks_flags_and_non_finite_geometry() {
let mesh = synthetic_land_mesh();
let source_triangle = [[0.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]];
let generated_triangle = [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, 0.25, 0.0]];
@@ -1385,6 +1478,11 @@ mod tests {
surface_mask: FullSurfaceMask(0x0300),
positions: generated_triangle,
},
+ BasementFoundationFragment {
+ source_face: None,
+ surface_mask: FullSurfaceMask(0x8300),
+ positions: [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, f32::NAN, 0.0]],
+ },
];
for (index, fragment) in invalid.into_iter().enumerate() {
@@ -1545,8 +1643,35 @@ mod tests {
.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 expected_lod0_faces = mesh
+ .face_lod_assignments()
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"))
+ .iter()
+ .filter(|assignment| assignment.lod == TerrainLod::Lod0)
+ .count();
let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ assert_eq!(
+ world.surface_count(),
+ expected_lod0_faces,
+ "{corpus} {path:?}"
+ );
+ if corpus == "IS" {
+ let map_name = path
+ .parent()
+ .and_then(Path::file_name)
+ .and_then(|name| name.to_str())
+ .map(str::to_ascii_lowercase);
+ let expected = match map_name.as_deref() {
+ Some("automap") => Some(1_912),
+ Some("prol") => Some(5_868),
+ Some("tut_1") => Some(5_046),
+ _ => None,
+ };
+ if let Some(expected) = expected {
+ assert_eq!(world.surface_count(), expected, "{corpus} {path:?}");
+ }
+ }
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
let triangle = &world.surfaces[0];
let point = [
@@ -1602,15 +1727,31 @@ mod tests {
aux18: Vec::new(),
faces: vec![face0, face1],
};
- let mut node = vec![u8::MAX; 38];
- node[8..10].copy_from_slice(&0_u16.to_le_bytes());
- let mut slot = [0_u8; 68];
- slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes());
- mesh.nodes_raw = node;
- mesh.slots.slots_raw.push(slot);
+ assign_terrain_lod_slots(&mut mesh, &[[0, u16::MAX]], &[[0, 2]]);
mesh
}
+ fn assign_terrain_lod_slots(
+ mesh: &mut LandMeshDocument,
+ node_slots: &[[u16; 2]],
+ face_ranges: &[[u16; 2]],
+ ) {
+ const NODE_STRIDE: usize = 38;
+ let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
+ for (node_index, [lod0, lod1]) in node_slots.iter().copied().enumerate() {
+ let offset = node_index * NODE_STRIDE + 8;
+ nodes[offset..offset + 2].copy_from_slice(&lod0.to_le_bytes());
+ nodes[offset + 2..offset + 4].copy_from_slice(&lod1.to_le_bytes());
+ }
+ let mut slots = vec![[0_u8; 68]; face_ranges.len()];
+ for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
+ slot[..2].copy_from_slice(&start.to_le_bytes());
+ slot[2..4].copy_from_slice(&count.to_le_bytes());
+ }
+ mesh.nodes_raw = nodes;
+ mesh.slots.slots_raw = slots;
+ }
+
fn terrain_face(
flags: FullSurfaceMask,
vertices: [u16; 3],