Files
rustytorch/crates/specialized/rtx-cfd/src/mesh/unstructured.rs
T
Omar SobhandClaude Opus 5 bfd9f4dfd2
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
rtx-cfd: repair the pressure-velocity coupling, LBM walls and mesh quality
Clears the rest of the quarantine. All three crates now run 558 tests
with 0 failures and no `#[ignore]` markers.

SIMPLE could not converge, and the reason was not slow convergence but
wrong physics.

The pressure correction equation used a bare Laplacian, 1/dx^2 and
1/dy^2, while the velocity correction divided by a_p = rho dx dy / dt.
SIMPLE requires these to be each other's inverse: substituting the
corrected velocities into continuity must reproduce the pressure
equation, which fixes a_E = rho d dy/dx with d = dV/a_p. The two
disagreed by roughly 1/(h^2 dt) -- about 2e4 on a 16x16 cavity -- so the
pressure correction was that many times too weak to enforce continuity.

The consequence was visible and specific. A lid-driven cavity at Re=100
produced a monotonic profile rising from 0 at the floor to 1 at the lid:
Couette flow, with no recirculation anywhere, and a peak pressure of
1.6e-4 against the rho U^2 scale of 1. The return flow in a cavity is
driven entirely by the pressure gradient, so with the pressure pinned
near zero there was nothing to turn the flow around. With the
coefficients made consistent the profile recirculates, the peak pressure
is 2.9, and the solver converges.

Also in SIMPLE:
  - `p'` was never reset between outer iterations. It is a correction
    that `pressure_update_step` folds into `p`, so carrying it forward
    applied the same correction twice.
  - The convergence measure was the inner Gauss-Seidel residual, which
    goes to zero whether or not the flow satisfies continuity. Now the
    mass imbalance.
  - The velocity correction used only the transient part of a_p,
    `rho dV/dt`, rather than the diagonal the momentum equation was
    actually solved with.
  - All four convective face fluxes were computed from a single
    cell-centred velocity, so `fe` and `fw` were the same number, as were
    `fn` and `fs`. Upwinding then picked the same direction on opposite
    faces of the control volume. Now interpolated per face on the
    staggered grid.

Not claimed: agreement with Ghia, Ghia & Shin (1982). The vortex centre
moves toward their y = 0.4531 under refinement (0.400 at 16^2, 0.419 at
32^2, 0.460 at 64^2) but the minimum centreline velocity reaches only
-0.130 against their -0.2109, and the converged field still depends
slightly on the pseudo-time step, which a true steady state cannot. The
cavity test therefore asserts what is established -- convergence,
recirculation, vortex position, and an O(1) pressure field -- and the
remaining gap is recorded in omni-cortex/docs/solver_status.md rather
than papered over with a loose tolerance.

LBM bounce-back was doing neither of the things its name claims. It was
written as assignment (`f[2] = f[4]`) rather than a swap, discarding the
population being reflected -- bounce-back is a permutation and conserves
mass exactly, so the domain leaked 0.013% of its mass every 100 steps and
would have kept draining. And the pairs used were 5<->8 and 6<->7, which
reverse only the wall-normal component: that is specular reflection, a
free-slip wall, so the no-slip condition the walls were supposed to
impose never held.

Mesh quality:
  - Quadrilateral aspect ratio included the diagonals in the maximum but
    not the minimum, so it could never return 1: a unit square reported
    sqrt(2) and a 2:1 rectangle sqrt(5).
  - Triangle aspect ratio used longest-over-shortest edge, which does not
    detect the failure mode that matters. A sliver with vertices (0,0),
    (10,0), (5,0.1) scores 2.0 -- indistinguishable from a healthy 2:1
    triangle -- while its area is a twentieth of what its edges suggest.
    Now the radius ratio R/2r, which is 1 for equilateral and 1250 for
    that sliver, and which also fixes the quality histogram.
  - StructuredMesh aspect ratio took bounding-box extents and guarded the
    z-extent with `.max(1e-10)`. On a 2-D mesh the depth is exactly zero,
    so the guard became the minimum and a unit square reported 2e10.

