diff options
Diffstat (limited to 'crates/fparkan-terrain')
| -rw-r--r-- | crates/fparkan-terrain/src/contour.rs | 809 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/foundation.rs | 1806 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 362 |
3 files changed, 2953 insertions, 24 deletions
diff --git a/crates/fparkan-terrain/src/contour.rs b/crates/fparkan-terrain/src/contour.rs new file mode 100644 index 0000000..de5077a --- /dev/null +++ b/crates/fparkan-terrain/src/contour.rs @@ -0,0 +1,809 @@ +/// One triangle fragment retained after subtracting a building contour. +/// `source_triangle` indexes the triangle array passed to +/// [`subtract_contour`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TerrainTriangleFragment { + /// Source triangle index, for preserving its material and vertex channels. + pub source_triangle: usize, + /// Fragment triangle vertices in the input coordinate space. + pub positions: [[f32; 3]; 3], +} + +/// Error returned when a terrain contour cannot be subtracted safely. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TerrainContourError { + /// The contour is non-finite, degenerate, or self-intersecting. + UnsupportedContour, + /// A source triangle has non-finite coordinates or a degenerate XY plane. + InvalidSourceTriangle { + /// Index of the source triangle. + triangle: usize, + }, +} + +impl std::fmt::Display for TerrainContourError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedContour => write!( + f, + "terrain contour must be finite, simple, and non-degenerate" + ), + Self::InvalidSourceTriangle { triangle } => { + write!( + f, + "terrain source triangle {triangle} is not a finite XY surface" + ) + } + } + } +} + +impl std::error::Error for TerrainContourError {} + +/// Subtracts a finite simple contour using the native per-source-face point set: +/// the three source vertices plus the actual contour segments clipped to that +/// source triangle. It does not extend cuts across the triangle or triangulate +/// the contour into unrelated internal edges. +/// +/// # Errors +/// +/// Returns [`TerrainContourError::UnsupportedContour`] for malformed or +/// self-intersecting contours, and +/// [`TerrainContourError::InvalidSourceTriangle`] when a source face cannot be +/// triangulated or lifted onto its original plane. +pub(crate) fn subtract_contour( + triangles: &[[[f32; 3]; 3]], + contour: &[[f32; 2]], +) -> Result<Vec<TerrainTriangleFragment>, TerrainContourError> { + let ring = normalize_ring(contour)?; + let contour_min: [f64; 2] = std::array::from_fn(|axis| { + ring.iter() + .map(|point| point[axis]) + .fold(f64::INFINITY, f64::min) + }); + let contour_max: [f64; 2] = std::array::from_fn(|axis| { + ring.iter() + .map(|point| point[axis]) + .fold(f64::NEG_INFINITY, f64::max) + }); + let mut output = Vec::new(); + + for (source_triangle, source) in triangles.iter().copied().enumerate() { + if !source.iter().flatten().all(|value| value.is_finite()) { + return Err(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }); + } + let source_xy = source.map(|point| [f64::from(point[0]), f64::from(point[1])]); + let source_area = cross2(source_xy[0], source_xy[1], source_xy[2]); + if source_area.abs() <= 1.0e-10 { + return Err(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }); + } + let source_min: [f64; 2] = std::array::from_fn(|axis| { + source_xy + .iter() + .map(|point| point[axis]) + .fold(f64::INFINITY, f64::min) + }); + let source_max: [f64; 2] = std::array::from_fn(|axis| { + source_xy + .iter() + .map(|point| point[axis]) + .fold(f64::NEG_INFINITY, f64::max) + }); + if (0..2).any(|axis| { + source_max[axis] < contour_min[axis] || contour_max[axis] < source_min[axis] + }) { + output.push(TerrainTriangleFragment { + source_triangle, + positions: source, + }); + continue; + } + + let mut vertices = source_xy.to_vec(); + let initial = if source_area > 0.0 { + [0, 1, 2] + } else { + [0, 2, 1] + }; + let mut triangulation = vec![initial]; + let mut clipped_segments = Vec::new(); + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + if let Some((start, end)) = clip_segment_to_triangle(first, second, source_xy) { + let start_id = insert_unique_point(&mut vertices, start); + let end_id = insert_unique_point(&mut vertices, end); + if start_id != end_id { + clipped_segments.push((start_id, end_id)); + } + } + } + + if clipped_segments.is_empty() { + let center = [ + (source_xy[0][0] + source_xy[1][0] + source_xy[2][0]) / 3.0, + (source_xy[0][1] + source_xy[1][1] + source_xy[2][1]) / 3.0, + ]; + if !point_in_ring(center, &ring) { + output.push(TerrainTriangleFragment { + source_triangle, + positions: source, + }); + } + continue; + } + + for point_id in 3..vertices.len() { + insert_triangulation_point(point_id, &vertices, &mut triangulation).map_err(|()| { + TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + } + })?; + } + + let mut constraints = source_boundary_constraints(&vertices, source_xy); + for (first, second) in clipped_segments { + let mut on_segment = vertices + .iter() + .copied() + .enumerate() + .filter_map(|(index, point)| { + point_on_segment(point, vertices[first], vertices[second]).then_some(( + segment_parameter(point, vertices[first], vertices[second]), + index, + )) + }) + .collect::<Vec<_>>(); + on_segment.sort_by(|left, right| left.0.total_cmp(&right.0)); + on_segment.dedup_by_key(|(_, index)| *index); + for pair in on_segment.windows(2) { + let edge = canonical_edge(pair[0].1, pair[1].1); + if edge.0 == edge.1 { + continue; + } + recover_constraint(edge, &mut triangulation, &vertices, &constraints).map_err( + |()| TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }, + )?; + constraints.insert(edge); + } + } + legalize_constrained_delaunay(&mut triangulation, &vertices, &constraints).map_err( + |()| TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + }, + )?; + + for triangle in triangulation { + let center = triangle.map(|index| vertices[index]); + let center = [ + (center[0][0] + center[1][0] + center[2][0]) / 3.0, + (center[0][1] + center[1][1] + center[2][1]) / 3.0, + ]; + if point_in_ring(center, &ring) { + continue; + } + let mut positions: [[f32; 3]; 3] = triangle + .map(|index| lift_to_source_plane(vertices[index], source)) + .into_iter() + .collect::<Option<Vec<_>>>() + .and_then(|points| points.try_into().ok()) + .ok_or(TerrainContourError::InvalidSourceTriangle { + triangle: source_triangle, + })?; + if source_area < 0.0 { + positions.swap(1, 2); + } + output.push(TerrainTriangleFragment { + source_triangle, + positions, + }); + } + } + Ok(output) +} + +fn normalize_ring(contour: &[[f32; 2]]) -> Result<Vec<[f64; 2]>, TerrainContourError> { + let mut ring = contour + .iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + if ring.iter().flatten().any(|value| !value.is_finite()) { + return Err(TerrainContourError::UnsupportedContour); + } + if ring.len() > 1 && distance_squared(ring[0], *ring.last().expect("nonempty")) <= 1.0e-16 { + ring.pop(); + } + let mut normalized = Vec::with_capacity(ring.len()); + for point in ring { + if normalized + .last() + .is_none_or(|previous| distance_squared(*previous, point) > 1.0e-16) + { + normalized.push(point); + } + } + if normalized.len() > 1 + && distance_squared(normalized[0], *normalized.last().expect("nonempty")) <= 1.0e-16 + { + normalized.pop(); + } + if normalized.len() < 3 { + return Err(TerrainContourError::UnsupportedContour); + } + let signed_area = polygon_area(&normalized); + if !signed_area.is_finite() || signed_area.abs() <= 1.0e-10 { + return Err(TerrainContourError::UnsupportedContour); + } + for first in 0..normalized.len() { + let first_next = (first + 1) % normalized.len(); + for second in (first + 1)..normalized.len() { + let second_next = (second + 1) % normalized.len(); + if first_next == second || second_next == first { + continue; + } + if segments_intersect( + normalized[first], + normalized[first_next], + normalized[second], + normalized[second_next], + ) { + return Err(TerrainContourError::UnsupportedContour); + } + } + } + if signed_area < 0.0 { + normalized.reverse(); + } + Ok(normalized) +} + +fn clip_segment_to_triangle( + first: [f64; 2], + second: [f64; 2], + triangle: [[f64; 2]; 3], +) -> Option<([f64; 2], [f64; 2])> { + let mut triangle = triangle; + if cross2(triangle[0], triangle[1], triangle[2]) < 0.0 { + triangle.swap(1, 2); + } + let mut start = 0.0_f64; + let mut end = 1.0_f64; + for edge in 0..3 { + let edge_first = triangle[edge]; + let edge_second = triangle[(edge + 1) % 3]; + let at_start = cross2(edge_first, edge_second, first); + let at_end = cross2(edge_first, edge_second, second); + let change = at_end - at_start; + if change.abs() <= 1.0e-14 { + if at_start < -1.0e-10 { + return None; + } + continue; + } + let crossing = -at_start / change; + if change > 0.0 { + start = start.max(crossing); + } else { + end = end.min(crossing); + } + if start > end + 1.0e-12 { + return None; + } + } + start = start.clamp(0.0, 1.0); + end = end.clamp(0.0, 1.0); + if end - start <= 1.0e-12 { + return None; + } + Some(( + interpolate(first, second, start), + interpolate(first, second, end), + )) +} + +fn insert_unique_point(vertices: &mut Vec<[f64; 2]>, point: [f64; 2]) -> usize { + // Terrain positions are f32 throughout the native data and renderer. Make + // clipped crossings use that same representable coordinate before building + // the local CDT, so distinct f64 intersections cannot collapse to one XY + // vertex only after fragment emission. + let point = [f64::from(point[0] as f32), f64::from(point[1] as f32)]; + if let Some(index) = vertices + .iter() + .position(|candidate| distance_squared(*candidate, point) <= 1.0e-18) + { + return index; + } + let index = vertices.len(); + vertices.push(point); + index +} + +fn insert_triangulation_point( + point_id: usize, + vertices: &[[f64; 2]], + triangles: &mut Vec<[usize; 3]>, +) -> Result<(), ()> { + let point = vertices[point_id]; + let edge = triangulation_edges(triangles) + .keys() + .copied() + .find(|&(first, second)| point_on_open_segment(point, vertices[first], vertices[second])); + if let Some(edge) = edge { + let mut split = Vec::new(); + triangles.retain(|triangle| { + if triangle_has_edge(*triangle, edge) { + let opposite = triangle + .iter() + .copied() + .find(|index| *index != edge.0 && *index != edge.1) + .expect("triangle has opposite vertex"); + split.push(opposite); + false + } else { + true + } + }); + if split.is_empty() { + return Err(()); + } + for opposite in split { + let first = make_ccw([edge.0, point_id, opposite], vertices).ok_or(())?; + let second = make_ccw([point_id, edge.1, opposite], vertices).ok_or(())?; + triangles.extend([first, second]); + } + return Ok(()); + } + + let containing = triangles.iter().position(|triangle| { + point_in_triangle( + point, + vertices[triangle[0]], + vertices[triangle[1]], + vertices[triangle[2]], + ) + }); + let Some(containing) = containing else { + return Err(()); + }; + let triangle = triangles.swap_remove(containing); + for edge in 0..3 { + let split = make_ccw( + [triangle[edge], triangle[(edge + 1) % 3], point_id], + vertices, + ) + .ok_or(())?; + triangles.push(split); + } + Ok(()) +} + +fn source_boundary_constraints( + vertices: &[[f64; 2]], + triangle: [[f64; 2]; 3], +) -> std::collections::BTreeSet<(usize, usize)> { + let mut constraints = std::collections::BTreeSet::new(); + for edge in 0..3 { + let first = triangle[edge]; + let second = triangle[(edge + 1) % 3]; + let mut points = vertices + .iter() + .copied() + .enumerate() + .filter_map(|(index, point)| { + point_on_segment(point, first, second) + .then_some((segment_parameter(point, first, second), index)) + }) + .collect::<Vec<_>>(); + points.sort_by(|left, right| left.0.total_cmp(&right.0)); + points.dedup_by_key(|(_, index)| *index); + for pair in points.windows(2) { + constraints.insert(canonical_edge(pair[0].1, pair[1].1)); + } + } + constraints +} + +fn recover_constraint( + target: (usize, usize), + triangles: &mut [[usize; 3]], + vertices: &[[f64; 2]], + constraints: &std::collections::BTreeSet<(usize, usize)>, +) -> Result<(), ()> { + let limit = vertices + .len() + .saturating_mul(vertices.len()) + .saturating_mul(8) + .max(1); + for _ in 0..limit { + if edge_exists(triangles, target) { + return Ok(()); + } + let mut crossed = triangulation_edges(triangles) + .into_iter() + .filter_map(|(edge, adjacent)| { + if adjacent.len() != 2 + || edge.0 == target.0 + || edge.0 == target.1 + || edge.1 == target.0 + || edge.1 == target.1 + { + return None; + } + proper_intersection_parameter( + vertices[target.0], + vertices[target.1], + vertices[edge.0], + vertices[edge.1], + ) + .map(|parameter| (parameter, edge, adjacent)) + }) + .collect::<Vec<_>>(); + crossed.sort_by(|left, right| left.0.total_cmp(&right.0)); + let mut flipped = false; + for (_, edge, adjacent) in crossed { + if constraints.contains(&edge) { + return Err(()); + } + let first_index = adjacent[0].0; + let second_index = adjacent[1].0; + let first_opposite = adjacent[0].1; + let second_opposite = adjacent[1].1; + if !convex_quadrilateral( + vertices[edge.0], + vertices[edge.1], + vertices[first_opposite], + vertices[second_opposite], + ) { + continue; + } + let replacement = canonical_edge(first_opposite, second_opposite); + if constraints.contains(&replacement) + || proper_intersection_parameter( + vertices[target.0], + vertices[target.1], + vertices[replacement.0], + vertices[replacement.1], + ) + .is_some() + { + continue; + } + let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?; + triangles[first_index] = + make_ccw([first_opposite, second_opposite, b], vertices).ok_or(())?; + triangles[second_index] = + make_ccw([second_opposite, first_opposite, a], vertices).ok_or(())?; + flipped = true; + break; + } + if !flipped { + return Err(()); + } + } + Err(()) +} + +fn legalize_constrained_delaunay( + triangles: &mut [[usize; 3]], + vertices: &[[f64; 2]], + constraints: &std::collections::BTreeSet<(usize, usize)>, +) -> Result<(), ()> { + let limit = triangles + .len() + .saturating_mul(triangles.len()) + .saturating_mul(8) + .max(1); + for _ in 0..limit { + let edges = triangulation_edges(triangles); + let mut flipped = false; + for (edge, adjacent) in edges { + if adjacent.len() != 2 || constraints.contains(&edge) { + continue; + } + let first_index = adjacent[0].0; + let second_index = adjacent[1].0; + let c = adjacent[0].1; + let d = adjacent[1].1; + if !convex_quadrilateral(vertices[edge.0], vertices[edge.1], vertices[c], vertices[d]) { + continue; + } + let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?; + if in_circle(vertices[a], vertices[b], vertices[c], vertices[d]) <= 1.0e-10 { + continue; + } + let replacement = canonical_edge(c, d); + if constraints.iter().any(|constraint| { + proper_intersection_parameter( + vertices[replacement.0], + vertices[replacement.1], + vertices[constraint.0], + vertices[constraint.1], + ) + .is_some() + }) { + continue; + } + triangles[first_index] = make_ccw([c, d, b], vertices).ok_or(())?; + triangles[second_index] = make_ccw([d, c, a], vertices).ok_or(())?; + flipped = true; + break; + } + if !flipped { + return Ok(()); + } + } + Err(()) +} + +fn triangulation_edges( + triangles: &[[usize; 3]], +) -> std::collections::BTreeMap<(usize, usize), Vec<(usize, usize)>> { + let mut edges = std::collections::BTreeMap::new(); + for (triangle_index, triangle) in triangles.iter().copied().enumerate() { + for edge in 0..3 { + let first = triangle[edge]; + let second = triangle[(edge + 1) % 3]; + let opposite = triangle[(edge + 2) % 3]; + edges + .entry(canonical_edge(first, second)) + .or_insert_with(Vec::new) + .push((triangle_index, opposite)); + } + } + edges +} + +fn triangle_has_edge(triangle: [usize; 3], edge: (usize, usize)) -> bool { + triangle.contains(&edge.0) && triangle.contains(&edge.1) +} + +fn edge_exists(triangles: &[[usize; 3]], edge: (usize, usize)) -> bool { + triangles + .iter() + .copied() + .any(|triangle| triangle_has_edge(triangle, edge)) +} + +fn directed_edge(triangle: [usize; 3], edge: (usize, usize)) -> Option<(usize, usize)> { + (0..3).find_map(|index| { + let first = triangle[index]; + let second = triangle[(index + 1) % 3]; + (canonical_edge(first, second) == edge).then_some((first, second)) + }) +} + +fn canonical_edge(first: usize, second: usize) -> (usize, usize) { + if first < second { + (first, second) + } else { + (second, first) + } +} + +fn make_ccw(triangle: [usize; 3], vertices: &[[f64; 2]]) -> Option<[usize; 3]> { + let area = cross2( + vertices[triangle[0]], + vertices[triangle[1]], + vertices[triangle[2]], + ); + if !area.is_finite() || area.abs() <= 1.0e-12 { + return None; + } + Some(if area > 0.0 { + triangle + } else { + [triangle[0], triangle[2], triangle[1]] + }) +} + +fn point_in_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool { + let edges = [(a, b), (b, c), (c, a)]; + edges.into_iter().all(|(first, second)| { + let scale = distance_squared(first, second).sqrt(); + cross2(first, second, point) >= -linear_tolerance(first, second, point) * scale + }) +} + +fn point_on_open_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + if !point_on_segment(point, first, second) { + return false; + } + let t = segment_parameter(point, first, second); + t > 1.0e-9 && t < 1.0 - 1.0e-9 +} + +fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + let length_squared = distance_squared(first, second); + if length_squared <= 1.0e-20 { + return distance_squared(point, first) <= 1.0e-18; + } + let cross = cross2(first, second, point).abs(); + let dot = (point[0] - first[0]) * (second[0] - first[0]) + + (point[1] - first[1]) * (second[1] - first[1]); + let tolerance = linear_tolerance(first, second, point); + let length = length_squared.sqrt(); + cross <= tolerance * length + && dot >= -tolerance * length + && dot <= length_squared + tolerance * length +} + +fn linear_tolerance(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 { + let coordinate_scale = first + .into_iter() + .chain(second) + .chain(point) + .map(f64::abs) + .fold(1.0_f64, f64::max); + 2.0 * f64::from(f32::EPSILON) * coordinate_scale +} + +fn segment_parameter(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> f64 { + let delta = [second[0] - first[0], second[1] - first[1]]; + let length_squared = delta[0] * delta[0] + delta[1] * delta[1]; + if length_squared <= 1.0e-20 { + 0.0 + } else { + ((point[0] - first[0]) * delta[0] + (point[1] - first[1]) * delta[1]) / length_squared + } +} + +fn proper_intersection_parameter( + first: [f64; 2], + second: [f64; 2], + edge_first: [f64; 2], + edge_second: [f64; 2], +) -> Option<f64> { + let first_delta = [second[0] - first[0], second[1] - first[1]]; + let edge_delta = [ + edge_second[0] - edge_first[0], + edge_second[1] - edge_first[1], + ]; + let denominator = first_delta[0] * edge_delta[1] - first_delta[1] * edge_delta[0]; + if denominator.abs() <= 1.0e-12 { + return None; + } + let offset = [edge_first[0] - first[0], edge_first[1] - first[1]]; + let t = (offset[0] * edge_delta[1] - offset[1] * edge_delta[0]) / denominator; + let u = (offset[0] * first_delta[1] - offset[1] * first_delta[0]) / denominator; + (t > 1.0e-9 && t < 1.0 - 1.0e-9 && u > 1.0e-9 && u < 1.0 - 1.0e-9).then_some(t) +} + +fn convex_quadrilateral(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + cross2(a, b, c) * cross2(a, b, d) < -1.0e-12 && cross2(c, d, a) * cross2(c, d, b) < -1.0e-12 +} + +fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 { + let a = [a[0] - d[0], a[1] - d[1]]; + let b = [b[0] - d[0], b[1] - d[1]]; + let c = [c[0] - d[0], c[1] - d[1]]; + let a2 = a[0] * a[0] + a[1] * a[1]; + let b2 = b[0] * b[0] + b[1] * b[1]; + let c2 = c[0] * c[0] + c[1] * c[1]; + a2 * (b[0] * c[1] - b[1] * c[0]) - b2 * (a[0] * c[1] - a[1] * c[0]) + + c2 * (a[0] * b[1] - a[1] * b[0]) +} + +fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let ab_c = cross2(a, b, c); + let ab_d = cross2(a, b, d); + let cd_a = cross2(c, d, a); + let cd_b = cross2(c, d, b); + let eps = 1.0e-8; + (ab_c.abs() <= eps && point_on_segment(c, a, b)) + || (ab_d.abs() <= eps && point_on_segment(d, a, b)) + || (cd_a.abs() <= eps && point_on_segment(a, c, d)) + || (cd_b.abs() <= eps && point_on_segment(b, c, d)) + || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0) +} + +fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut winding = 0_i32; + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + let side = cross2(first, second, point); + if first[1] <= point[1] { + if second[1] > point[1] && side > 0.0 { + winding += 1; + } + } else if second[1] <= point[1] && side < 0.0 { + winding -= 1; + } + } + winding != 0 +} + +fn polygon_area(points: &[[f64; 2]]) -> f64 { + (0..points.len()) + .map(|index| { + let first = points[index]; + let second = points[(index + 1) % points.len()]; + first[0] * second[1] - second[0] * first[1] + }) + .sum::<f64>() + * 0.5 +} + +fn lift_to_source_plane(point: [f64; 2], source: [[f32; 3]; 3]) -> Option<[f32; 3]> { + for vertex in source { + if distance_squared(point, [f64::from(vertex[0]), f64::from(vertex[1])]) <= 1.0e-18 { + return Some(vertex); + } + } + let a = source[0].map(f64::from); + let b = source[1].map(f64::from); + let c = source[2].map(f64::from); + let denominator = cross2([a[0], a[1]], [b[0], b[1]], [c[0], c[1]]); + if denominator.abs() <= 1.0e-10 { + return None; + } + let second_weight = cross2([a[0], a[1]], point, [c[0], c[1]]) / denominator; + let third_weight = cross2([a[0], a[1]], [b[0], b[1]], point) / denominator; + let first_weight = 1.0 - second_weight - third_weight; + let z = first_weight * a[2] + second_weight * b[2] + third_weight * c[2]; + let position = [point[0] as f32, point[1] as f32, z as f32]; + position + .iter() + .all(|value| value.is_finite()) + .then_some(position) +} + +fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 { + (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0]) +} + +fn interpolate(first: [f64; 2], second: [f64; 2], amount: f64) -> [f64; 2] { + [ + first[0] + (second[0] - first[0]) * amount, + first[1] + (second[1] - first[1]) * amount, + ] +} + +fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 { + let delta_x = first[0] - second[0]; + let delta_y = first[1] - second[1]; + delta_x * delta_x + delta_y * delta_y +} + +#[cfg(test)] +mod tests { + use super::subtract_contour; + + #[test] + fn translated_thin_outside_strip_keeps_its_area() { + let base = 10_000.0_f32; + let source = [[ + [base, base, 0.0], + [base + 10.0, base, 0.0], + [base, base + 10.0, 0.0], + ]]; + let outer = [ + [base + 0.003, base + 0.003], + [base + 1.0, base + 0.003], + [base + 1.0, base + 0.997], + [base + 0.003, base + 0.997], + ]; + let output = subtract_contour(&source, &outer).expect("simple contour"); + let retained_area = output + .iter() + .map(|fragment| { + let [a, b, c] = fragment.positions; + let ab = [f64::from(b[0] - a[0]), f64::from(b[1] - a[1])]; + let ac = [f64::from(c[0] - a[0]), f64::from(c[1] - a[1])]; + (ab[0] * ac[1] - ab[1] * ac[0]).abs() * 0.5 + }) + .sum::<f64>(); + let expected_area = + 50.0 - (f64::from(outer[1][0] - outer[0][0]) * f64::from(outer[2][1] - outer[1][1])); + assert!( + (retained_area - expected_area).abs() < 0.001, + "retained area {retained_area}, expected {expected_area}" + ); + } +} diff --git a/crates/fparkan-terrain/src/foundation.rs b/crates/fparkan-terrain/src/foundation.rs new file mode 100644 index 0000000..594bbfe --- /dev/null +++ b/crates/fparkan-terrain/src/foundation.rs @@ -0,0 +1,1806 @@ +//! Native-shaped building basement foundation geometry. + +use crate::contour::subtract_contour; +use fparkan_terrain_format::{FullSurfaceMask, LandMeshDocument, TerrainLod}; + +const LINE_EPSILON: f64 = 1.0e-5; +const POINT_EPSILON_SQUARED: f64 = 1.0e-12; + +/// A terrain or generated basement triangle shared by preview consumers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BasementFoundationFragment { + /// Original Land.msh face for terrain fragments; `None` for generated band faces. + pub source_face: Option<usize>, + /// Raw terrain or generated basement face flags. + pub surface_mask: FullSurfaceMask, + /// Triangle coordinates shared by rendering, shadows, and terrain queries. + pub positions: [[f32; 3]; 3], +} + +/// Canonical output of the native-shaped terrain basement preparation. +#[derive(Clone, Debug, PartialEq)] +pub struct BasementFoundation { + pub fragments: Vec<BasementFoundationFragment>, + /// Native placement-height inputs in footprint order. + pub placements: Vec<BasementPlacement>, +} + +/// One building footprint in native preparation order. +#[derive(Clone, Copy, Debug)] +pub struct BasementFootprint<'a> { + /// Authored outside ring in world XY coordinates. + pub outer: &'a [[f32; 2]], + /// Authored inner ring, retaining its source Z for placement calculations. + pub inner: &'a [[f32; 3]], + /// Whether native getter mode flattens the generated inner boundary to mean outer Z. + pub flatten_inner_to_mean_outer_z: bool, +} + +/// Native placement-height inputs captured for one building. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BasementPlacement { + /// Native transform adjustment: first authored inner Z minus mean sampled outer terrain Z. + pub placement_delta: f32, +} + +/// Error returned while preparing a basement foundation. +#[derive(Clone, Debug, PartialEq)] +pub enum BasementFoundationError { + /// Terrain Node38/Slot68 data cannot resolve LOD0 faces. + InvalidLodMapping(fparkan_terrain_format::TerrainLodError), + /// A selected LOD0 face refers to a missing vertex. + InvalidTerrainFace { face: usize, vertex: u16 }, + /// A contour, nested-ring relationship, or generated annulus is invalid. + InvalidContour, + /// The LOD0 mesh has no usable terrain faces. + EmptyLod0, + /// The outer contour did not cross any LOD0 face. + NoOuterCrossings, + /// A point of the authored outer contour lies outside the LOD0 terrain. + OuterPointOutsideLod0 { point: usize }, + /// An authored outer segment leaves the current terrain surface. + UncoveredOuterSegment { segment: usize }, + /// Footprint outer contours overlap; native overlap semantics remain unverified. + OverlappingFootprints, +} + +impl std::fmt::Display for BasementFoundationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), + Self::InvalidTerrainFace { face, vertex } => { + write!(f, "terrain face {face} references missing vertex {vertex}") + } + Self::InvalidContour => write!(f, "invalid basement contour geometry"), + Self::EmptyLod0 => write!(f, "terrain has no LOD0 faces"), + Self::NoOuterCrossings => write!(f, "outer contour does not cross LOD0 terrain"), + Self::OuterPointOutsideLod0 { point } => { + write!(f, "outer contour point {point} is outside LOD0 terrain") + } + Self::UncoveredOuterSegment { segment } => { + write!( + f, + "outer contour segment {segment} leaves the terrain surface" + ) + } + Self::OverlappingFootprints => { + write!(f, "overlapping basement footprints are not supported") + } + } + } +} + +impl std::error::Error for BasementFoundationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidLodMapping(error) => Some(error), + Self::InvalidTerrainFace { .. } + | Self::InvalidContour + | Self::EmptyLod0 + | Self::NoOuterCrossings => None, + Self::OuterPointOutsideLod0 { .. } | Self::UncoveredOuterSegment { .. } => None, + Self::OverlappingFootprints => None, + } + } +} + +/// Строит основания ландшафта в порядке нативной подготовки размещений. +/// +/// Вершины внешнего контура нормализуются по топологии LOD0: сначала проверяются +/// вершины граней и соседних граней, затем применяется проекция, используемая +/// нативным `FUN_10006240`. Это косинусная формула с отдельной проверкой +/// расстояния, а не обычная ортогональная проекция. При обходе сегмента +/// вставляются только вершины LOD0, прошедшие тот же нативный предикат и +/// находящиеся не ближе 0.5 мировых единиц к обоим концам; геометрическое +/// совпадение с отрезком само по себе недостаточно. +/// +/// Для каждого контура функция упорядочивает пересечения с плоскостями текущих +/// канонических треугольников, вычисляет среднюю высоту внешней границы и строит +/// ограниченную триангуляцию кольца. Явный флаг выбирает нативную ветвь высоты +/// внутренней границы. Непересекающиеся контуры последовательно вырезаются из +/// текущей поверхности; пересекающиеся контуры отклоняются, поскольку их +/// взаимодействие в нативном коде пока не установлено. +/// +/// Возвращённый набор фрагментов является общей геометрией для рендеринга, +/// теней и запросов поверхности. Фрагменты исходной поверхности сохраняют +/// номера и маски исходных граней; у сгенерированных граней маска соответствует +/// записи `AddBasementFaceProc`. +/// +/// The returned fragment vector is the canonical geometry for rendering, +/// shadows, and terrain queries. Source faces preserve their original face IDs +/// and masks. Generated faces use the raw mask written by AddBasementFaceProc. +pub fn build_basement_foundations( + mesh: &LandMeshDocument, + footprints: &[BasementFootprint<'_>], +) -> Result<BasementFoundation, BasementFoundationError> { + let assignments = mesh + .face_lod_assignments() + .map_err(BasementFoundationError::InvalidLodMapping)?; + let mut lod0_face_indices = Vec::new(); + let mut terrain = Vec::new(); + for assignment in assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + { + let face = &mesh.faces[assignment.face_index]; + let positions = terrain_face_positions(mesh, assignment.face_index)?; + terrain.push(BasementFoundationFragment { + source_face: Some(assignment.face_index), + surface_mask: face.flags, + positions, + }); + lod0_face_indices.push(assignment.face_index); + } + if terrain.is_empty() { + return Err(BasementFoundationError::EmptyLod0); + } + + let mut bands = Vec::new(); + let mut placements = Vec::with_capacity(footprints.len()); + let mut previous_outer_rings: Vec<Vec<[f64; 2]>> = Vec::with_capacity(footprints.len()); + for footprint in footprints { + let (outer_points, outer_closed) = normalize_ring(footprint.outer)?; + let snapped_outer = normalize_outer_points(&outer_points, &lod0_face_indices, mesh)?; + if !simple_ring(&snapped_outer) { + return Err(BasementFoundationError::InvalidContour); + } + if previous_outer_rings + .iter() + .any(|previous| rings_overlap(previous, &snapped_outer)) + { + return Err(BasementFoundationError::OverlappingFootprints); + } + previous_outer_rings.push(snapped_outer.clone()); + let mut outer_ring = snapped_outer + .iter() + .map(|point| [point[0] as f32, point[1] as f32]) + .collect::<Vec<_>>(); + if outer_closed { + outer_ring.push(outer_ring[0]); + } + let samples = terrain + .iter() + .map(|fragment| { + ( + fragment + .source_face + .expect("terrain fragments have source faces"), + fragment.surface_mask, + fragment.positions, + ) + }) + .collect::<Vec<_>>(); + let mut outer_crossings = contour_crossings(&snapped_outer, &samples)?; + if outer_crossings.is_empty() { + return Err(BasementFoundationError::NoOuterCrossings); + } + // Crossings are open here; restore the authored duplicate only afterward. + let mean_outer_z = outer_crossings + .iter() + .map(|crossing| f64::from(crossing[2])) + .sum::<f64>() + / outer_crossings.len() as f64; + if !mean_outer_z.is_finite() { + return Err(BasementFoundationError::InvalidContour); + } + let mean_outer_z = mean_outer_z as f32; + if outer_closed { + let closing = *outer_crossings + .first() + .ok_or(BasementFoundationError::NoOuterCrossings)?; + outer_crossings.push(closing); + } + + let inner_xy = footprint + .inner + .iter() + .map(|point| [point[0], point[1]]) + .collect::<Vec<_>>(); + let (inner_points, inner_closed) = normalize_ring(&inner_xy)?; + if footprint + .inner + .iter() + .flatten() + .any(|value| !value.is_finite()) + || !nested_rings(&snapped_outer, &inner_points) + { + return Err(BasementFoundationError::InvalidContour); + } + let authored_inner_z = footprint + .inner + .first() + .ok_or(BasementFoundationError::InvalidContour)?[2]; + let placement_delta = authored_inner_z - mean_outer_z; + if !placement_delta.is_finite() { + return Err(BasementFoundationError::InvalidContour); + } + placements.push(BasementPlacement { placement_delta }); + let inner_vertex_count = if inner_closed { + footprint.inner.len() - 1 + } else { + footprint.inner.len() + }; + let mut inner_ring = footprint.inner[..inner_vertex_count] + .iter() + .map(|point| { + [ + point[0], + point[1], + if footprint.flatten_inner_to_mean_outer_z { + mean_outer_z + } else { + point[2] + }, + ] + }) + .collect::<Vec<_>>(); + if inner_closed { + inner_ring.push(inner_ring[0]); + } + let outer_surface = crossings_as_ring(&outer_crossings, outer_closed)?; + let inner_surface = inner_ring_without_closure(&inner_ring); + for positions in triangulate_annulus(&outer_surface, inner_surface)? { + bands.push(BasementFoundationFragment { + source_face: None, + surface_mask: basement_surface_mask(positions), + positions, + }); + } + + let source_positions = terrain + .iter() + .map(|fragment| fragment.positions) + .collect::<Vec<_>>(); + let outside = subtract_contour(&source_positions, &outer_ring) + .map_err(|_| BasementFoundationError::InvalidContour)?; + let clipped = outside + .into_iter() + .map(|fragment| { + let source = terrain[fragment.source_triangle]; + BasementFoundationFragment { + source_face: source.source_face, + surface_mask: source.surface_mask, + positions: fragment.positions, + } + }) + .collect(); + terrain = clipped; + } + terrain.extend(bands); + Ok(BasementFoundation { + fragments: terrain, + placements, + }) +} + +fn terrain_face_positions( + mesh: &LandMeshDocument, + face_index: usize, +) -> Result<[[f32; 3]; 3], BasementFoundationError> { + let face = &mesh.faces[face_index]; + let resolve = |vertex: u16| { + mesh.positions.get(usize::from(vertex)).copied().ok_or( + BasementFoundationError::InvalidTerrainFace { + face: face_index, + vertex, + }, + ) + }; + Ok([ + resolve(face.vertices[0])?, + resolve(face.vertices[1])?, + resolve(face.vertices[2])?, + ]) +} + +fn normalize_ring(ring: &[[f32; 2]]) -> Result<(Vec<[f64; 2]>, bool), BasementFoundationError> { + if ring.iter().flatten().any(|value| !value.is_finite()) { + return Err(BasementFoundationError::InvalidContour); + } + let mut points = ring + .iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + let closed = points.len() > 1 + && distance_squared(points[0], *points.last().expect("nonempty")) <= POINT_EPSILON_SQUARED; + if closed { + points.pop(); + } + if points.len() < 3 || !simple_ring(&points) { + return Err(BasementFoundationError::InvalidContour); + } + Ok((points, closed)) +} + +fn normalize_outer_points( + points: &[[f64; 2]], + lod0_faces: &[usize], + mesh: &LandMeshDocument, +) -> Result<Vec<[f64; 2]>, BasementFoundationError> { + // shortcut: first-face lookup and neighbor ordering still use source LOD0 + // IDs for every placement. The crossing sampler and cutter use the current + // canonical fragment list, but dynamic neighbor-ID reconstruction is not + // modeled yet, so later placements are not claimed as native-identical. + let mut output = points + .iter() + .enumerate() + .map(|(point_index, point)| { + native_normalize_outer_point( + mesh, + lod0_faces, + [point[0] as f32, point[1] as f32], + point_index, + ) + }) + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect::<Vec<_>>(); + + // The native contour pass asks for terrain faces overlapping the current + // segment's AABB, then tries their three vertices in face order. On one + // accepted vertex it inserts the full terrain point and resumes from the + // shortened segment at the same index. + let mut segment_index = 0; + while segment_index < output.len() { + let first = output[segment_index].map(|value| value as f32); + let second = output[(segment_index + 1) % output.len()].map(|value| value as f32); + let min = [first[0].min(second[0]), first[1].min(second[1])]; + let max = [first[0].max(second[0]), first[1].max(second[1])]; + let mut inserted = false; + 'faces: for &face_index in lod0_faces { + let Some(face) = mesh.faces.get(face_index) else { + continue; + }; + let triangle = face + .vertices + .map(|vertex| mesh.positions.get(usize::from(vertex)).copied()); + let [Some(a), Some(b), Some(c)] = triangle else { + continue; + }; + let face_min = [a[0].min(b[0]).min(c[0]), a[1].min(b[1]).min(c[1])]; + let face_max = [a[0].max(b[0]).max(c[0]), a[1].max(b[1]).max(c[1])]; + if (0..2).any(|axis| face_max[axis] < min[axis] || max[axis] < face_min[axis]) { + continue; + } + for vertex in [a, b, c] { + if !native_segment_inserts_vertex(first, second, vertex) { + continue; + } + let vertex_xy = [f64::from(vertex[0]), f64::from(vertex[1])]; + if output + .iter() + .any(|existing| distance_squared(*existing, vertex_xy) <= POINT_EPSILON_SQUARED) + { + continue; + } + output.insert(segment_index + 1, vertex_xy); + inserted = true; + break 'faces; + } + } + if !inserted { + segment_index += 1; + } + } + Ok(output) +} + +fn native_normalize_outer_point( + mesh: &LandMeshDocument, + lod0_faces: &[usize], + point: [f32; 2], + point_index: usize, +) -> Result<[f32; 2], BasementFoundationError> { + let containing_face = lod0_faces.iter().copied().find(|&face_index| { + let Some(face) = mesh.faces.get(face_index) else { + return false; + }; + let triangle = face + .vertices + .map(|vertex| mesh.positions[usize::from(vertex)]); + point_in_triangle_xy(point, triangle) + }); + let containing_face = containing_face + .ok_or(BasementFoundationError::OuterPointOutsideLod0 { point: point_index })?; + let mut candidates = vec![containing_face]; + for neighbor in mesh.faces[containing_face].neighbors.iter().flatten() { + let neighbor = usize::from(*neighbor); + if lod0_faces.contains(&neighbor) && !candidates.contains(&neighbor) { + candidates.push(neighbor); + } + } + let normalized = point; + for face_index in candidates { + let face = &mesh.faces[face_index]; + let vertices = face + .vertices + .map(|vertex| mesh.positions.get(usize::from(vertex)).copied()); + let [Some(a), Some(b), Some(c)] = vertices else { + return Err(BasementFoundationError::InvalidTerrainFace { + face: face_index, + vertex: face.vertices[0], + }); + }; + let vertices = [a, b, c]; + for (first, second) in [(0, 1), (1, 2), (2, 0)] { + if let Some(candidate) = + native_normalize_edge(normalized, vertices[first], vertices[second])? + { + return Ok(candidate); + } + } + } + Ok(normalized) +} + +fn point_in_triangle_xy(point: [f32; 2], triangle: [[f32; 3]; 3]) -> bool { + let sign = |a: [f32; 3], b: [f32; 3]| { + (point[0] - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point[1] - b[1]) + }; + let d1 = sign(triangle[0], triangle[1]); + let d2 = sign(triangle[1], triangle[2]); + let d3 = sign(triangle[2], triangle[0]); + let has_negative = d1 < -1.0e-4 || d2 < -1.0e-4 || d3 < -1.0e-4; + let has_positive = d1 > 1.0e-4 || d2 > 1.0e-4 || d3 > 1.0e-4; + !(has_negative && has_positive) +} + +fn native_normalize_edge( + point: [f32; 2], + first: [f32; 3], + second: [f32; 3], +) -> Result<Option<[f32; 2]>, BasementFoundationError> { + if distance_xy(point, [first[0], first[1]]) < 0.5 { + let point = [first[0], first[1]]; + return Ok(Some(point)); + } + if distance_xy(point, [second[0], second[1]]) < 0.5 { + let point = [second[0], second[1]]; + return Ok(Some(point)); + } + let Some((offset, _)) = native_edge_projection(point, first, second) else { + return Ok(None); + }; + let mut projected = [point[0] + offset[0], point[1] + offset[1]]; + let Some((check_offset, _)) = native_edge_projection(projected, first, second) else { + return Err(BasementFoundationError::InvalidContour); + }; + if check_offset[0].hypot(check_offset[1]) >= 0.0001 { + return Err(BasementFoundationError::InvalidContour); + } + if distance_xy(projected, [first[0], first[1]]) < 0.5 { + projected = [first[0], first[1]]; + } + if distance_xy(projected, [second[0], second[1]]) < 0.5 { + projected = [second[0], second[1]]; + } + Ok(Some(projected)) +} + +fn native_segment_inserts_vertex(first: [f32; 2], second: [f32; 2], point: [f32; 3]) -> bool { + if distance_xy([point[0], point[1]], first) < 0.5 + || distance_xy([point[0], point[1]], second) < 0.5 + { + return false; + } + native_edge_projection( + [point[0], point[1]], + [first[0], first[1], 0.0], + [second[0], second[1], 0.0], + ) + .is_some() +} + +fn native_edge_projection( + point: [f32; 2], + first: [f32; 3], + second: [f32; 3], +) -> Option<([f32; 2], f32)> { + let ux = point[0] - first[0]; + let uy = point[1] - first[1]; + let ex = second[0] - first[0]; + let ey = second[1] - first[1]; + let edge_length = (ex * ex + ey * ey).sqrt(); + let point_length = (ux * ux + uy * uy).sqrt(); + if edge_length == 0.0 || point_length == 0.0 { + return None; + } + let t = ((ex * ux + ey * uy) / edge_length) / point_length; + if !(0.0..=1.0).contains(&t) { + return None; + } + let offset = [ + (t * ex + first[0]) - point[0], + (t * ey + first[1]) - point[1], + ]; + (offset[0].hypot(offset[1]) < 0.5).then_some((offset, t)) +} + +fn distance_xy(first: [f32; 2], second: [f32; 2]) -> f32 { + ((first[0] - second[0]).powi(2) + (first[1] - second[1]).powi(2)).sqrt() +} + +fn contour_crossings( + ring: &[[f64; 2]], + terrain_faces: &[(usize, FullSurfaceMask, [[f32; 3]; 3])], +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let mut crossings = Vec::new(); + for segment_index in 0..ring.len() { + let first = ring[segment_index]; + let second = ring[(segment_index + 1) % ring.len()]; + let mut segment_crossings = Vec::new(); + let mut coverage = Vec::new(); + for (source_face, _, triangle) in terrain_faces { + let Some((start, end)) = segment_triangle_interval(first, second, *triangle) else { + continue; + }; + coverage.push((start, end)); + for t in [start, end] { + let point = [ + first[0] + (second[0] - first[0]) * t, + first[1] + (second[1] - first[1]) * t, + ]; + let Some(position) = lift_to_face(point, *triangle) else { + continue; + }; + segment_crossings.push((t, *source_face, position)); + } + } + segment_crossings.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.cmp(&right.1)) + }); + coverage.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) + }); + let mut covered_until = 0.0_f64; + for (start, end) in coverage { + if start > covered_until + 1.0e-8 { + return Err(BasementFoundationError::UncoveredOuterSegment { + segment: segment_index, + }); + } + covered_until = covered_until.max(end); + } + if covered_until < 1.0 - 1.0e-8 { + return Err(BasementFoundationError::UncoveredOuterSegment { + segment: segment_index, + }); + } + for (_, _, position) in segment_crossings { + let duplicate_of_previous = crossings.last().is_some_and(|previous: &[f32; 3]| { + distance_squared( + [f64::from(previous[0]), f64::from(previous[1])], + [f64::from(position[0]), f64::from(position[1])], + ) <= POINT_EPSILON_SQUARED + }); + if duplicate_of_previous { + continue; + } + crossings.push(position); + } + } + if crossings.len() > 1 + && distance_squared( + [f64::from(crossings[0][0]), f64::from(crossings[0][1])], + [ + f64::from(crossings.last().expect("nonempty")[0]), + f64::from(crossings.last().expect("nonempty")[1]), + ], + ) <= POINT_EPSILON_SQUARED + { + crossings.pop(); + } + Ok(crossings) +} + +fn segment_triangle_interval( + first: [f64; 2], + second: [f64; 2], + triangle: [[f32; 3]; 3], +) -> Option<(f64, f64)> { + let vertices = triangle.map(|point| [f64::from(point[0]), f64::from(point[1])]); + let area = cross2(vertices[0], vertices[1], vertices[2]); + if !area.is_finite() || area.abs() <= 1.0e-12 { + return None; + } + let orientation = area.signum(); + let delta = [second[0] - first[0], second[1] - first[1]]; + let mut lower: f64 = 0.0; + let mut upper: f64 = 1.0; + for edge in 0..3 { + let edge_first = vertices[edge]; + let edge_second = vertices[(edge + 1) % 3]; + let start_distance = orientation * cross2(edge_first, edge_second, first); + let end = [first[0] + delta[0], first[1] + delta[1]]; + let end_distance = orientation * cross2(edge_first, edge_second, end); + let slope = end_distance - start_distance; + if slope.abs() <= 1.0e-15 { + if start_distance < -LINE_EPSILON { + return None; + } + continue; + } + // Tolerance decides whether a nearly outside interval is accepted; + // the actual boundary sample remains on the geometric edge (distance 0). + let crossing = -start_distance / slope; + if slope > 0.0 { + lower = lower.max(crossing); + } else { + upper = upper.min(crossing); + } + if lower > upper { + return None; + } + } + (upper >= 0.0 && lower <= 1.0).then_some((lower.clamp(0.0, 1.0), upper.clamp(0.0, 1.0))) +} + +fn lift_to_face(point: [f64; 2], triangle: [[f32; 3]; 3]) -> Option<[f32; 3]> { + let vertices = triangle.map(|vertex| vertex.map(f64::from)); + let denominator = cross2( + [vertices[0][0], vertices[0][1]], + [vertices[1][0], vertices[1][1]], + [vertices[2][0], vertices[2][1]], + ); + if denominator.abs() <= 1.0e-12 { + return None; + } + let weight_b = cross2( + [vertices[0][0], vertices[0][1]], + point, + [vertices[2][0], vertices[2][1]], + ) / denominator; + let weight_c = cross2( + [vertices[0][0], vertices[0][1]], + [vertices[1][0], vertices[1][1]], + point, + ) / denominator; + let weight_a = 1.0 - weight_b - weight_c; + let z = weight_a * vertices[0][2] + weight_b * vertices[1][2] + weight_c * vertices[2][2]; + let result = [point[0] as f32, point[1] as f32, z as f32]; + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn crossings_as_ring( + crossings: &[[f32; 3]], + closed: bool, +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let length = crossings.len().saturating_sub(usize::from(closed)); + let ring = crossings.iter().take(length).copied().collect::<Vec<_>>(); + (ring.len() >= 3) + .then_some(ring) + .ok_or(BasementFoundationError::InvalidContour) +} + +fn inner_ring_without_closure(ring: &[[f32; 3]]) -> Vec<[f32; 3]> { + let mut result = ring.to_vec(); + if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) { + result.pop(); + } + result +} + +fn triangulate_annulus( + outer: &[[f32; 3]], + inner: Vec<[f32; 3]>, +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let mut outer = remove_consecutive_duplicates(outer); + let mut inner = remove_consecutive_duplicates(&inner); + if outer.len() < 3 || inner.len() < 3 || !nested_rings_3d(&outer, &inner) { + return Err(BasementFoundationError::InvalidContour); + } + if signed_area_3d(&outer) < 0.0 { + outer.reverse(); + } + if signed_area_3d(&inner) > 0.0 { + inner.reverse(); + } + let polygon = bridge_hole(&outer, &inner)?; + let output = + legalize_constrained_edges(ear_clip_weakly_simple_polygon(&polygon)?, &outer, &inner)?; + if output.iter().any(|triangle| { + !triangle.iter().flatten().all(|value| value.is_finite()) + || cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() <= 1.0e-9 + }) { + return Err(BasementFoundationError::InvalidContour); + } + Ok(output) +} + +fn bridge_hole( + outer: &[[f32; 3]], + inner: &[[f32; 3]], +) -> Result<Vec<[f32; 3]>, BasementFoundationError> { + let hole_index = (0..inner.len()) + .max_by(|left, right| { + inner[*left][0] + .total_cmp(&inner[*right][0]) + .then_with(|| inner[*right][1].total_cmp(&inner[*left][1])) + }) + .ok_or(BasementFoundationError::InvalidContour)?; + let hole = inner[hole_index]; + let mut candidates = (0..outer.len()) + .map(|index| (distance_squared(xy(hole), xy(outer[index])), index)) + .collect::<Vec<_>>(); + candidates.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| outer[right.1][0].total_cmp(&outer[left.1][0])) + }); + let mut bridged_outer = None; + for (_, candidate_index) in candidates { + let candidate = outer[candidate_index]; + if visible_bridge(xy(hole), xy(candidate), outer, inner) { + bridged_outer = Some((outer.to_vec(), candidate_index)); + break; + } + } + let (outer, outer_index) = bridged_outer.ok_or(BasementFoundationError::InvalidContour)?; + let mut polygon = Vec::with_capacity(outer.len() + inner.len() + 2); + for step in 0..outer.len() { + let index = (outer_index + step) % outer.len(); + polygon.push(outer[index]); + if step == 0 { + polygon.push(inner[hole_index]); + for hole_step in 1..inner.len() { + polygon.push(inner[(hole_index + hole_step) % inner.len()]); + } + polygon.push(inner[hole_index]); + polygon.push(outer[outer_index]); + } + } + Ok(polygon) +} + +fn visible_bridge( + hole: [f64; 2], + outer: [f64; 2], + outer_ring: &[[f32; 3]], + inner_ring: &[[f32; 3]], +) -> bool { + let midpoint = [(hole[0] + outer[0]) * 0.5, (hole[1] + outer[1]) * 0.5]; + let outer_xy = outer_ring.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner_ring.iter().copied().map(xy).collect::<Vec<_>>(); + if !point_in_ring(midpoint, &outer_xy) || point_in_ring(midpoint, &inner_xy) { + return false; + } + if (0..outer_xy.len()).any(|index| { + segment_crosses_open_edge( + hole, + outer, + outer_xy[index], + outer_xy[(index + 1) % outer_xy.len()], + ) + }) { + return false; + } + !(0..inner_xy.len()).any(|index| { + segment_crosses_open_edge( + hole, + outer, + inner_xy[index], + inner_xy[(index + 1) % inner_xy.len()], + ) + }) +} + +fn segment_crosses_open_edge( + first: [f64; 2], + second: [f64; 2], + edge_first: [f64; 2], + edge_second: [f64; 2], +) -> bool { + if same_point(first, edge_first) + || same_point(first, edge_second) + || same_point(second, edge_first) + || same_point(second, edge_second) + { + return false; + } + segments_intersect(first, second, edge_first, edge_second) +} + +fn same_point(first: [f64; 2], second: [f64; 2]) -> bool { + distance_squared(first, second) <= POINT_EPSILON_SQUARED +} + +fn ear_clip_weakly_simple_polygon( + polygon: &[[f32; 3]], +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let mut remaining = (0..polygon.len()).collect::<Vec<_>>(); + let mut triangles = Vec::with_capacity(polygon.len().saturating_sub(2)); + while remaining.len() > 3 { + let mut ear = None; + for slot in 0..remaining.len() { + let previous = remaining[(slot + remaining.len() - 1) % remaining.len()]; + let current = remaining[slot]; + let next = remaining[(slot + 1) % remaining.len()]; + let a = xy(polygon[previous]); + let b = xy(polygon[current]); + let c = xy(polygon[next]); + if cross2(a, b, c) <= 1.0e-9 || !diagonal_is_clear(previous, next, &remaining, polygon) + { + continue; + } + let contains_point = remaining.iter().copied().any(|candidate| { + candidate != previous + && candidate != current + && candidate != next + && !same_xy(polygon[candidate], polygon[previous]) + && !same_xy(polygon[candidate], polygon[current]) + && !same_xy(polygon[candidate], polygon[next]) + && point_in_or_on_triangle(xy(polygon[candidate]), a, b, c) + }); + if !contains_point { + ear = Some((slot, [polygon[previous], polygon[current], polygon[next]])); + break; + } + } + if let Some((slot, triangle)) = ear { + triangles.push(triangle); + remaining.remove(slot); + } else { + return Err(BasementFoundationError::InvalidContour); + } + } + if remaining.len() == 3 { + let triangle = [ + polygon[remaining[0]], + polygon[remaining[1]], + polygon[remaining[2]], + ]; + if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) > 1.0e-9 { + triangles.push(triangle); + } + } + (!triangles.is_empty()) + .then_some(triangles) + .ok_or(BasementFoundationError::InvalidContour) +} + +fn diagonal_is_clear( + first_index: usize, + second_index: usize, + polygon_indices: &[usize], + polygon: &[[f32; 3]], +) -> bool { + let first = xy(polygon[first_index]); + let second = xy(polygon[second_index]); + for edge in 0..polygon_indices.len() { + let edge_first_index = polygon_indices[edge]; + let edge_second_index = polygon_indices[(edge + 1) % polygon_indices.len()]; + if edge_first_index == first_index + || edge_second_index == first_index + || edge_first_index == second_index + || edge_second_index == second_index + { + continue; + } + if segment_crosses_open_edge( + first, + second, + xy(polygon[edge_first_index]), + xy(polygon[edge_second_index]), + ) { + return false; + } + } + true +} + +fn point_in_or_on_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool { + let epsilon = 1.0e-9; + cross2(a, b, point) >= -epsilon + && cross2(b, c, point) >= -epsilon + && cross2(c, a, point) >= -epsilon +} + +fn nested_rings(outer: &[[f64; 2]], inner: &[[f64; 2]]) -> bool { + if !simple_ring(outer) || !simple_ring(inner) { + return false; + } + if rings_intersect(outer, inner) { + return false; + } + point_in_ring(inner[0], outer) +} + +fn nested_rings_3d(outer: &[[f32; 3]], inner: &[[f32; 3]]) -> bool { + let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>(); + nested_rings(&outer_xy, &inner_xy) +} + +fn remove_consecutive_duplicates(ring: &[[f32; 3]]) -> Vec<[f32; 3]> { + let mut result = Vec::with_capacity(ring.len()); + for point in ring { + if result + .last() + .is_none_or(|previous| !same_xy(*previous, *point)) + { + result.push(*point); + } + } + if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) { + result.pop(); + } + result +} + +fn simple_ring(ring: &[[f64; 2]]) -> bool { + if ring.len() < 3 || ring.iter().flatten().any(|value| !value.is_finite()) { + return false; + } + let area = ring_area(ring); + if !area.is_finite() || area.abs() <= 1.0e-10 { + return false; + } + for first in 0..ring.len() { + let first_next = (first + 1) % ring.len(); + if distance_squared(ring[first], ring[first_next]) <= POINT_EPSILON_SQUARED { + return false; + } + for second in (first + 1)..ring.len() { + let second_next = (second + 1) % ring.len(); + if first == second || first_next == second || second_next == first { + continue; + } + if segments_intersect( + ring[first], + ring[first_next], + ring[second], + ring[second_next], + ) { + return false; + } + } + } + true +} + +fn rings_intersect(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool { + (0..first.len()).any(|first_index| { + (0..second.len()).any(|second_index| { + segments_intersect( + first[first_index], + first[(first_index + 1) % first.len()], + second[second_index], + second[(second_index + 1) % second.len()], + ) + }) + }) +} + +fn rings_overlap(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool { + rings_intersect(first, second) + || point_in_ring(first[0], second) + || point_in_ring(second[0], first) +} + +fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut inside = false; + for index in 0..ring.len() { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + if point_on_segment(point, first, second) { + return true; + } + if (first[1] > point[1]) != (second[1] > point[1]) { + let intersection_x = + first[0] + (point[1] - first[1]) * (second[0] - first[0]) / (second[1] - first[1]); + if point[0] < intersection_x { + inside = !inside; + } + } + } + inside +} + +fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool { + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let length = (dx * dx + dy * dy).sqrt(); + cross2(first, second, point).abs() <= LINE_EPSILON * length + && point[0] >= first[0].min(second[0]) - LINE_EPSILON + && point[0] <= first[0].max(second[0]) + LINE_EPSILON + && point[1] >= first[1].min(second[1]) - LINE_EPSILON + && point[1] <= first[1].max(second[1]) + LINE_EPSILON +} + +fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let ab_c = cross2(a, b, c); + let ab_d = cross2(a, b, d); + let cd_a = cross2(c, d, a); + let cd_b = cross2(c, d, b); + let epsilon = 1.0e-8; + (ab_c.abs() <= epsilon && on_segment(c, a, b, epsilon)) + || (ab_d.abs() <= epsilon && on_segment(d, a, b, epsilon)) + || (cd_a.abs() <= epsilon && on_segment(a, c, d, epsilon)) + || (cd_b.abs() <= epsilon && on_segment(b, c, d, epsilon)) + || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0) +} + +fn on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2], epsilon: f64) -> bool { + point[0] >= first[0].min(second[0]) - epsilon + && point[0] <= first[0].max(second[0]) + epsilon + && point[1] >= first[1].min(second[1]) - epsilon + && point[1] <= first[1].max(second[1]) + epsilon +} + +fn ring_area(ring: &[[f64; 2]]) -> f64 { + (0..ring.len()) + .map(|index| { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + first[0] * second[1] - second[0] * first[1] + }) + .sum::<f64>() + * 0.5 +} + +fn signed_area_3d(ring: &[[f32; 3]]) -> f64 { + ring_area(&ring.iter().copied().map(xy).collect::<Vec<_>>()) +} + +fn xy(position: [f32; 3]) -> [f64; 2] { + [f64::from(position[0]), f64::from(position[1])] +} + +fn same_xy(first: [f32; 3], second: [f32; 3]) -> bool { + distance_squared(xy(first), xy(second)) <= POINT_EPSILON_SQUARED +} + +fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 { + (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0]) +} + +fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +pub(crate) fn basement_surface_mask(positions: [[f32; 3]; 3]) -> FullSurfaceMask { + let first = positions[1]; + let origin = positions[0]; + let second = positions[2]; + let normal_z = (first[0] - origin[0]) * (second[1] - origin[1]) + - (first[1] - origin[1]) * (second[0] - origin[0]); + FullSurfaceMask(if normal_z < 0.0 { 0x0300 } else { 0x8300 }) +} + +fn legalize_constrained_edges( + mut triangles: Vec<[[f32; 3]; 3]>, + outer: &[[f32; 3]], + inner: &[[f32; 3]], +) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> { + let max_flips = triangles + .len() + .saturating_mul(triangles.len()) + .saturating_mul(4) + .max(1); + for _ in 0..max_flips { + let mut flipped = false; + 'pairs: for first in 0..triangles.len() { + for second in (first + 1)..triangles.len() { + let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else { + continue; + }; + if ring_has_edge(outer, a, b) || ring_has_edge(inner, a, b) { + continue; + } + if !convex_quadrilateral(a, b, c, d) || in_circle(a, b, c, d) <= 1.0e-8 { + continue; + } + let first_triangle = orient_ccw([c, d, b]); + let second_triangle = orient_ccw([d, c, a]); + if cross2( + xy(first_triangle[0]), + xy(first_triangle[1]), + xy(first_triangle[2]), + ) <= 1.0e-9 + || cross2( + xy(second_triangle[0]), + xy(second_triangle[1]), + xy(second_triangle[2]), + ) <= 1.0e-9 + { + return Err(BasementFoundationError::InvalidContour); + } + triangles[first] = first_triangle; + triangles[second] = second_triangle; + flipped = true; + break 'pairs; + } + } + if !flipped { + return Ok(triangles); + } + } + Err(BasementFoundationError::InvalidContour) +} + +fn shared_edge( + first: [[f32; 3]; 3], + second: [[f32; 3]; 3], +) -> Option<([f32; 3], [f32; 3], [f32; 3], [f32; 3])> { + for edge in 0..3 { + let a = first[edge]; + let b = first[(edge + 1) % 3]; + if let Some(opposite) = second + .iter() + .copied() + .find(|point| !same_xy(*point, a) && !same_xy(*point, b)) + { + let has_a = second.iter().any(|point| same_xy(*point, a)); + let has_b = second.iter().any(|point| same_xy(*point, b)); + if has_a && has_b { + let first_opposite = first[(edge + 2) % 3]; + return Some((a, b, first_opposite, opposite)); + } + } + } + None +} + +fn ring_has_edge(ring: &[[f32; 3]], a: [f32; 3], b: [f32; 3]) -> bool { + (0..ring.len()).any(|index| { + let first = ring[index]; + let second = ring[(index + 1) % ring.len()]; + (same_xy(first, a) && same_xy(second, b)) || (same_xy(first, b) && same_xy(second, a)) + }) +} + +fn convex_quadrilateral(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> bool { + let epsilon = 1.0e-9; + let c_side = cross2(xy(a), xy(b), xy(c)); + let d_side = cross2(xy(a), xy(b), xy(d)); + let a_side = cross2(xy(c), xy(d), xy(a)); + let b_side = cross2(xy(c), xy(d), xy(b)); + c_side * d_side < -epsilon && a_side * b_side < -epsilon +} + +fn in_circle(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> f64 { + let a = [ + f64::from(a[0]) - f64::from(d[0]), + f64::from(a[1]) - f64::from(d[1]), + ]; + let b = [ + f64::from(b[0]) - f64::from(d[0]), + f64::from(b[1]) - f64::from(d[1]), + ]; + let c = [ + f64::from(c[0]) - f64::from(d[0]), + f64::from(c[1]) - f64::from(d[1]), + ]; + (a[0] * a[0] + a[1] * a[1]) * (b[0] * c[1] - b[1] * c[0]) + - (b[0] * b[0] + b[1] * b[1]) * (a[0] * c[1] - a[1] * c[0]) + + (c[0] * c[0] + c[1] * c[1]) * (a[0] * b[1] - a[1] * b[0]) +} + +fn orient_ccw(mut triangle: [[f32; 3]; 3]) -> [[f32; 3]; 3] { + if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) < 0.0 { + triangle.swap(1, 2); + } + triangle +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_terrain_format::{TerrainFace28, TerrainSlotTable}; + + #[test] + fn foundation_samples_lod0_planes_and_flattens_or_preserves_authored_inner_z() { + let mesh = two_lod_mesh(); + let outer = [[0.4, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.4, 0.0]]; + let inner = [ + [1.0, 1.0, 10.0], + [3.0, 1.0, 11.0], + [3.0, 3.0, 12.0], + [1.0, 3.0, 13.0], + [1.0, 1.0, 10.0], + ]; + let (open_outer, _) = normalize_ring(&outer).expect("outer ring"); + let snapped = normalize_outer_points(&open_outer, &[0, 1, 2], &mesh) + .expect("outer points lie within LOD0"); + assert_eq!(snapped.first(), Some(&[0.0, 0.0])); + assert!(!snapped.contains(&[2.0, 0.0])); + assert!(native_segment_inserts_vertex( + [0.0, 0.0], + [4.0, 0.0], + [3.96875, 0.4991, 12.0] + )); + assert!(!native_segment_inserts_vertex( + [0.0, 0.0], + [4.0, 0.0], + [2.0, 0.0, 12.0] + )); + let terrain = [ + ( + 0, + mesh.faces[0].flags, + terrain_face_positions(&mesh, 0).unwrap(), + ), + ( + 1, + mesh.faces[1].flags, + terrain_face_positions(&mesh, 1).unwrap(), + ), + ( + 2, + mesh.faces[2].flags, + terrain_face_positions(&mesh, 2).unwrap(), + ), + ]; + let crossings = contour_crossings(&snapped, &terrain).expect("LOD0 crossings"); + assert!(crossings.len() > 4); + assert_eq!(crossings.first().unwrap()[2], 0.0); + assert!(crossings + .iter() + .any(|crossing| same_xy(*crossing, [2.0, 0.0, 2.0]))); + let expected_mean = + crossings.iter().map(|crossing| crossing[2]).sum::<f32>() / crossings.len() as f32; + + let flat = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ) + .expect("flat foundation"); + let placement = flat.placements[0]; + assert!((placement.placement_delta - (10.0 - expected_mean)).abs() < 1.0e-6); + let flat_band = flat + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + assert!(flat + .fragments + .iter() + .filter_map(|fragment| fragment.source_face) + .all(|face| face < 3)); + assert!(!flat_band.is_empty()); + assert!(flat_band + .iter() + .all(|fragment| fragment.surface_mask == FullSurfaceMask(0x8300))); + for point in &inner[..4] { + assert!(flat_band + .iter() + .flat_map(|fragment| fragment.positions) + .any(|vertex| { + same_xy(vertex, *point) && (vertex[2] - expected_mean).abs() < 1.0e-6 + })); + } + let flat_area = flat_band + .iter() + .map(|fragment| { + let [a, b, c] = fragment.positions; + cross2(xy(a), xy(b), xy(c)).abs() * 0.5 + }) + .sum::<f64>(); + assert!((flat_area - 12.0).abs() < 1.0e-5); + + let authored = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: false, + }], + ) + .expect("authored-height foundation"); + let authored_band = authored + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + for point in &inner[..4] { + assert!(authored_band + .iter() + .flat_map(|fragment| fragment.positions) + .any(|vertex| { + same_xy(vertex, *point) && (vertex[2] - point[2]).abs() < 1.0e-6 + })); + } + } + + #[test] + fn foundation_rejects_outer_contours_outside_or_across_uncovered_terrain() { + let mesh = two_lod_mesh(); + let outside = [[5.0, 1.0], [5.5, 1.0], [5.0, 1.5]]; + let inner = [[5.1, 1.1, 0.0], [5.3, 1.1, 0.0], [5.1, 1.3, 0.0]]; + assert_eq!( + build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outside, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ), + Err(BasementFoundationError::OuterPointOutsideLod0 { point: 0 }) + ); + + let mut islands = two_lod_mesh(); + islands.positions = vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [2.0, 2.0, 0.0], + [3.0, 2.0, 0.0], + [2.0, 3.0, 0.0], + ]; + islands.faces.truncate(2); + islands.faces[0].vertices = [0, 1, 2]; + islands.faces[1].vertices = [3, 4, 5]; + islands.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes()); + islands.slots.slots_raw[1][0..2].copy_from_slice(&2_u16.to_le_bytes()); + islands.slots.slots_raw[1][2..4].copy_from_slice(&0_u16.to_le_bytes()); + let outer = [[0.1, 0.1], [0.7, 0.1], [2.1, 2.1]]; + let inner = [[0.2, 0.2, 0.0], [0.4, 0.2, 0.0], [0.2, 0.4, 0.0]]; + assert_eq!( + build_basement_foundations( + &islands, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ), + Err(BasementFoundationError::UncoveredOuterSegment { segment: 1 }) + ); + } + + #[test] + fn shared_diagonal_crossing_is_exact_and_counted_once() { + let first = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]]; + let second = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 1.0, 2.0]]; + let edge_start = [0.8, 0.5]; + let edge_end = [0.5, 0.8]; + let first_interval = + segment_triangle_interval(edge_start, edge_end, first).expect("first face interval"); + let second_interval = + segment_triangle_interval(edge_start, edge_end, second).expect("second face interval"); + assert!((first_interval.1 - 0.5).abs() < 1.0e-9); + assert!((second_interval.0 - 0.5).abs() < 1.0e-9); + + let ring = [[0.5, 0.2], [0.8, 0.5], [0.5, 0.8], [0.2, 0.5]]; + let crossings = contour_crossings( + &ring, + &[ + (0, FullSurfaceMask(1), first), + (1, FullSurfaceMask(2), second), + ], + ) + .expect("shared-edge crossings"); + let shared = crossings + .iter() + .filter(|crossing| { + distance_squared( + [f64::from(crossing[0]), f64::from(crossing[1])], + [0.65, 0.65], + ) <= POINT_EPSILON_SQUARED + }) + .collect::<Vec<_>>(); + assert_eq!( + shared.len(), + 1, + "shared-edge crossing duplicated: {shared:?}" + ); + assert!((shared[0][2] - 0.65).abs() < 1.0e-6); + } + + #[test] + fn annulus_preserves_collinear_outer_height_breakpoints_and_is_constrained_delaunay() { + let outer = [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 3.0], + [4.0, 0.0, 10.0], + [4.0, 4.0, 12.0], + [0.0, 4.0, 0.0], + ]; + let inner = vec![ + [1.0, 1.0, 5.0], + [3.0, 1.0, 5.0], + [3.0, 3.0, 5.0], + [1.0, 3.0, 5.0], + ]; + let triangles = triangulate_annulus(&outer, inner.clone()).expect("annulus"); + for edge in outer.windows(2) { + assert!( + triangles + .iter() + .any(|triangle| has_xy_edge(*triangle, edge[0], edge[1])), + "missing outer boundary segment {:?} -> {:?}", + edge[0], + edge[1] + ); + } + for point in outer { + assert!( + triangles + .iter() + .flat_map(|triangle| triangle.iter()) + .any(|vertex| { + same_xy(*vertex, point) && (vertex[2] - point[2]).abs() < 1.0e-6 + }), + "lost outer breakpoint {point:?}" + ); + } + assert_locally_delaunay(&triangles, &outer, &inner); + } + + #[test] + fn constrained_annulus_handles_concave_rings_with_different_vertex_counts() { + let outer = [ + [0.0, 0.0, 1.0], + [10.0, 0.0, 2.0], + [10.0, 3.0, 3.0], + [4.0, 3.0, 4.0], + [4.0, 10.0, 5.0], + [0.0, 10.0, 6.0], + ]; + let inner = vec![ + [1.0, 1.0, 3.0], + [3.0, 1.0, 3.0], + [3.0, 1.8, 3.0], + [2.2, 1.8, 3.0], + [2.2, 2.4, 3.0], + [3.0, 2.4, 3.0], + [3.0, 3.0, 3.0], + [1.0, 3.0, 3.0], + ]; + let band = triangulate_annulus(&outer, inner.clone()).expect("constrained annulus"); + let area = band + .iter() + .map(|triangle| cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() * 0.5) + .sum::<f64>(); + let expected = ring_area(&outer.iter().copied().map(xy).collect::<Vec<_>>()).abs() + - ring_area(&inner.iter().copied().map(xy).collect::<Vec<_>>()).abs(); + assert!( + (area - expected).abs() < 1.0e-5, + "area {area}, expected {expected}" + ); + + let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>(); + let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>(); + for triangle in &band { + let centroid = triangle_centroid(*triangle); + assert!(point_in_ring(centroid, &outer_xy)); + assert!(!point_in_ring(centroid, &inner_xy)); + } + for first in 0..band.len() { + for second in (first + 1)..band.len() { + assert!(!positive_area_overlap(band[first], band[second])); + } + } + assert_locally_delaunay(&band, &outer, &inner); + } + + #[test] + fn multiple_disjoint_footprints_clip_canonical_outside_once() { + let mesh = two_lod_mesh(); + let first_outer = [[0.6, 0.6], [1.7, 0.6], [1.7, 1.7], [0.6, 1.7]]; + let first_inner = [ + [0.9, 0.9, 5.0], + [1.4, 0.9, 5.0], + [1.4, 1.4, 5.0], + [0.9, 1.4, 5.0], + ]; + let second_outer = [[2.3, 2.3], [3.4, 2.3], [3.4, 3.4], [2.3, 3.4]]; + let second_inner = [ + [2.6, 2.6, 7.0], + [3.1, 2.6, 7.0], + [3.1, 3.1, 7.0], + [2.6, 3.1, 7.0], + ]; + let output = build_basement_foundations( + &mesh, + &[ + BasementFootprint { + outer: &first_outer, + inner: &first_inner, + flatten_inner_to_mean_outer_z: true, + }, + BasementFootprint { + outer: &second_outer, + inner: &second_inner, + flatten_inner_to_mean_outer_z: true, + }, + ], + ) + .expect("two disjoint foundations"); + assert_eq!(output.placements.len(), 2); + let terrain = output + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_some()) + .collect::<Vec<_>>(); + let bands = output + .fragments + .iter() + .filter(|fragment| fragment.source_face.is_none()) + .collect::<Vec<_>>(); + assert!(!terrain.is_empty()); + assert!(!bands.is_empty()); + for fragment in &terrain { + let centroid = triangle_centroid(fragment.positions); + assert!(!point_strictly_in_ring(centroid, &ring_f64(&first_outer))); + assert!(!point_strictly_in_ring(centroid, &ring_f64(&second_outer))); + } + for first in 0..terrain.len() { + for second in (first + 1)..terrain.len() { + assert!(!positive_area_overlap( + terrain[first].positions, + terrain[second].positions + )); + } + } + for band in bands { + let centroid = triangle_centroid(band.positions); + assert!( + point_in_ring(centroid, &ring_f64(&first_outer)) + || point_in_ring(centroid, &ring_f64(&second_outer)) + ); + assert!(!point_in_ring( + centroid, + &ring_f64(&first_inner.iter().map(|p| [p[0], p[1]]).collect::<Vec<_>>()) + )); + assert!(!point_in_ring( + centroid, + &ring_f64( + &second_inner + .iter() + .map(|p| [p[0], p[1]]) + .collect::<Vec<_>>() + ) + )); + } + } + + #[test] + fn overlapping_footprints_are_explicitly_rejected() { + let mesh = two_lod_mesh(); + let outer_a = [[0.5, 0.5], [2.5, 0.5], [2.5, 2.5], [0.5, 2.5]]; + let inner_a = [ + [1.0, 1.0, 3.0], + [2.0, 1.0, 3.0], + [2.0, 2.0, 3.0], + [1.0, 2.0, 3.0], + ]; + let outer_b = [[1.5, 1.5], [3.5, 1.5], [3.5, 3.5], [1.5, 3.5]]; + let inner_b = [ + [2.0, 2.0, 3.0], + [3.0, 2.0, 3.0], + [3.0, 3.0, 3.0], + [2.0, 3.0, 3.0], + ]; + assert_eq!( + build_basement_foundations( + &mesh, + &[ + BasementFootprint { + outer: &outer_a, + inner: &inner_a, + flatten_inner_to_mean_outer_z: true + }, + BasementFootprint { + outer: &outer_b, + inner: &inner_b, + flatten_inner_to_mean_outer_z: true + }, + ], + ), + Err(BasementFoundationError::OverlappingFootprints) + ); + } + + #[test] + fn generated_flags_follow_native_normal_z_branch() { + let positive = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let negative = [positive[0], positive[2], positive[1]]; + assert_eq!(basement_surface_mask(positive), FullSurfaceMask(0x8300)); + assert_eq!(basement_surface_mask(negative), FullSurfaceMask(0x0300)); + } + + #[test] + fn foundation_rejects_inner_ring_that_is_not_nested() { + let mesh = two_lod_mesh(); + let outer = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]; + let inner = [ + [3.0, 1.0, 0.0], + [5.0, 1.0, 0.0], + [5.0, 3.0, 0.0], + [3.0, 3.0, 0.0], + ]; + assert_eq!( + build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true + }], + ), + Err(BasementFoundationError::InvalidContour) + ); + } + + fn assert_locally_delaunay( + triangles: &[[[f32; 3]; 3]], + outer: &[[f32; 3]], + inner: &[[f32; 3]], + ) { + for first in 0..triangles.len() { + for second in (first + 1)..triangles.len() { + let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else { + continue; + }; + if ring_has_edge(outer, a, b) + || ring_has_edge(inner, a, b) + || !convex_quadrilateral(a, b, c, d) + { + continue; + } + assert!( + in_circle(a, b, c, d) <= 1.0e-8, + "illegal diagonal {a:?}->{b:?}" + ); + } + } + } + + fn has_xy_edge(triangle: [[f32; 3]; 3], first: [f32; 3], second: [f32; 3]) -> bool { + (0..3).any(|index| { + (same_xy(triangle[index], first) && same_xy(triangle[(index + 1) % 3], second)) + || (same_xy(triangle[index], second) && same_xy(triangle[(index + 1) % 3], first)) + }) + } + + fn triangle_centroid(triangle: [[f32; 3]; 3]) -> [f64; 2] { + [ + triangle + .iter() + .map(|point| f64::from(point[0])) + .sum::<f64>() + / 3.0, + triangle + .iter() + .map(|point| f64::from(point[1])) + .sum::<f64>() + / 3.0, + ] + } + + fn point_strictly_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool { + !((0..ring.len()) + .any(|index| point_on_segment(point, ring[index], ring[(index + 1) % ring.len()]))) + && point_in_ring(point, ring) + } + + fn ring_f64(ring: &[[f32; 2]]) -> Vec<[f64; 2]> { + ring.iter() + .map(|point| [f64::from(point[0]), f64::from(point[1])]) + .collect() + } + + fn two_lod_mesh() -> LandMeshDocument { + let positions = vec![ + [0.0, 0.0, 0.0], + [2.0, 0.0, 2.0], + [4.0, 0.0, 4.0], + [0.0, 4.0, 4.0], + [4.0, 4.0, 8.0], + [0.0, 0.0, 100.0], + [4.0, 0.0, 100.0], + [0.0, 4.0, 100.0], + ]; + let face = |flags, vertices| TerrainFace28 { + flags: FullSurfaceMask(flags), + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None; 3], + tail_raw: [0; 8], + raw: [0; 28], + }; + let faces = vec![ + face(1, [0, 1, 3]), + face(2, [1, 4, 3]), + face(4, [1, 2, 4]), + face(8, [5, 6, 7]), + ]; + let mut nodes_raw = vec![u8::MAX; 38]; + nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes()); + nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes()); + let mut slots_raw = vec![[0_u8; 68]; 2]; + slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes()); + slots_raw[1][0..2].copy_from_slice(&3_u16.to_le_bytes()); + slots_raw[1][2..4].copy_from_slice(&1_u16.to_le_bytes()); + LandMeshDocument { + streams: Vec::new(), + nodes_raw, + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw, + }, + positions, + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces, + } + } + + fn positive_area_overlap(first: [[f32; 3]; 3], second: [[f32; 3]; 3]) -> bool { + for triangle in [first, second] { + for edge in 0..3 { + let a = xy(triangle[edge]); + let b = xy(triangle[(edge + 1) % 3]); + let axis = [b[1] - a[1], a[0] - b[0]]; + let project = |vertices: [[f32; 3]; 3]| { + vertices.map(|vertex| { + let point = xy(vertex); + point[0] * axis[0] + point[1] * axis[1] + }) + }; + let first_projection = project(first); + let second_projection = project(second); + let first_min = first_projection + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let first_max = first_projection + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let second_min = second_projection + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let second_max = second_projection + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + if first_max <= second_min + 1.0e-8 || second_max <= first_min + 1.0e-8 { + return false; + } + } + } + true + } +} diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index a014b61..db55506 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -4,6 +4,15 @@ use fparkan_terrain_format::{LandMapDocument, LandMeshDocument}; use std::collections::VecDeque; +mod contour; +mod foundation; + +use foundation::basement_surface_mask; +pub use foundation::{ + build_basement_foundations, BasementFootprint, BasementFoundation, BasementFoundationError, + BasementFoundationFragment, BasementPlacement, +}; + /// Terrain material-selector contract preserved from the decoded map mesh. /// /// Applications access this semantic terrain API through this terrain crate @@ -21,6 +30,7 @@ pub struct TerrainWorld { surfaces: Vec<RuntimeTriangle>, surface_bvh: SurfaceBvh, source_mesh: Option<LandMeshDocument>, + basement_foundation: Option<BasementFoundation>, } /// Surface hit. @@ -91,6 +101,13 @@ pub enum TerrainError { /// Position count. position_count: usize, }, + /// A basement fragment does not preserve a valid LOD0 source face and mask. + InvalidFoundationFragment { + /// Fragment index. + fragment: usize, + }, + /// Foundation fragments require a retained source Land.msh document. + FoundationRequiresMesh, /// Terrain Node38/Slot68 records do not assign every face to one LOD. InvalidLodMapping(TerrainLodError), } @@ -119,6 +136,12 @@ impl std::fmt::Display for TerrainError { f, "terrain face {face} references vertex {vertex} outside {position_count} positions" ), + Self::InvalidFoundationFragment { fragment } => { + write!(f, "invalid basement foundation fragment {fragment}") + } + Self::FoundationRequiresMesh => { + write!(f, "basement foundation requires a source Land.msh") + } Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), } } @@ -132,7 +155,9 @@ impl std::error::Error for TerrainError { | Self::TooManyAreals { .. } | Self::InvalidGridReference { .. } | Self::InvalidArealReference { .. } - | Self::InvalidSurfaceVertex { .. } => None, + | Self::InvalidSurfaceVertex { .. } + | Self::InvalidFoundationFragment { .. } + | Self::FoundationRequiresMesh => None, } } } @@ -254,6 +279,7 @@ impl TerrainWorld { surfaces: Vec::new(), surface_bvh: SurfaceBvh::default(), source_mesh: None, + basement_foundation: None, }) } @@ -290,6 +316,87 @@ impl TerrainWorld { Ok(world) } + /// Returns a new world whose surface queries use exactly the canonical + /// source and generated triangles from a basement foundation. + /// + /// # Errors + /// + /// Returns [`TerrainError`] if this world has no source mesh or any + /// fragment does not preserve valid LOD0 identity, flags, and geometry. + pub fn with_basement_foundation( + mut self, + foundation: BasementFoundation, + ) -> Result<Self, TerrainError> { + let mesh = self + .source_mesh + .as_ref() + .ok_or(TerrainError::FoundationRequiresMesh)?; + let assignments = mesh + .face_lod_assignments() + .map_err(TerrainError::InvalidLodMapping)?; + let is_lod0 = assignments + .iter() + .map(|assignment| assignment.lod == TerrainLod::Lod0) + .collect::<Vec<_>>(); + 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) { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + let face = if let Some(source_face) = fragment.source_face { + let Some(source) = mesh.faces.get(source_face) else { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + }; + if !is_lod0.get(source_face).copied().unwrap_or(false) + || source.flags != fragment.surface_mask + { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + source_face + } else { + if basement_surface_mask(fragment.positions) != fragment.surface_mask { + return Err(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }); + } + let face = mesh.faces.len().checked_add(generated).ok_or( + TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + }, + )?; + generated = + generated + .checked_add(1) + .ok_or(TerrainError::InvalidFoundationFragment { + fragment: fragment_index, + })?; + face + }; + surfaces.push(RuntimeTriangle { + face, + mask: fragment.surface_mask, + vertices: fragment.positions, + }); + } + self.surfaces = surfaces; + self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces); + self.basement_foundation = Some(foundation); + Ok(self) + } + + /// Returns the canonical basement geometry used by the surface query index. + #[must_use] + pub fn basement_foundation(&self) -> Option<&BasementFoundation> { + self.basement_foundation.as_ref() + } + /// Returns the number of navigation areas. #[must_use] pub fn areal_count(&self) -> usize { @@ -740,26 +847,31 @@ impl RuntimeTriangle { } 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 { + let edge1 = sub3_f64(self.vertices[1], self.vertices[0]); + let edge2 = sub3_f64(self.vertices[2], self.vertices[0]); + let direction = direction.map(f64::from); + let pvec = cross3_f64(direction, edge2); + let det = dot3_f64(edge1, pvec); + if det == 0.0 || !det.is_finite() { return None; } let inv_det = 1.0 / det; - let tvec = sub3(origin, self.vertices[0]); - let u = dot3(tvec, pvec) * inv_det; + let tvec = sub3_f64(origin, self.vertices[0]); + let u = dot3_f64(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; + let qvec = cross3_f64(tvec, edge1); + let v = dot3_f64(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) + let distance = dot3_f64(edge2, qvec) * inv_det; + if distance >= 0.0 && distance.is_finite() && distance <= f64::from(f32::MAX) { + Some(distance as f32) + } else { + None + } } } @@ -919,6 +1031,21 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra Ok(triangles) } +fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { + if !vertices.iter().flatten().all(|value| value.is_finite()) { + return false; + } + let first = [ + f64::from(vertices[1][0]) - f64::from(vertices[0][0]), + f64::from(vertices[1][1]) - f64::from(vertices[0][1]), + ]; + let second = [ + 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 +} + fn surface_vertex( mesh: &LandMeshDocument, face: usize, @@ -940,25 +1067,41 @@ fn barycentric_2d( 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 edge_second = [ + f64::from(second[0]) - f64::from(first[0]), + f64::from(second[1]) - f64::from(first[1]), + ]; + let edge_third = [ + f64::from(third[0]) - f64::from(first[0]), + f64::from(third[1]) - f64::from(first[1]), + ]; + let point_delta = [ + f64::from(point[0]) - f64::from(first[0]), + f64::from(point[1]) - f64::from(first[1]), + ]; let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1]; - if denom.abs() <= 1.0e-6 { + if denom == 0.0 { 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 second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) / denom; + let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) / denom; let first_weight = 1.0 - second_weight - third_weight; - Some([first_weight, second_weight, third_weight]) + let weights = [first_weight, second_weight, third_weight]; + if weights.iter().any(|weight| !weight.is_finite()) { + return None; + } + Some(weights.map(|weight| weight as f32)) } -fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +fn sub3_f64(a: [f32; 3], b: [f32; 3]) -> [f64; 3] { + [ + f64::from(a[0]) - f64::from(b[0]), + f64::from(a[1]) - f64::from(b[1]), + f64::from(a[2]) - f64::from(b[2]), + ] } -fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { +fn cross3_f64(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], @@ -966,7 +1109,7 @@ fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { ] } -fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { +fn dot3_f64(a: [f64; 3], b: [f64; 3]) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } @@ -1127,6 +1270,177 @@ mod tests { } #[test] + fn foundation_world_queries_use_the_canonical_cut_and_generated_band() { + use crate::{build_basement_foundations, BasementFootprint}; + + let mesh = synthetic_land_mesh(); + let outer = [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]; + let inner = [ + [0.4, 0.4, 1.0], + [0.6, 0.4, 1.0], + [0.6, 0.6, 1.0], + [0.4, 0.6, 1.0], + ]; + let foundation = build_basement_foundations( + &mesh, + &[BasementFootprint { + outer: &outer, + inner: &inner, + flatten_inner_to_mean_outer_z: true, + }], + ) + .expect("foundation"); + let expected_fragments = foundation.fragments.clone(); + let expected_fragment_count = foundation.fragments.len(); + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("foundation world"); + + assert_eq!(world.surface_count(), expected_fragment_count); + assert_eq!( + world + .basement_foundation() + .map(|value| value.fragments.as_slice()), + Some(expected_fragments.as_slice()) + ); + assert!(world + .height_at([0.05, 0.05]) + .expect("band height") + .is_some()); + assert!(world.height_at([0.3, 0.3]).expect("band height").is_some()); + assert_eq!(world.height_at([0.5, 0.5]).expect("hole height"), None); + assert_eq!( + world + .raycast([0.5, 0.5, 4.0], [0.0, 0.0, -1.0], FullSurfaceMask(0),) + .expect("hole ray"), + None + ); + + let mut generated_face = mesh.faces.len(); + for (runtime, fragment) in world.surfaces.iter().zip(&expected_fragments) { + assert_eq!(runtime.mask, fragment.surface_mask); + assert_eq!(runtime.vertices, fragment.positions); + if let Some(source_face) = fragment.source_face { + assert_eq!(runtime.face, source_face); + assert_eq!(runtime.mask, mesh.faces[source_face].flags); + } else { + assert_eq!(runtime.face, generated_face); + generated_face += 1; + } + } + assert!(generated_face > mesh.faces.len()); + } + + #[test] + fn foundation_install_accepts_thin_nonzero_fragments_for_surface_queries() { + let mesh = synthetic_land_mesh(); + let fragment = BasementFoundationFragment { + source_face: Some(0), + surface_mask: mesh.faces[0].flags, + positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.000_000_5, 0.0]], + }; + let foundation = BasementFoundation { + fragments: vec![fragment], + placements: Vec::new(), + }; + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("thin but non-degenerate triangle"); + + assert_eq!(world.surface_count(), 1); + assert_eq!( + world.height_at([0.25, 0.000_000_125]).expect("height"), + Some(0.0) + ); + assert!(world + .raycast( + [0.25, 0.000_000_125, 1.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0), + ) + .expect("raycast") + .is_some()); + } + + #[test] + fn foundation_install_rejects_invalid_source_identity_masks_and_generated_flags() { + 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]]; + let invalid = [ + BasementFoundationFragment { + source_face: Some(mesh.faces.len()), + surface_mask: mesh.faces[0].flags, + positions: source_triangle, + }, + BasementFoundationFragment { + source_face: Some(0), + surface_mask: FullSurfaceMask(mesh.faces[0].flags.0 ^ 1), + positions: source_triangle, + }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x0300), + positions: generated_triangle, + }, + ]; + + for (index, fragment) in invalid.into_iter().enumerate() { + let foundation = BasementFoundation { + fragments: vec![fragment], + placements: Vec::new(), + }; + assert!( + matches!( + TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation), + Err(TerrainError::InvalidFoundationFragment { fragment: 0 }) + ), + "invalid fragment case {index}" + ); + } + } + + #[test] + fn foundation_install_rejects_lod1_source_faces() { + use fparkan_terrain_format::TerrainLod; + + let mut mesh = synthetic_land_mesh(); + mesh.positions.push([0.0, 0.0, 2.0]); + mesh.positions.push([1.0, 0.0, 2.0]); + mesh.positions.push([0.0, 1.0, 2.0]); + mesh.faces + .push(terrain_face(FullSurfaceMask(0x0000_0003), [4, 5, 6])); + mesh.nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes()); + let mut lod1_slot = [0_u8; 68]; + lod1_slot[0..2].copy_from_slice(&2_u16.to_le_bytes()); + lod1_slot[2..4].copy_from_slice(&1_u16.to_le_bytes()); + mesh.slots.slots_raw.push(lod1_slot); + assert_eq!( + mesh.face_lod_assignments().expect("LOD assignment")[2].lod, + TerrainLod::Lod1 + ); + + let foundation = BasementFoundation { + fragments: vec![BasementFoundationFragment { + source_face: Some(2), + surface_mask: mesh.faces[2].flags, + positions: [[0.0, 0.0, 2.0], [1.0, 0.0, 2.0], [0.0, 1.0, 2.0]], + }], + placements: Vec::new(), + }; + assert!(matches!( + TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation), + Err(TerrainError::InvalidFoundationFragment { fragment: 0 }) + )); + } + + #[test] fn surface_bvh_reduces_candidates_without_changing_surface_math() { let surfaces: Vec<_> = (0_u16..16) .map(|face| { |
