Skip to main content

oxiphysics_io/exodus/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::manual_div_ceil)]
6#![allow(clippy::items_after_test_module)]
7use super::types::{
8    ElementQuality, ExodusMesh, ExodusResult, ExodusSideSet, ExodusTimeStep, ExodusVariable,
9    FieldStats, MeshPartition, MeshValidationReport,
10};
11
12/// Write an `ExodusMesh` to a simplified text representation.
13///
14/// Format:
15/// ```text
16/// EXODUS_MESH
17/// TITLE `title`
18/// NODES `count`
19/// `x` `y` `z`
20/// ...
21/// BLOCKS `count`
22/// BLOCK `id` `name` `etype` <n_nodes_per_elem> `n_elements`
23/// `conn0` `conn1` ...
24/// ...
25/// NODE_SETS `count`
26/// NODE_SET `id` `name` `count`
27/// `nid0` `nid1` ...
28/// SIDE_SETS `count`
29/// SIDE_SET `id` `name` `count`
30/// `eid0` `sid0` `eid1` `sid1` ...
31/// END
32/// ```
33pub fn write_text(mesh: &ExodusMesh) -> String {
34    let mut s = String::new();
35    s.push_str("EXODUS_MESH\n");
36    s.push_str(&format!("TITLE {}\n", mesh.title));
37    s.push_str(&format!("NODES {}\n", mesh.coordinates.len()));
38    for coord in &mesh.coordinates {
39        s.push_str(&format!("{} {} {}\n", coord[0], coord[1], coord[2]));
40    }
41    s.push_str(&format!("BLOCKS {}\n", mesh.blocks.len()));
42    for block in &mesh.blocks {
43        let n_elems = block
44            .connectivity
45            .len()
46            .checked_div(block.n_nodes_per_elem)
47            .unwrap_or(0);
48        s.push_str(&format!(
49            "BLOCK {} {} {} {} {}\n",
50            block.id, block.name, block.element_type, block.n_nodes_per_elem, n_elems
51        ));
52        if !block.connectivity.is_empty() {
53            let strs: Vec<String> = block.connectivity.iter().map(|v| v.to_string()).collect();
54            s.push_str(&strs.join(" "));
55            s.push('\n');
56        }
57    }
58    s.push_str(&format!("NODE_SETS {}\n", mesh.node_sets.len()));
59    for ns in &mesh.node_sets {
60        s.push_str(&format!(
61            "NODE_SET {} {} {}\n",
62            ns.id,
63            ns.name,
64            ns.node_ids.len()
65        ));
66        if !ns.node_ids.is_empty() {
67            let strs: Vec<String> = ns.node_ids.iter().map(|v| v.to_string()).collect();
68            s.push_str(&strs.join(" "));
69            s.push('\n');
70        }
71    }
72    s.push_str(&format!("SIDE_SETS {}\n", mesh.side_sets.len()));
73    for ss in &mesh.side_sets {
74        s.push_str(&format!(
75            "SIDE_SET {} {} {}\n",
76            ss.id,
77            ss.name,
78            ss.element_ids.len()
79        ));
80        if !ss.element_ids.is_empty() {
81            let mut pairs = Vec::new();
82            for (eid, sid) in ss.element_ids.iter().zip(ss.side_ids.iter()) {
83                pairs.push(format!("{} {}", eid, sid));
84            }
85            s.push_str(&pairs.join(" "));
86            s.push('\n');
87        }
88    }
89    s.push_str("END\n");
90    s
91}
92/// Parse a text-format Exodus mesh string back into an `ExodusMesh`.
93pub fn parse_text(s: &str) -> Result<ExodusMesh, String> {
94    let mut lines = s.lines().peekable();
95    match lines.next() {
96        Some("EXODUS_MESH") => {}
97        other => return Err(format!("Expected EXODUS_MESH header, got: {:?}", other)),
98    }
99    let title = match lines.next() {
100        Some(line) if line.starts_with("TITLE ") => line[6..].trim().to_string(),
101        other => return Err(format!("Expected TITLE, got: {:?}", other)),
102    };
103    let mut mesh = ExodusMesh::new(&title);
104    let n_nodes: usize = match lines.next() {
105        Some(line) if line.starts_with("NODES ") => line[6..]
106            .trim()
107            .parse()
108            .map_err(|e| format!("Bad node count: {}", e))?,
109        other => return Err(format!("Expected NODES, got: {:?}", other)),
110    };
111    for _ in 0..n_nodes {
112        let line = lines.next().ok_or("Unexpected EOF reading nodes")?;
113        let parts: Vec<&str> = line.split_whitespace().collect();
114        if parts.len() < 3 {
115            return Err(format!("Expected 3 coordinates, got: {}", line));
116        }
117        let x: f64 = parts[0].parse().map_err(|e| format!("Bad x: {}", e))?;
118        let y: f64 = parts[1].parse().map_err(|e| format!("Bad y: {}", e))?;
119        let z: f64 = parts[2].parse().map_err(|e| format!("Bad z: {}", e))?;
120        mesh.add_node(x, y, z);
121    }
122    let n_blocks: usize = match lines.next() {
123        Some(line) if line.starts_with("BLOCKS ") => line[7..]
124            .trim()
125            .parse()
126            .map_err(|e| format!("Bad block count: {}", e))?,
127        other => return Err(format!("Expected BLOCKS, got: {:?}", other)),
128    };
129    for _ in 0..n_blocks {
130        let header = lines.next().ok_or("Unexpected EOF reading block header")?;
131        let parts: Vec<&str> = header.split_whitespace().collect();
132        if parts.len() < 6 || parts[0] != "BLOCK" {
133            return Err(format!("Expected BLOCK header, got: {}", header));
134        }
135        let id: u32 = parts[1]
136            .parse()
137            .map_err(|e| format!("Bad block id: {}", e))?;
138        let name = parts[2].to_string();
139        let etype = parts[3].to_string();
140        let n_nodes_per_elem: usize = parts[4]
141            .parse()
142            .map_err(|e| format!("Bad n_nodes_per_elem: {}", e))?;
143        let n_elems: usize = parts[5]
144            .parse()
145            .map_err(|e| format!("Bad n_elems: {}", e))?;
146        let block_idx = mesh.add_block(id, &name, &etype, n_nodes_per_elem);
147        if n_elems > 0 {
148            let conn_line = lines.next().ok_or("Unexpected EOF reading connectivity")?;
149            let conn: Result<Vec<usize>, _> = conn_line
150                .split_whitespace()
151                .map(|v| v.parse::<usize>())
152                .collect();
153            let conn = conn.map_err(|e| format!("Bad connectivity: {}", e))?;
154            mesh.add_element_to_block(block_idx, &conn);
155        }
156    }
157    let n_node_sets: usize = match lines.next() {
158        Some(line) if line.starts_with("NODE_SETS ") => line[10..]
159            .trim()
160            .parse()
161            .map_err(|e| format!("Bad node set count: {}", e))?,
162        other => return Err(format!("Expected NODE_SETS, got: {:?}", other)),
163    };
164    for _ in 0..n_node_sets {
165        let header = lines
166            .next()
167            .ok_or("Unexpected EOF reading node set header")?;
168        let parts: Vec<&str> = header.split_whitespace().collect();
169        if parts.len() < 4 || parts[0] != "NODE_SET" {
170            return Err(format!("Expected NODE_SET header, got: {}", header));
171        }
172        let id: u32 = parts[1]
173            .parse()
174            .map_err(|e| format!("Bad node set id: {}", e))?;
175        let name = parts[2].to_string();
176        let count: usize = parts[3]
177            .parse()
178            .map_err(|e| format!("Bad node set count: {}", e))?;
179        let nodes = if count > 0 {
180            let data_line = lines.next().ok_or("Unexpected EOF reading node set data")?;
181            let parsed: Result<Vec<usize>, _> = data_line
182                .split_whitespace()
183                .map(|v| v.parse::<usize>())
184                .collect();
185            parsed.map_err(|e| format!("Bad node set data: {}", e))?
186        } else {
187            Vec::new()
188        };
189        mesh.add_node_set(id, &name, nodes);
190    }
191    let n_side_sets: usize = match lines.next() {
192        Some(line) if line.starts_with("SIDE_SETS ") => line[10..]
193            .trim()
194            .parse()
195            .map_err(|e| format!("Bad side set count: {}", e))?,
196        other => return Err(format!("Expected SIDE_SETS, got: {:?}", other)),
197    };
198    for _ in 0..n_side_sets {
199        let header = lines
200            .next()
201            .ok_or("Unexpected EOF reading side set header")?;
202        let parts: Vec<&str> = header.split_whitespace().collect();
203        if parts.len() < 4 || parts[0] != "SIDE_SET" {
204            return Err(format!("Expected SIDE_SET header, got: {}", header));
205        }
206        let id: u32 = parts[1]
207            .parse()
208            .map_err(|e| format!("Bad side set id: {}", e))?;
209        let name = parts[2].to_string();
210        let count: usize = parts[3]
211            .parse()
212            .map_err(|e| format!("Bad side set count: {}", e))?;
213        let (element_ids, side_ids) = if count > 0 {
214            let data_line = lines.next().ok_or("Unexpected EOF reading side set data")?;
215            let nums: Result<Vec<usize>, _> = data_line
216                .split_whitespace()
217                .map(|v| v.parse::<usize>())
218                .collect();
219            let nums = nums.map_err(|e| format!("Bad side set data: {}", e))?;
220            let mut eids = Vec::with_capacity(count);
221            let mut sids = Vec::with_capacity(count);
222            for chunk in nums.chunks(2) {
223                if chunk.len() == 2 {
224                    eids.push(chunk[0]);
225                    sids.push(chunk[1]);
226                }
227            }
228            (eids, sids)
229        } else {
230            (Vec::new(), Vec::new())
231        };
232        mesh.side_sets.push(ExodusSideSet {
233            id,
234            name,
235            element_ids,
236            side_ids,
237        });
238    }
239    match lines.next() {
240        Some("END") => {}
241        other => return Err(format!("Expected END, got: {:?}", other)),
242    }
243    Ok(mesh)
244}
245/// Compute the axis-aligned bounding box of a mesh.
246///
247/// Returns `(min, max)` corner points.
248pub fn bounding_box(mesh: &ExodusMesh) -> ([f64; 3], [f64; 3]) {
249    if mesh.coordinates.is_empty() {
250        return ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
251    }
252    let first = mesh.coordinates[0];
253    let mut min = first;
254    let mut max = first;
255    for &coord in &mesh.coordinates {
256        for i in 0..3 {
257            if coord[i] < min[i] {
258                min[i] = coord[i];
259            }
260            if coord[i] > max[i] {
261                max[i] = coord[i];
262            }
263        }
264    }
265    (min, max)
266}
267/// Translate all nodes in the mesh by a constant offset.
268pub fn translate_mesh(mesh: &mut ExodusMesh, offset: [f64; 3]) {
269    for coord in &mut mesh.coordinates {
270        coord[0] += offset[0];
271        coord[1] += offset[1];
272        coord[2] += offset[2];
273    }
274}
275/// Compute the time values for `n_steps` linearly spaced from `t_start` to `t_end`.
276#[allow(dead_code)]
277pub fn linear_time_steps(t_start: f64, t_end: f64, n_steps: usize) -> Vec<f64> {
278    if n_steps == 0 {
279        return vec![];
280    }
281    if n_steps == 1 {
282        return vec![t_start];
283    }
284    let dt = (t_end - t_start) / (n_steps - 1) as f64;
285    (0..n_steps).map(|i| t_start + i as f64 * dt).collect()
286}
287/// Compute the time values for `n_steps` logarithmically spaced from `t_start` to `t_end`.
288///
289/// Both `t_start` and `t_end` must be positive.
290#[allow(dead_code)]
291pub fn log_time_steps(t_start: f64, t_end: f64, n_steps: usize) -> Vec<f64> {
292    if n_steps == 0 || t_start <= 0.0 || t_end <= 0.0 {
293        return vec![];
294    }
295    if n_steps == 1 {
296        return vec![t_start];
297    }
298    let log_start = t_start.ln();
299    let log_end = t_end.ln();
300    let d = (log_end - log_start) / (n_steps - 1) as f64;
301    (0..n_steps)
302        .map(|i| (log_start + i as f64 * d).exp())
303        .collect()
304}
305/// Add a time step with a scalar node variable to an `ExodusResult`.
306#[allow(dead_code)]
307pub fn add_node_variable_step(
308    result: &mut ExodusResult,
309    time: f64,
310    var_name: &str,
311    values: Vec<f64>,
312) {
313    result.time_steps.push(ExodusTimeStep {
314        time,
315        variables: vec![ExodusVariable {
316            name: var_name.to_string(),
317            entity_type: "node".to_string(),
318            values,
319        }],
320    });
321}
322/// Add a time step with an element variable to an `ExodusResult`.
323#[allow(dead_code)]
324pub fn add_element_variable_step(
325    result: &mut ExodusResult,
326    time: f64,
327    var_name: &str,
328    values: Vec<f64>,
329) {
330    result.time_steps.push(ExodusTimeStep {
331        time,
332        variables: vec![ExodusVariable {
333            name: var_name.to_string(),
334            entity_type: "element".to_string(),
335            values,
336        }],
337    });
338}
339/// Validate an `ExodusMesh` for common consistency issues.
340///
341/// Checks:
342/// - Node connectivity: all node IDs in blocks are valid (1-based, ≤ n_nodes).
343/// - Block completeness: connectivity length is divisible by `n_nodes_per_elem`.
344/// - Node set node IDs are in valid range.
345/// - Side set element IDs are in valid range.
346/// - No duplicate block IDs.
347/// - No duplicate node-set IDs.
348#[allow(dead_code)]
349pub fn validate_mesh(mesh: &ExodusMesh) -> MeshValidationReport {
350    let mut report = MeshValidationReport {
351        is_valid: true,
352        errors: Vec::new(),
353        warnings: Vec::new(),
354    };
355    let n_nodes = mesh.node_count();
356    let total_elements = mesh.element_count();
357    if n_nodes == 0 {
358        report.add_warning("Mesh has no nodes".to_string());
359    }
360    if total_elements == 0 {
361        report.add_warning("Mesh has no elements".to_string());
362    }
363    let mut seen_block_ids: Vec<u32> = Vec::new();
364    for (bi, block) in mesh.blocks.iter().enumerate() {
365        if seen_block_ids.contains(&block.id) {
366            report.add_error(format!(
367                "Duplicate block id {} at block index {}",
368                block.id, bi
369            ));
370        } else {
371            seen_block_ids.push(block.id);
372        }
373        if block.n_nodes_per_elem == 0 {
374            report.add_error(format!("Block {} has zero nodes_per_elem", block.id));
375            continue;
376        }
377        if block.connectivity.len() % block.n_nodes_per_elem != 0 {
378            report.add_error(format!(
379                "Block {}: connectivity len {} not divisible by n_nodes_per_elem {}",
380                block.id,
381                block.connectivity.len(),
382                block.n_nodes_per_elem
383            ));
384        }
385        for &nid in &block.connectivity {
386            if nid == 0 || nid > n_nodes {
387                report.add_error(format!(
388                    "Block {}: node id {} out of range [1, {}]",
389                    block.id, nid, n_nodes
390                ));
391            }
392        }
393    }
394    let mut seen_ns_ids: Vec<u32> = Vec::new();
395    for ns in &mesh.node_sets {
396        if seen_ns_ids.contains(&ns.id) {
397            report.add_error(format!("Duplicate node set id {}", ns.id));
398        } else {
399            seen_ns_ids.push(ns.id);
400        }
401        for &nid in &ns.node_ids {
402            if nid == 0 || nid > n_nodes {
403                report.add_error(format!(
404                    "Node set {}: node id {} out of range [1, {}]",
405                    ns.id, nid, n_nodes
406                ));
407            }
408        }
409    }
410    for ss in &mesh.side_sets {
411        if ss.element_ids.len() != ss.side_ids.len() {
412            report.add_error(format!(
413                "Side set {}: element_ids and side_ids have different lengths ({} vs {})",
414                ss.id,
415                ss.element_ids.len(),
416                ss.side_ids.len()
417            ));
418        }
419        for &eid in &ss.element_ids {
420            if eid == 0 || eid > total_elements {
421                report.add_error(format!(
422                    "Side set {}: element id {} out of range [1, {}]",
423                    ss.id, eid, total_elements
424                ));
425            }
426        }
427    }
428    report
429}
430/// Scale all node coordinates by a uniform factor.
431#[allow(dead_code)]
432pub fn scale_mesh(mesh: &mut ExodusMesh, factor: f64) {
433    for coord in &mut mesh.coordinates {
434        coord[0] *= factor;
435        coord[1] *= factor;
436        coord[2] *= factor;
437    }
438}
439/// Compute the centroid of a mesh (arithmetic mean of node coordinates).
440#[allow(dead_code)]
441pub fn mesh_centroid(mesh: &ExodusMesh) -> [f64; 3] {
442    let n = mesh.coordinates.len();
443    if n == 0 {
444        return [0.0, 0.0, 0.0];
445    }
446    let mut sum = [0.0_f64; 3];
447    for &c in &mesh.coordinates {
448        sum[0] += c[0];
449        sum[1] += c[1];
450        sum[2] += c[2];
451    }
452    [sum[0] / n as f64, sum[1] / n as f64, sum[2] / n as f64]
453}
454/// Build a unit-cube HEX8 mesh with 8 nodes and 1 element.
455#[allow(dead_code)]
456pub fn build_unit_hex_mesh(title: &str) -> ExodusMesh {
457    let mut mesh = ExodusMesh::new(title);
458    let coords = [
459        (0.0, 0.0, 0.0),
460        (1.0, 0.0, 0.0),
461        (1.0, 1.0, 0.0),
462        (0.0, 1.0, 0.0),
463        (0.0, 0.0, 1.0),
464        (1.0, 0.0, 1.0),
465        (1.0, 1.0, 1.0),
466        (0.0, 1.0, 1.0),
467    ];
468    for (x, y, z) in coords {
469        mesh.add_node(x, y, z);
470    }
471    let bi = mesh.add_block(1, "hex_block", "HEX8", 8);
472    mesh.add_element_to_block(bi, &[1, 2, 3, 4, 5, 6, 7, 8]);
473    mesh
474}
475/// Build a unit tetrahedron mesh with 4 nodes and 1 TET4 element.
476#[allow(dead_code)]
477pub fn build_unit_tet_mesh(title: &str) -> ExodusMesh {
478    let mut mesh = ExodusMesh::new(title);
479    mesh.add_node(0.0, 0.0, 0.0);
480    mesh.add_node(1.0, 0.0, 0.0);
481    mesh.add_node(0.0, 1.0, 0.0);
482    mesh.add_node(0.0, 0.0, 1.0);
483    let bi = mesh.add_block(1, "tet_block", "TET4", 4);
484    mesh.add_element_to_block(bi, &[1, 2, 3, 4]);
485    mesh
486}
487/// Merge two meshes together (renumber the second mesh's node and element IDs).
488///
489/// Returns a new mesh combining both. Node IDs in the second mesh are offset
490/// by the number of nodes in the first mesh.
491#[allow(dead_code)]
492pub fn merge_meshes(a: &ExodusMesh, b: &ExodusMesh) -> ExodusMesh {
493    let mut out = ExodusMesh::new(&format!("merged_{}_{}", a.title, b.title));
494    for &c in &a.coordinates {
495        out.coordinates.push(c);
496    }
497    let offset = a.node_count();
498    for &c in &b.coordinates {
499        out.coordinates.push(c);
500    }
501    for block in &a.blocks {
502        let bi = out.add_block(
503            block.id,
504            &block.name,
505            &block.element_type,
506            block.n_nodes_per_elem,
507        );
508        out.add_element_to_block(bi, &block.connectivity);
509    }
510    let max_bid = a.blocks.iter().map(|b| b.id).max().unwrap_or(0);
511    for block in &b.blocks {
512        let new_id = block.id + max_bid;
513        let bi = out.add_block(
514            new_id,
515            &block.name,
516            &block.element_type,
517            block.n_nodes_per_elem,
518        );
519        let shifted: Vec<usize> = block.connectivity.iter().map(|&n| n + offset).collect();
520        out.add_element_to_block(bi, &shifted);
521    }
522    for ns in &a.node_sets {
523        out.add_node_set(ns.id, &ns.name, ns.node_ids.clone());
524    }
525    for ns in &b.node_sets {
526        let shifted: Vec<usize> = ns.node_ids.iter().map(|&n| n + offset).collect();
527        out.add_node_set(ns.id + a.node_sets.len() as u32, &ns.name, shifted);
528    }
529    out
530}
531#[cfg(test)]
532mod tests {
533    use super::*;
534    fn make_simple_mesh() -> ExodusMesh {
535        let mut mesh = ExodusMesh::new("test_mesh");
536        mesh.add_node(0.0, 0.0, 0.0);
537        mesh.add_node(1.0, 0.0, 0.0);
538        mesh.add_node(1.0, 1.0, 0.0);
539        mesh.add_node(0.0, 1.0, 0.0);
540        let bi = mesh.add_block(1, "solid", "QUAD4", 4);
541        mesh.add_element_to_block(bi, &[1, 2, 3, 4]);
542        mesh
543    }
544    #[test]
545    fn test_new_mesh_title() {
546        let mesh = ExodusMesh::new("MyMesh");
547        assert_eq!(mesh.title, "MyMesh");
548    }
549    #[test]
550    fn test_new_mesh_empty() {
551        let mesh = ExodusMesh::new("empty");
552        assert_eq!(mesh.node_count(), 0);
553        assert_eq!(mesh.element_count(), 0);
554        assert!(mesh.blocks.is_empty());
555        assert!(mesh.node_sets.is_empty());
556        assert!(mesh.side_sets.is_empty());
557    }
558    #[test]
559    fn test_add_node_returns_one_based_id() {
560        let mut mesh = ExodusMesh::new("t");
561        let id1 = mesh.add_node(1.0, 2.0, 3.0);
562        let id2 = mesh.add_node(4.0, 5.0, 6.0);
563        assert_eq!(id1, 1);
564        assert_eq!(id2, 2);
565    }
566    #[test]
567    fn test_node_count() {
568        let mesh = make_simple_mesh();
569        assert_eq!(mesh.node_count(), 4);
570    }
571    #[test]
572    fn test_element_count_quad() {
573        let mesh = make_simple_mesh();
574        assert_eq!(mesh.element_count(), 1);
575    }
576    #[test]
577    fn test_add_block_returns_index() {
578        let mut mesh = ExodusMesh::new("t");
579        let idx0 = mesh.add_block(1, "a", "TET4", 4);
580        let idx1 = mesh.add_block(2, "b", "HEX8", 8);
581        assert_eq!(idx0, 0);
582        assert_eq!(idx1, 1);
583    }
584    #[test]
585    fn test_block_properties() {
586        let mut mesh = ExodusMesh::new("t");
587        mesh.add_block(42, "myblock", "HEX8", 8);
588        assert_eq!(mesh.blocks[0].id, 42);
589        assert_eq!(mesh.blocks[0].name, "myblock");
590        assert_eq!(mesh.blocks[0].element_type, "HEX8");
591        assert_eq!(mesh.blocks[0].n_nodes_per_elem, 8);
592    }
593    #[test]
594    fn test_add_node_set() {
595        let mut mesh = ExodusMesh::new("t");
596        mesh.add_node(0.0, 0.0, 0.0);
597        mesh.add_node(1.0, 0.0, 0.0);
598        mesh.add_node_set(10, "bottom", vec![1, 2]);
599        assert_eq!(mesh.node_sets.len(), 1);
600        assert_eq!(mesh.node_sets[0].id, 10);
601        assert_eq!(mesh.node_sets[0].name, "bottom");
602        assert_eq!(mesh.node_sets[0].node_ids, vec![1, 2]);
603    }
604    #[test]
605    fn test_element_count_multiple_blocks() {
606        let mut mesh = ExodusMesh::new("t");
607        for i in 0..8 {
608            mesh.add_node(i as f64, 0.0, 0.0);
609        }
610        let b0 = mesh.add_block(1, "b0", "QUAD4", 4);
611        mesh.add_element_to_block(b0, &[1, 2, 3, 4]);
612        mesh.add_element_to_block(b0, &[5, 6, 7, 8]);
613        let b1 = mesh.add_block(2, "b1", "TET4", 4);
614        mesh.add_element_to_block(b1, &[1, 2, 3, 4]);
615        assert_eq!(mesh.element_count(), 3);
616    }
617    #[test]
618    fn test_bounding_box_single_node() {
619        let mut mesh = ExodusMesh::new("t");
620        mesh.add_node(3.0, 4.0, 5.0);
621        let (min, max) = bounding_box(&mesh);
622        assert_eq!(min, [3.0, 4.0, 5.0]);
623        assert_eq!(max, [3.0, 4.0, 5.0]);
624    }
625    #[test]
626    fn test_bounding_box_multiple_nodes() {
627        let mesh = make_simple_mesh();
628        let (min, max) = bounding_box(&mesh);
629        assert_eq!(min, [0.0, 0.0, 0.0]);
630        assert_eq!(max, [1.0, 1.0, 0.0]);
631    }
632    #[test]
633    fn test_bounding_box_empty_mesh() {
634        let mesh = ExodusMesh::new("empty");
635        let (min, max) = bounding_box(&mesh);
636        assert_eq!(min, [0.0, 0.0, 0.0]);
637        assert_eq!(max, [0.0, 0.0, 0.0]);
638    }
639    #[test]
640    fn test_bounding_box_negative_coords() {
641        let mut mesh = ExodusMesh::new("t");
642        mesh.add_node(-5.0, -3.0, -1.0);
643        mesh.add_node(2.0, 4.0, 6.0);
644        let (min, max) = bounding_box(&mesh);
645        assert_eq!(min, [-5.0, -3.0, -1.0]);
646        assert_eq!(max, [2.0, 4.0, 6.0]);
647    }
648    #[test]
649    fn test_translate_mesh() {
650        let mut mesh = make_simple_mesh();
651        translate_mesh(&mut mesh, [1.0, 2.0, 3.0]);
652        assert_eq!(mesh.coordinates[0], [1.0, 2.0, 3.0]);
653        assert_eq!(mesh.coordinates[1], [2.0, 2.0, 3.0]);
654        assert_eq!(mesh.coordinates[2], [2.0, 3.0, 3.0]);
655        assert_eq!(mesh.coordinates[3], [1.0, 3.0, 3.0]);
656    }
657    #[test]
658    fn test_translate_mesh_zero() {
659        let mut mesh = make_simple_mesh();
660        let orig: Vec<[f64; 3]> = mesh.coordinates.clone();
661        translate_mesh(&mut mesh, [0.0, 0.0, 0.0]);
662        assert_eq!(mesh.coordinates, orig);
663    }
664    #[test]
665    fn test_write_text_roundtrip() {
666        let mesh = make_simple_mesh();
667        let text = write_text(&mesh);
668        let parsed = parse_text(&text).expect("parse failed");
669        assert_eq!(parsed.title, mesh.title);
670        assert_eq!(parsed.node_count(), mesh.node_count());
671        assert_eq!(parsed.element_count(), mesh.element_count());
672    }
673    #[test]
674    fn test_write_text_contains_header() {
675        let mesh = make_simple_mesh();
676        let text = write_text(&mesh);
677        assert!(text.starts_with("EXODUS_MESH\n"));
678        assert!(text.contains("TITLE test_mesh"));
679        assert!(text.contains("END"));
680    }
681    #[test]
682    fn test_parse_text_coordinates() {
683        let mesh = make_simple_mesh();
684        let text = write_text(&mesh);
685        let parsed = parse_text(&text).expect("parse failed");
686        assert!((parsed.coordinates[0][0] - 0.0).abs() < 1e-12);
687        assert!((parsed.coordinates[1][0] - 1.0).abs() < 1e-12);
688    }
689    #[test]
690    fn test_parse_text_block_type() {
691        let mesh = make_simple_mesh();
692        let text = write_text(&mesh);
693        let parsed = parse_text(&text).expect("parse failed");
694        assert_eq!(parsed.blocks[0].element_type, "QUAD4");
695        assert_eq!(parsed.blocks[0].n_nodes_per_elem, 4);
696    }
697    #[test]
698    fn test_parse_text_bad_header() {
699        let result = parse_text("BAD_HEADER\n");
700        assert!(result.is_err());
701    }
702    #[test]
703    fn test_parse_text_node_sets_roundtrip() {
704        let mut mesh = ExodusMesh::new("ns_test");
705        mesh.add_node(0.0, 0.0, 0.0);
706        mesh.add_node(1.0, 0.0, 0.0);
707        mesh.add_node_set(5, "left", vec![1]);
708        mesh.add_node_set(6, "right", vec![2]);
709        let text = write_text(&mesh);
710        let parsed = parse_text(&text).expect("parse failed");
711        assert_eq!(parsed.node_sets.len(), 2);
712        assert_eq!(parsed.node_sets[0].id, 5);
713        assert_eq!(parsed.node_sets[0].name, "left");
714        assert_eq!(parsed.node_sets[0].node_ids, vec![1]);
715        assert_eq!(parsed.node_sets[1].id, 6);
716    }
717    #[test]
718    fn test_side_sets_roundtrip() {
719        let mut mesh = ExodusMesh::new("ss_test");
720        for i in 0..4 {
721            mesh.add_node(i as f64, 0.0, 0.0);
722        }
723        let bi = mesh.add_block(1, "b", "QUAD4", 4);
724        mesh.add_element_to_block(bi, &[1, 2, 3, 4]);
725        mesh.side_sets.push(ExodusSideSet {
726            id: 10,
727            name: "top".to_string(),
728            element_ids: vec![1],
729            side_ids: vec![3],
730        });
731        let text = write_text(&mesh);
732        let parsed = parse_text(&text).expect("parse failed");
733        assert_eq!(parsed.side_sets.len(), 1);
734        assert_eq!(parsed.side_sets[0].id, 10);
735        assert_eq!(parsed.side_sets[0].element_ids, vec![1]);
736        assert_eq!(parsed.side_sets[0].side_ids, vec![3]);
737    }
738    #[test]
739    fn test_exodus_result_write_summary() {
740        let mut mesh = ExodusMesh::new("result_test");
741        mesh.add_node(0.0, 0.0, 0.0);
742        let bi = mesh.add_block(1, "b", "TET4", 4);
743        mesh.add_element_to_block(bi, &[1, 1, 1, 1]);
744        let result = ExodusResult {
745            mesh,
746            time_steps: vec![
747                ExodusTimeStep {
748                    time: 0.0,
749                    variables: vec![ExodusVariable {
750                        name: "displacement".to_string(),
751                        entity_type: "node".to_string(),
752                        values: vec![0.0],
753                    }],
754                },
755                ExodusTimeStep {
756                    time: 1.0,
757                    variables: vec![],
758                },
759            ],
760        };
761        let summary = result.write_summary();
762        assert!(summary.contains("Title: result_test"));
763        assert!(summary.contains("Nodes: 1"));
764        assert!(summary.contains("Time Steps: 2"));
765        assert!(summary.contains("t=0.000000"));
766        assert!(summary.contains("t=1.000000"));
767    }
768    #[test]
769    fn test_hex8_mesh() {
770        let mut mesh = ExodusMesh::new("hex_mesh");
771        let coords = [
772            (0.0, 0.0, 0.0),
773            (1.0, 0.0, 0.0),
774            (1.0, 1.0, 0.0),
775            (0.0, 1.0, 0.0),
776            (0.0, 0.0, 1.0),
777            (1.0, 0.0, 1.0),
778            (1.0, 1.0, 1.0),
779            (0.0, 1.0, 1.0),
780        ];
781        for (x, y, z) in coords {
782            mesh.add_node(x, y, z);
783        }
784        let bi = mesh.add_block(1, "vol", "HEX8", 8);
785        mesh.add_element_to_block(bi, &[1, 2, 3, 4, 5, 6, 7, 8]);
786        assert_eq!(mesh.node_count(), 8);
787        assert_eq!(mesh.element_count(), 1);
788        let (min, max) = bounding_box(&mesh);
789        assert_eq!(min, [0.0, 0.0, 0.0]);
790        assert_eq!(max, [1.0, 1.0, 1.0]);
791    }
792    #[test]
793    fn test_write_text_empty_mesh() {
794        let mesh = ExodusMesh::new("empty");
795        let text = write_text(&mesh);
796        let parsed = parse_text(&text).expect("parse of empty mesh failed");
797        assert_eq!(parsed.node_count(), 0);
798        assert_eq!(parsed.element_count(), 0);
799    }
800    #[test]
801    fn test_linear_time_steps_count() {
802        let ts = linear_time_steps(0.0, 1.0, 5);
803        assert_eq!(ts.len(), 5);
804        assert!((ts[0] - 0.0).abs() < 1e-12);
805        assert!((ts[4] - 1.0).abs() < 1e-12);
806    }
807    #[test]
808    fn test_linear_time_steps_spacing() {
809        let ts = linear_time_steps(0.0, 4.0, 5);
810        for w in ts.windows(2) {
811            assert!((w[1] - w[0] - 1.0).abs() < 1e-12, "Spacing should be 1.0");
812        }
813    }
814    #[test]
815    fn test_linear_time_steps_empty() {
816        let ts = linear_time_steps(0.0, 1.0, 0);
817        assert!(ts.is_empty());
818    }
819    #[test]
820    fn test_linear_time_steps_single() {
821        let ts = linear_time_steps(3.0, 7.0, 1);
822        assert_eq!(ts.len(), 1);
823        assert!((ts[0] - 3.0).abs() < 1e-12);
824    }
825    #[test]
826    fn test_log_time_steps_count() {
827        let ts = log_time_steps(1.0, 100.0, 3);
828        assert_eq!(ts.len(), 3);
829        assert!((ts[0] - 1.0).abs() < 1e-9);
830        assert!((ts[2] - 100.0).abs() < 1e-6);
831    }
832    #[test]
833    fn test_log_time_steps_monotone() {
834        let ts = log_time_steps(0.1, 10.0, 10);
835        for w in ts.windows(2) {
836            assert!(w[1] > w[0], "Log steps should be monotonically increasing");
837        }
838    }
839    #[test]
840    fn test_add_node_variable_step() {
841        let mesh = ExodusMesh::new("t");
842        let mut result = ExodusResult {
843            mesh,
844            time_steps: vec![],
845        };
846        add_node_variable_step(&mut result, 1.5, "temperature", vec![300.0, 310.0]);
847        assert_eq!(result.time_steps.len(), 1);
848        assert!((result.time_steps[0].time - 1.5).abs() < 1e-12);
849        assert_eq!(result.time_steps[0].variables[0].name, "temperature");
850        assert_eq!(result.time_steps[0].variables[0].values.len(), 2);
851    }
852    #[test]
853    fn test_add_element_variable_step() {
854        let mesh = ExodusMesh::new("t");
855        let mut result = ExodusResult {
856            mesh,
857            time_steps: vec![],
858        };
859        add_element_variable_step(&mut result, 2.0, "stress", vec![1e6]);
860        assert_eq!(result.time_steps[0].variables[0].entity_type, "element");
861    }
862    #[test]
863    fn test_validate_valid_mesh() {
864        let mesh = make_simple_mesh();
865        let report = validate_mesh(&mesh);
866        assert!(
867            report.is_valid,
868            "Simple mesh should be valid: {:?}",
869            report.errors
870        );
871    }
872    #[test]
873    fn test_validate_empty_mesh_has_warnings() {
874        let mesh = ExodusMesh::new("empty");
875        let report = validate_mesh(&mesh);
876        assert!(report.is_valid);
877        assert!(
878            !report.warnings.is_empty(),
879            "Empty mesh should have warnings"
880        );
881    }
882    #[test]
883    fn test_validate_bad_node_id() {
884        let mut mesh = ExodusMesh::new("bad");
885        mesh.add_node(0.0, 0.0, 0.0);
886        let bi = mesh.add_block(1, "b", "QUAD4", 4);
887        mesh.add_element_to_block(bi, &[1, 2, 3, 4]);
888        let report = validate_mesh(&mesh);
889        assert!(!report.is_valid, "Invalid node IDs should fail validation");
890        assert!(!report.errors.is_empty());
891    }
892    #[test]
893    fn test_validate_duplicate_block_id() {
894        let mut mesh = ExodusMesh::new("dup");
895        mesh.add_node(0.0, 0.0, 0.0);
896        mesh.add_block(1, "a", "TET4", 4);
897        mesh.add_block(1, "b", "TET4", 4);
898        let report = validate_mesh(&mesh);
899        assert!(!report.is_valid);
900        assert!(
901            report
902                .errors
903                .iter()
904                .any(|e| e.contains("Duplicate block id"))
905        );
906    }
907    #[test]
908    fn test_validate_mismatched_connectivity_length() {
909        let mut mesh = ExodusMesh::new("mis");
910        for _ in 0..4 {
911            mesh.add_node(0.0, 0.0, 0.0);
912        }
913        let bi = mesh.add_block(1, "b", "QUAD4", 4);
914        mesh.blocks[bi].connectivity = vec![1, 2, 3];
915        let report = validate_mesh(&mesh);
916        assert!(!report.is_valid);
917        assert!(report.errors.iter().any(|e| e.contains("not divisible")));
918    }
919    #[test]
920    fn test_scale_mesh() {
921        let mut mesh = make_simple_mesh();
922        scale_mesh(&mut mesh, 2.0);
923        assert!((mesh.coordinates[1][0] - 2.0).abs() < 1e-12);
924        assert!((mesh.coordinates[2][0] - 2.0).abs() < 1e-12);
925        assert!((mesh.coordinates[2][1] - 2.0).abs() < 1e-12);
926    }
927    #[test]
928    fn test_mesh_centroid() {
929        let mesh = make_simple_mesh();
930        let c = mesh_centroid(&mesh);
931        assert!((c[0] - 0.5).abs() < 1e-12);
932        assert!((c[1] - 0.5).abs() < 1e-12);
933        assert!(c[2].abs() < 1e-12);
934    }
935    #[test]
936    fn test_mesh_centroid_empty() {
937        let mesh = ExodusMesh::new("empty");
938        let c = mesh_centroid(&mesh);
939        assert_eq!(c, [0.0, 0.0, 0.0]);
940    }
941    #[test]
942    fn test_build_unit_hex_mesh() {
943        let mesh = build_unit_hex_mesh("test_hex");
944        assert_eq!(mesh.node_count(), 8);
945        assert_eq!(mesh.element_count(), 1);
946        assert_eq!(mesh.blocks[0].element_type, "HEX8");
947        let report = validate_mesh(&mesh);
948        assert!(
949            report.is_valid,
950            "Unit HEX8 mesh should be valid: {:?}",
951            report.errors
952        );
953    }
954    #[test]
955    fn test_build_unit_tet_mesh() {
956        let mesh = build_unit_tet_mesh("test_tet");
957        assert_eq!(mesh.node_count(), 4);
958        assert_eq!(mesh.element_count(), 1);
959        assert_eq!(mesh.blocks[0].element_type, "TET4");
960        let report = validate_mesh(&mesh);
961        assert!(report.is_valid);
962    }
963    #[test]
964    fn test_merge_meshes_node_count() {
965        let a = make_simple_mesh();
966        let b = build_unit_tet_mesh("b");
967        let merged = merge_meshes(&a, &b);
968        assert_eq!(merged.node_count(), 8);
969    }
970    #[test]
971    fn test_merge_meshes_element_count() {
972        let a = make_simple_mesh();
973        let b = build_unit_tet_mesh("b");
974        let merged = merge_meshes(&a, &b);
975        assert_eq!(merged.element_count(), 2);
976    }
977    #[test]
978    fn test_unit_hex_mesh_validation() {
979        let mesh = build_unit_hex_mesh("validation_test");
980        let report = validate_mesh(&mesh);
981        assert!(report.is_valid);
982        assert!(report.errors.is_empty());
983    }
984}
985/// Compute the Euclidean distance between two nodes.
986#[allow(dead_code)]
987pub fn node_distance(a: [f64; 3], b: [f64; 3]) -> f64 {
988    let dx = b[0] - a[0];
989    let dy = b[1] - a[1];
990    let dz = b[2] - a[2];
991    (dx * dx + dy * dy + dz * dz).sqrt()
992}
993/// Compute quality metrics for all TET4 elements in a mesh.
994///
995/// Elements that do not belong to TET4 blocks are skipped.
996#[allow(dead_code)]
997pub fn tet4_quality(mesh: &ExodusMesh) -> Vec<ElementQuality> {
998    let mut result = Vec::new();
999    for block in &mesh.blocks {
1000        if block.element_type != "TET4" || block.n_nodes_per_elem != 4 {
1001            continue;
1002        }
1003        let n_elem = block.connectivity.len() / 4;
1004        for ei in 0..n_elem {
1005            let base = ei * 4;
1006            let n0 = mesh.coordinates[block.connectivity[base] - 1];
1007            let n1 = mesh.coordinates[block.connectivity[base + 1] - 1];
1008            let n2 = mesh.coordinates[block.connectivity[base + 2] - 1];
1009            let n3 = mesh.coordinates[block.connectivity[base + 3] - 1];
1010            let edges = [
1011                node_distance(n0, n1),
1012                node_distance(n0, n2),
1013                node_distance(n0, n3),
1014                node_distance(n1, n2),
1015                node_distance(n1, n3),
1016                node_distance(n2, n3),
1017            ];
1018            let min_e = edges.iter().cloned().fold(f64::INFINITY, f64::min);
1019            let max_e = edges.iter().cloned().fold(0.0_f64, f64::max);
1020            let aspect = if max_e > 1e-30 { min_e / max_e } else { 0.0 };
1021            let e1 = [n1[0] - n0[0], n1[1] - n0[1], n1[2] - n0[2]];
1022            let e2 = [n2[0] - n0[0], n2[1] - n0[1], n2[2] - n0[2]];
1023            let e3 = [n3[0] - n0[0], n3[1] - n0[1], n3[2] - n0[2]];
1024            let jdet = e1[0] * (e2[1] * e3[2] - e2[2] * e3[1])
1025                - e1[1] * (e2[0] * e3[2] - e2[2] * e3[0])
1026                + e1[2] * (e2[0] * e3[1] - e2[1] * e3[0]);
1027            let len1 = (e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]).sqrt();
1028            let len2 = (e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]).sqrt();
1029            let len3 = (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
1030            let denom = len1 * len2 * len3;
1031            let scaled_jac = if denom > 1e-30 { jdet / denom } else { 0.0 };
1032            result.push(ElementQuality {
1033                block_id: block.id,
1034                element_index: ei,
1035                aspect_ratio: aspect,
1036                scaled_jacobian: scaled_jac,
1037                min_edge: min_e,
1038                max_edge: max_e,
1039            });
1040        }
1041    }
1042    result
1043}
1044/// Compute quality metrics for all HEX8 elements in a mesh.
1045#[allow(dead_code)]
1046pub fn hex8_quality(mesh: &ExodusMesh) -> Vec<ElementQuality> {
1047    let mut result = Vec::new();
1048    for block in &mesh.blocks {
1049        if block.element_type != "HEX8" || block.n_nodes_per_elem != 8 {
1050            continue;
1051        }
1052        let n_elem = block.connectivity.len() / 8;
1053        for ei in 0..n_elem {
1054            let base = ei * 8;
1055            let nodes: Vec<[f64; 3]> = (0..8)
1056                .map(|k| mesh.coordinates[block.connectivity[base + k] - 1])
1057                .collect();
1058            let edge_pairs = [
1059                (0, 1),
1060                (1, 2),
1061                (2, 3),
1062                (3, 0),
1063                (4, 5),
1064                (5, 6),
1065                (6, 7),
1066                (7, 4),
1067                (0, 4),
1068                (1, 5),
1069                (2, 6),
1070                (3, 7),
1071            ];
1072            let edges: Vec<f64> = edge_pairs
1073                .iter()
1074                .map(|&(a, b)| node_distance(nodes[a], nodes[b]))
1075                .collect();
1076            let min_e = edges.iter().cloned().fold(f64::INFINITY, f64::min);
1077            let max_e = edges.iter().cloned().fold(0.0_f64, f64::max);
1078            let aspect = if max_e > 1e-30 { min_e / max_e } else { 0.0 };
1079            let e1 = [
1080                nodes[1][0] - nodes[0][0],
1081                nodes[1][1] - nodes[0][1],
1082                nodes[1][2] - nodes[0][2],
1083            ];
1084            let e2 = [
1085                nodes[3][0] - nodes[0][0],
1086                nodes[3][1] - nodes[0][1],
1087                nodes[3][2] - nodes[0][2],
1088            ];
1089            let e3 = [
1090                nodes[4][0] - nodes[0][0],
1091                nodes[4][1] - nodes[0][1],
1092                nodes[4][2] - nodes[0][2],
1093            ];
1094            let jdet = e1[0] * (e2[1] * e3[2] - e2[2] * e3[1])
1095                - e1[1] * (e2[0] * e3[2] - e2[2] * e3[0])
1096                + e1[2] * (e2[0] * e3[1] - e2[1] * e3[0]);
1097            let len1 = (e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]).sqrt();
1098            let len2 = (e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]).sqrt();
1099            let len3 = (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
1100            let denom = len1 * len2 * len3;
1101            let scaled_jac = if denom > 1e-30 { jdet / denom } else { 0.0 };
1102            result.push(ElementQuality {
1103                block_id: block.id,
1104                element_index: ei,
1105                aspect_ratio: aspect,
1106                scaled_jacobian: scaled_jac,
1107                min_edge: min_e,
1108                max_edge: max_e,
1109            });
1110        }
1111    }
1112    result
1113}
1114/// Interpolate a scalar field at an arbitrary point using nearest-node
1115/// inverse-distance weighting.
1116///
1117/// `field` must have one value per node.
1118#[allow(dead_code)]
1119pub fn idw_interpolate(mesh: &ExodusMesh, field: &[f64], query: [f64; 3], power: f64) -> f64 {
1120    if field.is_empty() || mesh.coordinates.is_empty() {
1121        return 0.0;
1122    }
1123    let mut num = 0.0_f64;
1124    let mut den = 0.0_f64;
1125    for (i, &c) in mesh.coordinates.iter().enumerate() {
1126        if i >= field.len() {
1127            break;
1128        }
1129        let d = node_distance(c, query);
1130        if d < 1e-15 {
1131            return field[i];
1132        }
1133        let w = 1.0 / d.powf(power);
1134        num += w * field[i];
1135        den += w;
1136    }
1137    if den.abs() < 1e-30 { 0.0 } else { num / den }
1138}
1139/// Find the index of the node closest to a query point.
1140#[allow(dead_code)]
1141pub fn find_nearest_node(mesh: &ExodusMesh, query: [f64; 3]) -> Option<usize> {
1142    if mesh.coordinates.is_empty() {
1143        return None;
1144    }
1145    let mut best_idx = 0;
1146    let mut best_dist = f64::INFINITY;
1147    for (i, &c) in mesh.coordinates.iter().enumerate() {
1148        let d = node_distance(c, query);
1149        if d < best_dist {
1150            best_dist = d;
1151            best_idx = i;
1152        }
1153    }
1154    Some(best_idx)
1155}
1156/// Extract the subset of nodes within a spherical region.
1157///
1158/// Returns 0-based indices of nodes whose distance from `center` is ≤ `radius`.
1159#[allow(dead_code)]
1160pub fn nodes_in_sphere(mesh: &ExodusMesh, center: [f64; 3], radius: f64) -> Vec<usize> {
1161    mesh.coordinates
1162        .iter()
1163        .enumerate()
1164        .filter(|&(_, c)| node_distance(*c, center) <= radius)
1165        .map(|(i, _)| i)
1166        .collect()
1167}
1168/// Extract a sub-mesh containing only elements in the specified block.
1169#[allow(dead_code)]
1170pub fn extract_block(mesh: &ExodusMesh, block_id: u32) -> Option<ExodusMesh> {
1171    let block = mesh.blocks.iter().find(|b| b.id == block_id)?;
1172    let mut out = ExodusMesh::new(&format!("{}_block_{}", mesh.title, block_id));
1173    let mut used_nodes: Vec<usize> = block.connectivity.clone();
1174    used_nodes.sort_unstable();
1175    used_nodes.dedup();
1176    let mut remap = std::collections::HashMap::new();
1177    for (new_idx, &old_id) in used_nodes.iter().enumerate() {
1178        remap.insert(old_id, new_idx + 1);
1179    }
1180    for &old_id in &used_nodes {
1181        if old_id <= mesh.coordinates.len() {
1182            let c = mesh.coordinates[old_id - 1];
1183            out.coordinates.push(c);
1184        }
1185    }
1186    let new_conn: Vec<usize> = block
1187        .connectivity
1188        .iter()
1189        .map(|&n| *remap.get(&n).unwrap_or(&n))
1190        .collect();
1191    let bi = out.add_block(
1192        block.id,
1193        &block.name,
1194        &block.element_type,
1195        block.n_nodes_per_elem,
1196    );
1197    out.add_element_to_block(bi, &new_conn);
1198    Some(out)
1199}
1200/// Extract a sub-mesh containing only the nodes in a node set.
1201#[allow(dead_code)]
1202pub fn extract_node_set_coords(mesh: &ExodusMesh, ns_id: u32) -> Option<Vec<[f64; 3]>> {
1203    let ns = mesh.node_sets.iter().find(|ns| ns.id == ns_id)?;
1204    let coords: Vec<[f64; 3]> = ns
1205        .node_ids
1206        .iter()
1207        .filter_map(|&nid| {
1208            if nid >= 1 && nid <= mesh.coordinates.len() {
1209                Some(mesh.coordinates[nid - 1])
1210            } else {
1211                None
1212            }
1213        })
1214        .collect();
1215    Some(coords)
1216}
1217/// Refine a mesh by midpoint subdivision of TET4 elements.
1218///
1219/// Each TET4 is split into 8 smaller tetrahedra by inserting 6 midpoint nodes
1220/// on every edge (regular octasection). This produces a conforming mesh.
1221#[allow(dead_code)]
1222pub fn refine_tet4_mesh(mesh: &ExodusMesh) -> ExodusMesh {
1223    let mut out = ExodusMesh::new(&format!("{}_refined", mesh.title));
1224    for &c in &mesh.coordinates {
1225        out.coordinates.push(c);
1226    }
1227    let mut edge_mid: std::collections::HashMap<(usize, usize), usize> =
1228        std::collections::HashMap::new();
1229    let add_midpoint = |coords: &mut Vec<[f64; 3]>, a: usize, b: usize| -> usize {
1230        let ca = coords[a - 1];
1231        let cb = coords[b - 1];
1232        let mid = [
1233            (ca[0] + cb[0]) * 0.5,
1234            (ca[1] + cb[1]) * 0.5,
1235            (ca[2] + cb[2]) * 0.5,
1236        ];
1237        coords.push(mid);
1238        coords.len()
1239    };
1240    for block in &mesh.blocks {
1241        if block.element_type != "TET4" || block.n_nodes_per_elem != 4 {
1242            let bi = out.add_block(
1243                block.id,
1244                &block.name,
1245                &block.element_type,
1246                block.n_nodes_per_elem,
1247            );
1248            out.add_element_to_block(bi, &block.connectivity);
1249            continue;
1250        }
1251        let bi = out.add_block(block.id, &block.name, "TET4", 4);
1252        let n_elem = block.connectivity.len() / 4;
1253        for ei in 0..n_elem {
1254            let base = ei * 4;
1255            let (v0, v1, v2, v3) = (
1256                block.connectivity[base],
1257                block.connectivity[base + 1],
1258                block.connectivity[base + 2],
1259                block.connectivity[base + 3],
1260            );
1261            let get_mid = |a: usize,
1262                           b: usize,
1263                           coords: &mut Vec<[f64; 3]>,
1264                           cache: &mut std::collections::HashMap<(usize, usize), usize>|
1265             -> usize {
1266                let key = if a < b { (a, b) } else { (b, a) };
1267                if let Some(&mid) = cache.get(&key) {
1268                    mid
1269                } else {
1270                    let mid = add_midpoint(coords, a, b);
1271                    cache.insert(key, mid);
1272                    mid
1273                }
1274            };
1275            let m01 = get_mid(v0, v1, &mut out.coordinates, &mut edge_mid);
1276            let m02 = get_mid(v0, v2, &mut out.coordinates, &mut edge_mid);
1277            let m03 = get_mid(v0, v3, &mut out.coordinates, &mut edge_mid);
1278            let m12 = get_mid(v1, v2, &mut out.coordinates, &mut edge_mid);
1279            let m13 = get_mid(v1, v3, &mut out.coordinates, &mut edge_mid);
1280            let m23 = get_mid(v2, v3, &mut out.coordinates, &mut edge_mid);
1281            let sub_tets = [
1282                [v0, m01, m02, m03],
1283                [v1, m01, m12, m13],
1284                [v2, m02, m12, m23],
1285                [v3, m03, m13, m23],
1286                [m01, m02, m03, m13],
1287                [m01, m02, m12, m13],
1288                [m02, m03, m13, m23],
1289                [m02, m12, m13, m23],
1290            ];
1291            for tet in &sub_tets {
1292                out.add_element_to_block(bi, tet);
1293            }
1294        }
1295    }
1296    out
1297}
1298/// Extract all unique node IDs referenced in node sets as a sorted vec.
1299#[allow(dead_code)]
1300pub fn all_boundary_nodes(mesh: &ExodusMesh) -> Vec<usize> {
1301    let mut ids: Vec<usize> = mesh
1302        .node_sets
1303        .iter()
1304        .flat_map(|ns| ns.node_ids.iter().cloned())
1305        .collect();
1306    ids.sort_unstable();
1307    ids.dedup();
1308    ids
1309}
1310/// Count how many times each node appears in element connectivity.
1311///
1312/// Returns a vector of length `n_nodes` with valence counts (0-based index).
1313#[allow(dead_code)]
1314pub fn node_valence(mesh: &ExodusMesh) -> Vec<usize> {
1315    let n = mesh.node_count();
1316    let mut valence = vec![0usize; n];
1317    for block in &mesh.blocks {
1318        for &nid in &block.connectivity {
1319            if nid >= 1 && nid <= n {
1320                valence[nid - 1] += 1;
1321            }
1322        }
1323    }
1324    valence
1325}
1326/// Write an `ExodusResult` (mesh + time steps) to a text representation.
1327///
1328/// Variables are written after the mesh section with their time stamps.
1329#[allow(dead_code)]
1330pub fn write_result_text(result: &ExodusResult) -> String {
1331    let mut s = write_text(&result.mesh);
1332    if s.ends_with("END\n") {
1333        s.truncate(s.len() - 4);
1334    }
1335    s.push_str(&format!("TIME_STEPS {}\n", result.time_steps.len()));
1336    for ts in &result.time_steps {
1337        s.push_str(&format!("TIME_STEP {:.15e}\n", ts.time));
1338        s.push_str(&format!("VARIABLES {}\n", ts.variables.len()));
1339        for var in &ts.variables {
1340            s.push_str(&format!(
1341                "VAR {} {} {}\n",
1342                var.entity_type,
1343                var.name,
1344                var.values.len()
1345            ));
1346            let vals: Vec<String> = var.values.iter().map(|v| format!("{:.15e}", v)).collect();
1347            s.push_str(&vals.join(" "));
1348            s.push('\n');
1349        }
1350    }
1351    s.push_str("END\n");
1352    s
1353}
1354/// Parse an `ExodusResult` from the text format written by `write_result_text`.
1355#[allow(dead_code)]
1356pub fn parse_result_text(data: &str) -> std::result::Result<ExodusResult, String> {
1357    let ts_marker = "TIME_STEPS ";
1358    if let Some(pos) = data.find(ts_marker) {
1359        let mesh_part = format!("{}END\n", &data[..pos]);
1360        let mesh = parse_text(&mesh_part)?;
1361        let mut result = ExodusResult {
1362            mesh,
1363            time_steps: Vec::new(),
1364        };
1365        let rest = &data[pos..];
1366        let mut lines = rest.lines().peekable();
1367        let n_steps: usize = match lines.next() {
1368            Some(line) if line.starts_with("TIME_STEPS ") => line[11..]
1369                .trim()
1370                .parse()
1371                .map_err(|e| format!("Bad TIME_STEPS: {}", e))?,
1372            other => return Err(format!("Expected TIME_STEPS, got: {:?}", other)),
1373        };
1374        for _ in 0..n_steps {
1375            let time: f64 = match lines.next() {
1376                Some(line) if line.starts_with("TIME_STEP ") => line[10..]
1377                    .trim()
1378                    .parse()
1379                    .map_err(|e| format!("Bad TIME_STEP: {}", e))?,
1380                other => return Err(format!("Expected TIME_STEP, got: {:?}", other)),
1381            };
1382            let n_vars: usize = match lines.next() {
1383                Some(line) if line.starts_with("VARIABLES ") => line[10..]
1384                    .trim()
1385                    .parse()
1386                    .map_err(|e| format!("Bad VARIABLES: {}", e))?,
1387                other => return Err(format!("Expected VARIABLES, got: {:?}", other)),
1388            };
1389            let mut variables = Vec::new();
1390            for _ in 0..n_vars {
1391                let hdr = lines.next().ok_or("Unexpected EOF reading VAR")?;
1392                let parts: Vec<&str> = hdr.split_whitespace().collect();
1393                if parts.len() < 4 || parts[0] != "VAR" {
1394                    return Err(format!("Expected VAR header, got: {}", hdr));
1395                }
1396                let entity_type = parts[1].to_string();
1397                let name = parts[2].to_string();
1398                let n_vals: usize = parts[3]
1399                    .parse()
1400                    .map_err(|e| format!("Bad VAR count: {}", e))?;
1401                let vals_line = lines.next().ok_or("Unexpected EOF reading VAR data")?;
1402                let values: std::result::Result<Vec<f64>, _> = vals_line
1403                    .split_whitespace()
1404                    .take(n_vals)
1405                    .map(|v| v.parse::<f64>())
1406                    .collect();
1407                let values = values.map_err(|e| format!("Bad VAR values: {}", e))?;
1408                variables.push(ExodusVariable {
1409                    name,
1410                    entity_type,
1411                    values,
1412                });
1413            }
1414            result.time_steps.push(ExodusTimeStep { time, variables });
1415        }
1416        Ok(result)
1417    } else {
1418        let mesh = parse_text(data)?;
1419        Ok(ExodusResult {
1420            mesh,
1421            time_steps: Vec::new(),
1422        })
1423    }
1424}
1425/// Partition a mesh into `n_parts` using a round-robin element assignment.
1426///
1427/// Only the first block is partitioned; subsequent blocks are copied to part 0.
1428#[allow(dead_code)]
1429pub fn partition_mesh_round_robin(mesh: &ExodusMesh, n_parts: usize) -> Vec<MeshPartition> {
1430    if n_parts == 0 || mesh.blocks.is_empty() {
1431        return Vec::new();
1432    }
1433    let mut parts: Vec<ExodusMesh> = (0..n_parts)
1434        .map(|i| ExodusMesh::new(&format!("{}_part_{}", mesh.title, i)))
1435        .collect();
1436    for p in &mut parts {
1437        for &c in &mesh.coordinates {
1438            p.coordinates.push(c);
1439        }
1440    }
1441    let block = &mesh.blocks[0];
1442    let n_elem = block
1443        .connectivity
1444        .len()
1445        .checked_div(block.n_nodes_per_elem)
1446        .unwrap_or(0);
1447    let block_idxs: Vec<usize> = (0..n_parts)
1448        .map(|pi| {
1449            parts[pi].add_block(
1450                block.id,
1451                &block.name,
1452                &block.element_type,
1453                block.n_nodes_per_elem,
1454            )
1455        })
1456        .collect();
1457    for ei in 0..n_elem {
1458        let part = ei % n_parts;
1459        let base = ei * block.n_nodes_per_elem;
1460        let conn = &block.connectivity[base..base + block.n_nodes_per_elem];
1461        parts[part].add_element_to_block(block_idxs[part], conn);
1462    }
1463    for ns in &mesh.node_sets {
1464        for p in &mut parts {
1465            p.add_node_set(ns.id, &ns.name, ns.node_ids.clone());
1466        }
1467    }
1468    parts
1469        .into_iter()
1470        .enumerate()
1471        .map(|(i, m)| MeshPartition {
1472            partition_id: i,
1473            mesh: m,
1474        })
1475        .collect()
1476}
1477/// Compute statistics for a scalar field.
1478#[allow(dead_code)]
1479pub fn field_stats(values: &[f64]) -> FieldStats {
1480    if values.is_empty() {
1481        return FieldStats {
1482            min: 0.0,
1483            max: 0.0,
1484            mean: 0.0,
1485            std_dev: 0.0,
1486            count: 0,
1487        };
1488    }
1489    let count = values.len();
1490    let mut min_v = values[0];
1491    let mut max_v = values[0];
1492    let mut sum = 0.0_f64;
1493    for &v in values {
1494        if v < min_v {
1495            min_v = v;
1496        }
1497        if v > max_v {
1498            max_v = v;
1499        }
1500        sum += v;
1501    }
1502    let mean = sum / count as f64;
1503    let var: f64 = values
1504        .iter()
1505        .map(|&v| {
1506            let d = v - mean;
1507            d * d
1508        })
1509        .sum::<f64>()
1510        / count as f64;
1511    FieldStats {
1512        min: min_v,
1513        max: max_v,
1514        mean,
1515        std_dev: var.sqrt(),
1516        count,
1517    }
1518}
1519/// Normalize a scalar field to the range \[0, 1\].
1520#[allow(dead_code)]
1521pub fn normalize_field(values: &[f64]) -> Vec<f64> {
1522    if values.is_empty() {
1523        return Vec::new();
1524    }
1525    let stats = field_stats(values);
1526    let range = stats.max - stats.min;
1527    if range < 1e-30 {
1528        return vec![0.5; values.len()];
1529    }
1530    values.iter().map(|&v| (v - stats.min) / range).collect()
1531}
1532/// Compute the L2 norm of a field.
1533#[allow(dead_code)]
1534pub fn field_l2_norm(values: &[f64]) -> f64 {
1535    let sum_sq: f64 = values.iter().map(|&v| v * v).sum();
1536    sum_sq.sqrt()
1537}
1538/// Export node coordinates to a CSV string.
1539#[allow(dead_code)]
1540pub fn export_nodes_csv(mesh: &ExodusMesh) -> String {
1541    let mut s = String::from("node_id,x,y,z\n");
1542    for (i, &c) in mesh.coordinates.iter().enumerate() {
1543        s.push_str(&format!("{},{},{},{}\n", i + 1, c[0], c[1], c[2]));
1544    }
1545    s
1546}
1547/// Export node field values to a CSV string alongside node coordinates.
1548#[allow(dead_code)]
1549pub fn export_field_csv(mesh: &ExodusMesh, field_name: &str, values: &[f64]) -> String {
1550    let mut s = format!("node_id,x,y,z,{}\n", field_name);
1551    for (i, &c) in mesh.coordinates.iter().enumerate() {
1552        let v = values.get(i).copied().unwrap_or(0.0);
1553        s.push_str(&format!("{},{},{},{},{}\n", i + 1, c[0], c[1], c[2], v));
1554    }
1555    s
1556}
1557/// Export element connectivity to a CSV string.
1558#[allow(dead_code)]
1559pub fn export_connectivity_csv(mesh: &ExodusMesh) -> String {
1560    let mut s = String::from("block_id,element_id");
1561    let max_npe = mesh
1562        .blocks
1563        .iter()
1564        .map(|b| b.n_nodes_per_elem)
1565        .max()
1566        .unwrap_or(0);
1567    for k in 0..max_npe {
1568        s.push_str(&format!(",n{}", k));
1569    }
1570    s.push('\n');
1571    let mut global_elem = 1;
1572    for block in &mesh.blocks {
1573        if block.n_nodes_per_elem == 0 {
1574            continue;
1575        }
1576        let n_elem = block.connectivity.len() / block.n_nodes_per_elem;
1577        for ei in 0..n_elem {
1578            let base = ei * block.n_nodes_per_elem;
1579            s.push_str(&format!("{},{}", block.id, global_elem));
1580            for k in 0..block.n_nodes_per_elem {
1581                s.push_str(&format!(",{}", block.connectivity[base + k]));
1582            }
1583            for _ in block.n_nodes_per_elem..max_npe {
1584                s.push_str(",-1");
1585            }
1586            s.push('\n');
1587            global_elem += 1;
1588        }
1589    }
1590    s
1591}