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
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]>
795 lines
26 KiB
Rust
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());
|
|
}
|
|
}
|