diff options
Diffstat (limited to 'crates/fparkan-terrain/src')
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 161 |
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], |