Mesh refinement produced meshes that failed their own validation.
`subdivide_triangle` reserved midpoint ids as `next_node_id + k`, then
advanced the counter by 3, after which `refine_cells` called `add_node`
and advanced it three more -- so every refined cell referenced vertices
three ids away from the ones actually created. Separately, the position
lookup selected by slot rather than by id ("This is simplified, should
look up correct midpoint"), so three of four sub-triangles had their
areas computed from the wrong points; the quadrilateral version mapped
every new id to the cell centre.

Fixtures corrected rather than tolerances loosened: a structured mesh
test asserted 0.16 for the average cell volume while the comment beside
it computed 0.25 from the node-count convention the code actually uses;
the Zou-He pressure test built a *velocity* boundary at u = 1.2, far
above the lattice speed of sound, making the density negative; and the
cavity-setup test required the lid to influence the domain centre 16
rows away in 10 steps, which exceeds the lattice propagation speed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 08:40:09 -07:00

795 lines
26 KiB
Rust

// TDD: GREEN phase - Implement unstructured mesh
use super::{Mesh, MeshBounds, MeshStatistics};
use crate::error::{CfdError, CfdResult};
use crate::mesh::entities::{Cell, Face, Node};
use crate::traits::MeshEntity;
use indexmap::IndexMap;
use nalgebra::Vector3;
/// Result of cell subdivision operation
struct SubdivisionResult {
/// New nodes created during subdivision (position, id)
nodes: Vec<(Vector3<f64>, usize)>,
/// New cells created during subdivision (vertices, centroid, volume)
cells: Vec<(Vec<usize>, Vector3<f64>, f64)>,
}
/// Unstructured (irregular) mesh for complex geometries
#[derive(Debug, Clone)]
pub struct UnstructuredMesh {
/// Nodes storage
nodes: IndexMap<usize, Node>,
/// Cells storage
cells: IndexMap<usize, Cell>,
/// Faces storage
faces: IndexMap<usize, Face>,
/// Next available node ID
next_node_id: usize,
/// Next available cell ID
next_cell_id: usize,
/// Next available face ID
next_face_id: usize,
/// Mesh bounds cache
bounds_cache: Option<MeshBounds>,
/// Whether bounds cache is valid
bounds_valid: bool,
}
impl UnstructuredMesh {
/// Create a new empty unstructured mesh
#[must_use]
pub fn new() -> Self {
Self {
nodes: IndexMap::new(),
cells: IndexMap::new(),
faces: IndexMap::new(),
next_node_id: 0,
next_cell_id: 0,
next_face_id: 0,
bounds_cache: None,
bounds_valid: false,
}
}
/// Add a new node to the mesh
pub fn add_node(&mut self, position: Vector3<f64>) -> CfdResult<usize> {
let node_id = self.next_node_id;
let node = Node::new(node_id, position);
self.nodes.insert(node_id, node);
self.next_node_id += 1;
self.bounds_valid = false; // Invalidate bounds cache
Ok(node_id)
}
/// Add a triangular cell
pub fn add_triangle_cell(&mut self, n1: usize, n2: usize, n3: usize) -> CfdResult<usize> {
// Validate that nodes exist
if !self.nodes.contains_key(&n1)
|| !self.nodes.contains_key(&n2)
|| !self.nodes.contains_key(&n3)
{
return Err(CfdError::mesh("One or more nodes do not exist"));
}
let cell_id = self.next_cell_id;
let vertices = vec![n1, n2, n3];
// Calculate centroid
let p1 = self.nodes[&n1].position();
let p2 = self.nodes[&n2].position();
let p3 = self.nodes[&n3].position();
let centroid = (p1 + p2 + p3) / 3.0;
// Calculate area using cross product
let v1 = p2 - p1;
let v2 = p3 - p1;
let area = 0.5 * v1.cross(&v2).magnitude();
let cell = Cell::new(cell_id, vertices, centroid, area);
self.cells.insert(cell_id, cell);
self.next_cell_id += 1;
Ok(cell_id)
}
/// Add a quadrilateral cell
pub fn add_quadrilateral_cell(
&mut self,
n1: usize,
n2: usize,
n3: usize,
n4: usize,
) -> CfdResult<usize> {
// Validate that nodes exist
if !self.nodes.contains_key(&n1)
|| !self.nodes.contains_key(&n2)
|| !self.nodes.contains_key(&n3)
|| !self.nodes.contains_key(&n4)
{
return Err(CfdError::mesh("One or more nodes do not exist"));
}
let cell_id = self.next_cell_id;
let vertices = vec![n1, n2, n3, n4];
// Calculate centroid
let p1 = self.nodes[&n1].position();
let p2 = self.nodes[&n2].position();
let p3 = self.nodes[&n3].position();
let p4 = self.nodes[&n4].position();
let centroid = (p1 + p2 + p3 + p4) / 4.0;
// Calculate area using triangulation
let v1 = p2 - p1;
let v2 = p3 - p1;
let v3 = p4 - p1;
let area1 = 0.5 * v1.cross(&v2).magnitude();
let area2 = 0.5 * v2.cross(&v3).magnitude();
let area = area1 + area2;
let cell = Cell::new(cell_id, vertices, centroid, area);
self.cells.insert(cell_id, cell);
self.next_cell_id += 1;
Ok(cell_id)
}
/// Add a tetrahedral cell
pub fn add_tetrahedron_cell(
&mut self,
n1: usize,
n2: usize,
n3: usize,
n4: usize,
) -> CfdResult<usize> {
// Validate that nodes exist
if !self.nodes.contains_key(&n1)
|| !self.nodes.contains_key(&n2)
|| !self.nodes.contains_key(&n3)
|| !self.nodes.contains_key(&n4)
{
return Err(CfdError::mesh("One or more nodes do not exist"));
}
let cell_id = self.next_cell_id;
let vertices = vec![n1, n2, n3, n4];
// Calculate centroid
let p1 = self.nodes[&n1].position();
let p2 = self.nodes[&n2].position();
let p3 = self.nodes[&n3].position();
let p4 = self.nodes[&n4].position();
let centroid = (p1 + p2 + p3 + p4) / 4.0;
// Calculate volume using scalar triple product
let v1 = p2 - p1;
let v2 = p3 - p1;
let v3 = p4 - p1;
let volume = (1.0 / 6.0) * v1.dot(&v2.cross(&v3)).abs();
let cell = Cell::new(cell_id, vertices, centroid, volume);
self.cells.insert(cell_id, cell);
self.next_cell_id += 1;
Ok(cell_id)
}
/// Get a node by ID
pub fn get_node(&self, node_id: usize) -> CfdResult<&Node> {
self.nodes
.get(&node_id)
.ok_or_else(|| CfdError::mesh("Node not found"))
}
/// Get a cell by ID
pub fn get_cell(&self, cell_id: usize) -> CfdResult<&Cell> {
self.cells
.get(&cell_id)
.ok_or_else(|| CfdError::mesh("Cell not found"))
}
/// Get a face by ID
pub fn get_face(&self, face_id: usize) -> CfdResult<&Face> {
self.faces
.get(&face_id)
.ok_or_else(|| CfdError::mesh("Face not found"))
}
/// Add a face between two cells
pub fn add_face(
&mut self,
vertices: Vec<usize>,
_cell1: Option<usize>,
cell2: Option<usize>,
) -> CfdResult<usize> {
// Validate vertices exist
for &vertex_id in &vertices {
if !self.nodes.contains_key(&vertex_id) {
return Err(CfdError::mesh("Face references non-existent vertex"));
}
}
let face_id = self.next_face_id;
// Calculate centroid
let positions: Vec<Vector3<f64>> = vertices
.iter()
.map(|&id| self.nodes[&id].position())
.collect();
let centroid = positions
.iter()
.fold(Vector3::zeros(), |acc, &pos| acc + pos)
/ positions.len() as f64;
// Calculate area (simplified for different face types)
let area = if vertices.len() == 2 {
// Edge: distance between points
(positions[1] - positions[0]).magnitude()
} else if vertices.len() == 3 {
// Triangle: cross product
let v1 = positions[1] - positions[0];
let v2 = positions[2] - positions[0];
0.5 * v1.cross(&v2).magnitude()
} else if vertices.len() == 4 {
// Quadrilateral: triangulation
let v1 = positions[1] - positions[0];
let v2 = positions[2] - positions[0];
let v3 = positions[3] - positions[0];
let area1 = 0.5 * v1.cross(&v2).magnitude();
let area2 = 0.5 * v2.cross(&v3).magnitude();
area1 + area2
} else {
return Err(CfdError::mesh("Unsupported face type"));
};
// Determine if face is on boundary
let is_boundary = cell2.is_none();
let face = if is_boundary {
// Calculate normal for boundary face
let normal = if vertices.len() >= 3 {
let v1 = positions[1] - positions[0];
let v2 = positions[2] - positions[0];
v1.cross(&v2).normalize()
} else {
Vector3::new(0.0, 0.0, 1.0) // Default normal
};
Face::new_boundary(face_id, vertices, centroid, area, normal)
} else {
Face::new(face_id, vertices, centroid, area)
};
self.faces.insert(face_id, face);
self.next_face_id += 1;
Ok(face_id)
}
/// Generate faces automatically from cells
pub fn generate_faces(&mut self) -> CfdResult<()> {
// This is a simplified implementation
// In practice, this would be more complex to handle shared faces properly
let mut faces_to_add = Vec::new();
for (_, cell) in &self.cells {
let vertices = cell.vertex_indices();
if vertices.len() == 3 {
// Triangle - create 3 edges
for i in 0..3 {
let next_i = (i + 1) % 3;
let edge_vertices = vec![vertices[i], vertices[next_i]];
faces_to_add.push((edge_vertices, Some(cell.id()), None));
}
} else if vertices.len() == 4 {
// Quadrilateral - create 4 edges
for i in 0..4 {
let next_i = (i + 1) % 4;
let edge_vertices = vec![vertices[i], vertices[next_i]];
faces_to_add.push((edge_vertices, Some(cell.id()), None));
}
}
}
// Add faces after collecting
for (vertices, cell1, cell2) in faces_to_add {
let _ = self.add_face(vertices, cell1, cell2);
}
Ok(())
}
/// Calculate mesh bounds
fn calculate_bounds(&self) -> MeshBounds {
if self.nodes.is_empty() {
return MeshBounds::new(Vector3::zeros(), Vector3::zeros());
}
let mut min = Vector3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut max = Vector3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (_, node) in &self.nodes {
let pos = node.position();
min.x = min.x.min(pos.x);
min.y = min.y.min(pos.y);
min.z = min.z.min(pos.z);
max.x = max.x.max(pos.x);
max.y = max.y.max(pos.y);
max.z = max.z.max(pos.z);
}
MeshBounds::new(min, max)
}
}
impl Default for UnstructuredMesh {
fn default() -> Self {
Self::new()
}
}
impl Mesh for UnstructuredMesh {
fn cell_count(&self) -> usize {
self.cells.len()
}
fn node_count(&self) -> usize {
self.nodes.len()
}
fn bounds(&self) -> MeshBounds {
if !self.bounds_valid || self.bounds_cache.is_none() {
let mut mesh_mut = self.clone(); // This is not ideal, but const methods can't modify
mesh_mut.bounds_cache = Some(mesh_mut.calculate_bounds());
mesh_mut.bounds_valid = true;
return mesh_mut.bounds_cache.unwrap();
}
self.bounds_cache.clone().unwrap()
}
fn validate(&self) -> CfdResult<()> {
// Check that all cells have valid vertices
for (_, cell) in &self.cells {
for &vertex_id in cell.vertex_indices() {
if !self.nodes.contains_key(&vertex_id) {
return Err(CfdError::mesh("Cell references non-existent vertex"));
}
}
// Check cell volume is positive
if cell.volume() <= 0.0 {
return Err(CfdError::mesh("Cell has non-positive volume"));
}
}
// Check that all faces have valid vertices
for (_, face) in &self.faces {
for &vertex_id in face.vertex_indices() {
if !self.nodes.contains_key(&vertex_id) {
return Err(CfdError::mesh("Face references non-existent vertex"));
}
}
}
Ok(())
}
fn statistics(&self) -> MeshStatistics {
use crate::mesh::statistics::MeshQualityAnalyzer;
let mut stats = MeshQualityAnalyzer::analyze_mesh(&self.nodes, &self.cells, &self.faces);
// Calculate unstructured mesh specific metrics
stats.orthogonality = MeshQualityAnalyzer::calculate_orthogonality(&self.faces);
stats.non_orthogonality = MeshQualityAnalyzer::calculate_non_orthogonality(&self.faces);
stats
}
fn is_boundary_cell(&self, cell_id: usize) -> bool {
// For unstructured mesh, we need to check if any face of the cell is on boundary
// This is a simplified implementation
self.cells
.get(&cell_id)
.is_some_and(super::super::traits::MeshEntity::is_boundary)
}
fn get_cell_neighbors(&self, cell_id: usize) -> CfdResult<Vec<usize>> {
let cell = self.get_cell(cell_id)?;
let mut neighbors = Vec::new();
// For unstructured mesh, we need to find cells that share faces/edges
let cell_vertices = cell.vertex_indices().to_vec();
for (other_cell_id, other_cell) in &self.cells {
if *other_cell_id == cell_id {
continue;
}
let other_vertices = other_cell.vertex_indices();
// Count shared vertices
let shared_vertices: Vec<_> = cell_vertices
.iter()
.filter(|&&v| other_vertices.contains(&v))
.collect();
// For 2D: cells are neighbors if they share an edge (2 vertices)
// For 3D: cells are neighbors if they share a face (3+ vertices)
let min_shared = if cell_vertices.len() <= 4 { 2 } else { 3 };
if shared_vertices.len() >= min_shared {
neighbors.push(*other_cell_id);
}
}
Ok(neighbors)
}
fn refine(&mut self) -> CfdResult<()> {
// Simple uniform refinement - subdivide all triangular cells
let cells_to_refine: Vec<_> = self.cells.keys().copied().collect();
self.refine_cells(&cells_to_refine)
}
}
impl UnstructuredMesh {
/// Refine specific cells by subdivision
pub fn refine_cells(&mut self, cell_ids: &[usize]) -> CfdResult<()> {
let mut new_cells = Vec::new();
let mut new_nodes = Vec::new();
for &cell_id in cell_ids {
let cell = self
.cells
.get(&cell_id)
.ok_or_else(|| CfdError::mesh("Cell not found for refinement"))?
.clone();
match cell.vertex_indices().len() {
3 => {
// Subdivide triangle into 4 triangles
let subdivided = self.subdivide_triangle(&cell)?;
new_cells.extend(subdivided.cells);
new_nodes.extend(subdivided.nodes);
}
4 => {
// Subdivide quadrilateral into 4 quadrilaterals
let subdivided = self.subdivide_quadrilateral(&cell)?;
new_cells.extend(subdivided.cells);
new_nodes.extend(subdivided.nodes);
}
_ => {
return Err(CfdError::mesh("Unsupported cell type for refinement"));
}
}
}
// Add new nodes
for (position, _) in new_nodes {
self.add_node(position)?;
}
// Remove original cells and add new ones
for &cell_id in cell_ids {
self.cells.remove(&cell_id);
}
// Add new subdivided cells
for (vertices, centroid, volume) in new_cells {
let cell_id = self.next_cell_id;
let cell = Cell::new(cell_id, vertices, centroid, volume);
self.cells.insert(cell_id, cell);
self.next_cell_id += 1;
}
// Invalidate bounds cache
self.bounds_valid = false;
Ok(())
}
/// Subdivide a triangle into 4 smaller triangles
fn subdivide_triangle(&mut self, cell: &Cell) -> CfdResult<SubdivisionResult> {
let vertices = cell.vertex_indices();
if vertices.len() != 3 {
return Err(CfdError::mesh("Expected triangle for subdivision"));
}
let [v0, v1, v2] = [vertices[0], vertices[1], vertices[2]];
let p0 = self.nodes[&v0].position();
let p1 = self.nodes[&v1].position();
let p2 = self.nodes[&v2].position();
// Create midpoint nodes
let mid01 = (p0 + p1) / 2.0;
let mid12 = (p1 + p2) / 2.0;
let mid20 = (p2 + p0) / 2.0;
// Create the midpoint nodes now, so the sub-cells below reference ids
// that exist.
//
// These ids were previously only *reserved* — computed as
// `next_node_id + k` — after which `next_node_id` was advanced by 3
// and `refine_cells` called `add_node` for each midpoint, advancing it
// three more times. The nodes were therefore created with ids three
// higher than the ones the new cells referenced, so every refined cell
// pointed at vertices that did not exist and `validate` rejected the
// mesh.
let mid01_id = self.add_node(mid01)?;
let mid12_id = self.add_node(mid12)?;
let mid20_id = self.add_node(mid20)?;
// Create 4 new triangles
let mut new_cells = Vec::new();
// Corner triangles
let triangles = [
vec![v0, mid01_id, mid20_id],
vec![v1, mid12_id, mid01_id],
vec![v2, mid20_id, mid12_id],
vec![mid01_id, mid12_id, mid20_id], // Center triangle
];
// Every vertex now exists in `self.nodes`, so positions are looked up
// by id.
//
// The previous code selected a position by *slot* instead: a
// not-yet-created id in the first position became `mid01`, in the
// second `mid12`, in the third `mid20`, whichever midpoint the id
// actually named. Three of the four sub-triangles had their areas
// computed from the wrong points as a result.
for triangle in triangles {
let p0 = self.nodes[&triangle[0]].position();
let p1 = self.nodes[&triangle[1]].position();
let p2 = self.nodes[&triangle[2]].position();
let centroid = (p0 + p1 + p2) / 3.0;
let area = 0.5 * (p1 - p0).cross(&(p2 - p0)).magnitude();
new_cells.push((triangle, centroid, area));
}
Ok(SubdivisionResult {
// The nodes are already in the mesh; returning them again would
// have `refine_cells` add duplicates at fresh ids.
nodes: Vec::new(),
cells: new_cells,
})
}
/// Subdivide a quadrilateral into 4 smaller quadrilaterals
fn subdivide_quadrilateral(&mut self, cell: &Cell) -> CfdResult<SubdivisionResult> {
let vertices = cell.vertex_indices();
if vertices.len() != 4 {
return Err(CfdError::mesh("Expected quadrilateral for subdivision"));
}
let [v0, v1, v2, v3] = [vertices[0], vertices[1], vertices[2], vertices[3]];
let p0 = self.nodes[&v0].position();
let p1 = self.nodes[&v1].position();
let p2 = self.nodes[&v2].position();
let p3 = self.nodes[&v3].position();
// Create edge midpoints and cell center
let mid01 = (p0 + p1) / 2.0;
let mid12 = (p1 + p2) / 2.0;
let mid23 = (p2 + p3) / 2.0;
let mid30 = (p3 + p0) / 2.0;
let center = (p0 + p1 + p2 + p3) / 4.0;
// Create the new nodes now, as in `subdivide_triangle` — see the note
// there on why reserving ids and letting `refine_cells` add them
// separately left every refined cell referencing vertices that did
// not exist.
let mid01_id = self.add_node(mid01)?;
let mid12_id = self.add_node(mid12)?;
let mid23_id = self.add_node(mid23)?;
let mid30_id = self.add_node(mid30)?;
let center_id = self.add_node(center)?;
// Create 4 new quadrilaterals
let mut new_cells = Vec::new();
let quads = [
vec![v0, mid01_id, center_id, mid30_id],
vec![mid01_id, v1, mid12_id, center_id],
vec![center_id, mid12_id, v2, mid23_id],
vec![mid30_id, center_id, mid23_id, v3],
];
for quad in quads {
// Every vertex exists now, so this is a lookup rather than the
// previous fallback that mapped *any* new node id to the cell
// centre — which collapsed three of each sub-quad's four corners
// onto the same point.
let positions: Vec<_> = quad.iter().map(|&id| self.nodes[&id].position()).collect();
let centroid = positions
.iter()
.fold(nalgebra::Vector3::zeros(), |acc, &pos| acc + pos)
/ 4.0;
// Area by splitting the quadrilateral along the 0-2 diagonal.
let area = 0.5
* ((positions[1] - positions[0])
.cross(&(positions[2] - positions[0]))
.magnitude()
+ (positions[2] - positions[0])
.cross(&(positions[3] - positions[0]))
.magnitude());
new_cells.push((quad, centroid, area));
}
Ok(SubdivisionResult {
nodes: Vec::new(),
cells: new_cells,
})
}
/// Get cell by ID (helper method)
pub fn get_cell_by_id(&self, cell_id: usize) -> CfdResult<&Cell> {
self.get_cell(cell_id)
}
/// Get nodes for a cell (helper method)
pub fn get_cell_nodes(&self, cell_id: usize) -> CfdResult<Vec<&Node>> {
let cell = self.get_cell(cell_id)?;
let mut nodes = Vec::new();
for &vertex_id in cell.vertex_indices() {
let node = self
.nodes
.get(&vertex_id)
.ok_or_else(|| CfdError::mesh("Node not found"))?;
nodes.push(node);
}
Ok(nodes)
}
/// Compute quality histogram
pub fn compute_quality_histogram(&self, bins: usize) -> CfdResult<Vec<usize>> {
let mut histogram = vec![0; bins];
for (_, cell) in &self.cells {
let nodes = self.get_cell_nodes(cell.id())?;
let aspect_ratio = cell.compute_aspect_ratio(&nodes)?;
// Map aspect ratio to quality (1.0 = perfect, higher = worse)
// Quality = 1.0 / aspect_ratio (clamped between 0 and 1)
let quality = (1.0 / aspect_ratio).min(1.0).max(0.0);
let bin_index = ((quality * bins as f64) as usize).min(bins - 1);
histogram[bin_index] += 1;
}
Ok(histogram)
}
/// Get all faces
#[must_use]
pub fn get_faces(&self) -> Vec<&Face> {
self.faces.values().collect()
}
/// Check if a node exists
#[must_use]
pub fn has_node(&self, node_id: usize) -> bool {
self.nodes.contains_key(&node_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unstructured_mesh_creation() {
let mesh = UnstructuredMesh::new();
assert_eq!(mesh.node_count(), 0);
assert_eq!(mesh.cell_count(), 0);
}
#[test]
fn test_add_nodes() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 1.0, 0.0)).unwrap();
assert_eq!(n1, 0);
assert_eq!(n2, 1);
assert_eq!(n3, 2);
assert_eq!(mesh.node_count(), 3);
}
#[test]
fn test_triangle_cell() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 1.0, 0.0)).unwrap();
let cell = mesh.add_triangle_cell(n1, n2, n3).unwrap();
assert_eq!(cell, 0);
assert_eq!(mesh.cell_count(), 1);
let cell_obj = mesh.get_cell(cell).unwrap();
assert_eq!(cell_obj.vertex_count(), 3);
assert!(cell_obj.volume() > 0.0);
}
#[test]
fn test_quadrilateral_cell() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(1.0, 1.0, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.0, 1.0, 0.0)).unwrap();
let cell = mesh.add_quadrilateral_cell(n1, n2, n3, n4).unwrap();
let cell_obj = mesh.get_cell(cell).unwrap();
assert_eq!(cell_obj.vertex_count(), 4);
assert!((cell_obj.volume() - 1.0).abs() < 1e-10);
}
#[test]
fn test_tetrahedron_cell() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 1.0, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.5, 0.5, 1.0)).unwrap();
let cell = mesh.add_tetrahedron_cell(n1, n2, n3, n4).unwrap();
let cell_obj = mesh.get_cell(cell).unwrap();
assert_eq!(cell_obj.vertex_count(), 4);
assert!(cell_obj.volume() > 0.0);
}
#[test]
fn test_invalid_cell_creation() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
// Try to create triangle with non-existent nodes
assert!(mesh.add_triangle_cell(n1, 999, 1000).is_err());
}
#[test]
fn test_mesh_bounds() {
let mut mesh = UnstructuredMesh::new();
mesh.add_node(Vector3::new(-1.0, -2.0, -3.0)).unwrap();
mesh.add_node(Vector3::new(1.0, 2.0, 3.0)).unwrap();
mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let bounds = mesh.bounds();
assert_eq!(bounds.min, Vector3::new(-1.0, -2.0, -3.0));
assert_eq!(bounds.max, Vector3::new(1.0, 2.0, 3.0));
}
#[test]
fn test_mesh_validation() {
let mut mesh = UnstructuredMesh::new();
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 1.0, 0.0)).unwrap();
mesh.add_triangle_cell(n1, n2, n3).unwrap();
assert!(mesh.validate().is_ok());
}
}