Skip to main content

oxiphysics_io/
gmsh_format.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gmsh mesh format (.msh) version 2.2 ASCII support.
5//!
6//! Implements reading and writing of the Gmsh 2.2 ASCII `.msh` format,
7//! including node coordinates, element connectivity, and physical groups.
8
9use std::collections::HashMap;
10
11// ── Data types ────────────────────────────────────────────────────────────────
12
13/// A node in a Gmsh mesh.
14#[derive(Debug, Clone, PartialEq)]
15pub struct GmshNode {
16    /// 1-based node number as stored in the file.
17    pub id: usize,
18    /// Node coordinates `[x, y, z]`.
19    pub coords: [f64; 3],
20}
21
22/// Gmsh element types (subset used in practice).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[allow(dead_code)]
25pub enum GmshElementType {
26    /// 2-node line.
27    Line2 = 1,
28    /// 3-node triangle.
29    Triangle3 = 2,
30    /// 4-node quadrilateral.
31    Quad4 = 3,
32    /// 4-node tetrahedron.
33    Tet4 = 4,
34    /// 8-node hexahedron.
35    Hex8 = 5,
36    /// 1-node point.
37    Point1 = 15,
38}
39
40impl GmshElementType {
41    /// Try to construct from the integer type code.
42    pub fn from_code(code: usize) -> Option<Self> {
43        match code {
44            1 => Some(Self::Line2),
45            2 => Some(Self::Triangle3),
46            3 => Some(Self::Quad4),
47            4 => Some(Self::Tet4),
48            5 => Some(Self::Hex8),
49            15 => Some(Self::Point1),
50            _ => None,
51        }
52    }
53
54    /// Number of nodes for this element type.
55    pub fn node_count(self) -> usize {
56        match self {
57            Self::Point1 => 1,
58            Self::Line2 => 2,
59            Self::Triangle3 => 3,
60            Self::Quad4 => 4,
61            Self::Tet4 => 4,
62            Self::Hex8 => 8,
63        }
64    }
65}
66
67/// A single Gmsh mesh element.
68#[derive(Debug, Clone, PartialEq)]
69pub struct GmshElement {
70    /// 1-based element number.
71    pub id: usize,
72    /// Element type code.
73    pub element_type: GmshElementType,
74    /// Physical group tag (first tag in the tag list, 0 if absent).
75    pub physical_tag: usize,
76    /// Geometric entity tag (second tag, 0 if absent).
77    pub geometric_tag: usize,
78    /// 1-based node connectivity.
79    pub nodes: Vec<usize>,
80}
81
82/// A physical group: a named set of elements sharing a tag.
83#[derive(Debug, Clone, PartialEq)]
84pub struct PhysicalGroup {
85    /// Spatial dimension (0–3).
86    pub dimension: usize,
87    /// Integer tag matching `GmshElement::physical_tag`.
88    pub tag: usize,
89    /// Human-readable name.
90    pub name: String,
91}
92
93/// A complete Gmsh 2.2 mesh.
94#[derive(Debug, Clone, Default)]
95pub struct GmshMesh {
96    /// Node list (1-based IDs preserved).
97    pub nodes: Vec<GmshNode>,
98    /// Element list.
99    pub elements: Vec<GmshElement>,
100    /// Physical groups declared in `$PhysicalNames`.
101    pub physical_groups: Vec<PhysicalGroup>,
102}
103
104impl GmshMesh {
105    /// Create an empty Gmsh mesh.
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Return the node with the given 1-based ID, or `None`.
111    pub fn node_by_id(&self, id: usize) -> Option<&GmshNode> {
112        self.nodes.iter().find(|n| n.id == id)
113    }
114
115    /// Count of nodes.
116    pub fn node_count(&self) -> usize {
117        self.nodes.len()
118    }
119
120    /// Count of elements.
121    pub fn element_count(&self) -> usize {
122        self.elements.len()
123    }
124}
125
126/// Generic FEM mesh connectivity.
127#[derive(Debug, Clone, Default)]
128pub struct FemMesh {
129    /// Node coordinates `[x, y, z]` in node-index order (0-based).
130    pub coords: Vec<[f64; 3]>,
131    /// Element connectivity as lists of 0-based node indices.
132    pub connectivity: Vec<Vec<usize>>,
133}
134
135// ── Parsing ───────────────────────────────────────────────────────────────────
136
137/// Parse a Gmsh 2.2 ASCII `.msh` file from a string.
138///
139/// Sections recognised: `$MeshFormat`, `$PhysicalNames`, `$Nodes`, `$Elements`.
140pub fn read_gmsh_v2(src: &str) -> GmshMesh {
141    let mut mesh = GmshMesh::new();
142    let lines: Vec<&str> = src.lines().collect();
143    let mut i = 0;
144
145    while i < lines.len() {
146        let line = lines[i].trim();
147        match line {
148            "$MeshFormat" => {
149                // Skip until $EndMeshFormat
150                while i < lines.len() && lines[i].trim() != "$EndMeshFormat" {
151                    i += 1;
152                }
153            }
154            "$PhysicalNames" => {
155                i += 1;
156                if i < lines.len() {
157                    let _count: usize = lines[i].trim().parse().unwrap_or(0);
158                    i += 1;
159                }
160                while i < lines.len() && lines[i].trim() != "$EndPhysicalNames" {
161                    let parts: Vec<&str> = lines[i].split_whitespace().collect();
162                    if parts.len() >= 3 {
163                        let dim = parts[0].parse::<usize>().unwrap_or(0);
164                        let tag = parts[1].parse::<usize>().unwrap_or(0);
165                        // Name may be quoted
166                        let name = parts[2..].join(" ").trim_matches('"').to_string();
167                        mesh.physical_groups.push(PhysicalGroup {
168                            dimension: dim,
169                            tag,
170                            name,
171                        });
172                    }
173                    i += 1;
174                }
175            }
176            "$Nodes" => {
177                i += 1;
178                if i < lines.len() {
179                    let _n: usize = lines[i].trim().parse().unwrap_or(0);
180                    i += 1;
181                }
182                while i < lines.len() && lines[i].trim() != "$EndNodes" {
183                    let parts: Vec<&str> = lines[i].split_whitespace().collect();
184                    if parts.len() >= 4
185                        && let (Ok(id), Ok(x), Ok(y), Ok(z)) = (
186                            parts[0].parse::<usize>(),
187                            parts[1].parse::<f64>(),
188                            parts[2].parse::<f64>(),
189                            parts[3].parse::<f64>(),
190                        )
191                    {
192                        mesh.nodes.push(GmshNode {
193                            id,
194                            coords: [x, y, z],
195                        });
196                    }
197                    i += 1;
198                }
199            }
200            "$Elements" => {
201                i += 1;
202                if i < lines.len() {
203                    let _n: usize = lines[i].trim().parse().unwrap_or(0);
204                    i += 1;
205                }
206                while i < lines.len() && lines[i].trim() != "$EndElements" {
207                    let parts: Vec<&str> = lines[i].split_whitespace().collect();
208                    if parts.len() >= 4
209                        && let (Ok(id), Ok(type_code), Ok(n_tags)) = (
210                            parts[0].parse::<usize>(),
211                            parts[1].parse::<usize>(),
212                            parts[2].parse::<usize>(),
213                        )
214                        && let Some(etype) = GmshElementType::from_code(type_code)
215                    {
216                        let tag_start = 3;
217                        let node_start = tag_start + n_tags;
218                        let physical_tag = if n_tags >= 1 {
219                            parts
220                                .get(tag_start)
221                                .and_then(|s| s.parse().ok())
222                                .unwrap_or(0)
223                        } else {
224                            0
225                        };
226                        let geometric_tag = if n_tags >= 2 {
227                            parts
228                                .get(tag_start + 1)
229                                .and_then(|s| s.parse().ok())
230                                .unwrap_or(0)
231                        } else {
232                            0
233                        };
234                        let nodes: Vec<usize> = parts[node_start..]
235                            .iter()
236                            .filter_map(|s| s.parse::<usize>().ok())
237                            .collect();
238                        mesh.elements.push(GmshElement {
239                            id,
240                            element_type: etype,
241                            physical_tag,
242                            geometric_tag,
243                            nodes,
244                        });
245                    }
246                    i += 1;
247                }
248            }
249            _ => {}
250        }
251        i += 1;
252    }
253
254    mesh
255}
256
257/// Serialise a `GmshMesh` to Gmsh 2.2 ASCII format.
258pub fn write_gmsh_v2(mesh: &GmshMesh) -> String {
259    let mut out = String::new();
260
261    // Header
262    out.push_str("$MeshFormat\n2.2 0 8\n$EndMeshFormat\n");
263
264    // Physical names
265    if !mesh.physical_groups.is_empty() {
266        out.push_str("$PhysicalNames\n");
267        out.push_str(&format!("{}\n", mesh.physical_groups.len()));
268        for pg in &mesh.physical_groups {
269            out.push_str(&format!("{} {} \"{}\"\n", pg.dimension, pg.tag, pg.name));
270        }
271        out.push_str("$EndPhysicalNames\n");
272    }
273
274    // Nodes
275    out.push_str("$Nodes\n");
276    out.push_str(&format!("{}\n", mesh.nodes.len()));
277    for node in &mesh.nodes {
278        out.push_str(&format!(
279            "{} {} {} {}\n",
280            node.id, node.coords[0], node.coords[1], node.coords[2]
281        ));
282    }
283    out.push_str("$EndNodes\n");
284
285    // Elements
286    out.push_str("$Elements\n");
287    out.push_str(&format!("{}\n", mesh.elements.len()));
288    for el in &mesh.elements {
289        let type_code = el.element_type as usize;
290        // Always write 2 tags: physical + geometric
291        let node_str: Vec<String> = el.nodes.iter().map(|n| n.to_string()).collect();
292        out.push_str(&format!(
293            "{} {} 2 {} {} {}\n",
294            el.id,
295            type_code,
296            el.physical_tag,
297            el.geometric_tag,
298            node_str.join(" ")
299        ));
300    }
301    out.push_str("$EndElements\n");
302
303    out
304}
305
306/// Collect the 1-based node IDs belonging to a specific physical group tag.
307///
308/// An element belongs to the group if `element.physical_tag == tag`.
309/// All node IDs referenced by matching elements are returned (deduplicated,
310/// sorted).
311pub fn physical_group_nodes(mesh: &GmshMesh, tag: usize) -> Vec<usize> {
312    let mut node_set: std::collections::BTreeSet<usize> = Default::default();
313    for el in &mesh.elements {
314        if el.physical_tag == tag {
315            for &nid in &el.nodes {
316                node_set.insert(nid);
317            }
318        }
319    }
320    node_set.into_iter().collect()
321}
322
323/// Convert a `GmshMesh` to a generic `FemMesh`.
324///
325/// Nodes are re-indexed to 0-based.  Only elements with a known element type
326/// are included.
327pub fn convert_to_fem_mesh(mesh: &GmshMesh) -> FemMesh {
328    // Build a map from 1-based Gmsh ID → 0-based FEM index
329    let mut id_to_idx: HashMap<usize, usize> = HashMap::new();
330    let mut coords: Vec<[f64; 3]> = Vec::with_capacity(mesh.nodes.len());
331
332    for (idx, node) in mesh.nodes.iter().enumerate() {
333        id_to_idx.insert(node.id, idx);
334        coords.push(node.coords);
335    }
336
337    let mut connectivity: Vec<Vec<usize>> = Vec::with_capacity(mesh.elements.len());
338    for el in &mesh.elements {
339        let zero_based: Vec<usize> = el
340            .nodes
341            .iter()
342            .filter_map(|nid| id_to_idx.get(nid).copied())
343            .collect();
344        connectivity.push(zero_based);
345    }
346
347    FemMesh {
348        coords,
349        connectivity,
350    }
351}
352
353// ── Tests ─────────────────────────────────────────────────────────────────────
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn triangle_msh_src() -> &'static str {
360        "$MeshFormat\n\
361         2.2 0 8\n\
362         $EndMeshFormat\n\
363         $PhysicalNames\n\
364         1\n\
365         2 1 \"Surface\"\n\
366         $EndPhysicalNames\n\
367         $Nodes\n\
368         3\n\
369         1 0.0 0.0 0.0\n\
370         2 1.0 0.0 0.0\n\
371         3 0.0 1.0 0.0\n\
372         $EndNodes\n\
373         $Elements\n\
374         1\n\
375         1 2 2 1 0 1 2 3\n\
376         $EndElements\n"
377    }
378
379    // --- read_gmsh_v2 ---
380
381    #[test]
382    fn test_read_node_count() {
383        let mesh = read_gmsh_v2(triangle_msh_src());
384        assert_eq!(mesh.node_count(), 3);
385    }
386
387    #[test]
388    fn test_read_node_coords() {
389        let mesh = read_gmsh_v2(triangle_msh_src());
390        let n1 = mesh.node_by_id(1).unwrap();
391        assert_eq!(n1.coords, [0.0, 0.0, 0.0]);
392    }
393
394    #[test]
395    fn test_read_node_id_preserved() {
396        let mesh = read_gmsh_v2(triangle_msh_src());
397        assert!(mesh.node_by_id(2).is_some());
398        assert!(mesh.node_by_id(99).is_none());
399    }
400
401    #[test]
402    fn test_read_element_count() {
403        let mesh = read_gmsh_v2(triangle_msh_src());
404        assert_eq!(mesh.element_count(), 1);
405    }
406
407    #[test]
408    fn test_read_element_type() {
409        let mesh = read_gmsh_v2(triangle_msh_src());
410        assert_eq!(mesh.elements[0].element_type, GmshElementType::Triangle3);
411    }
412
413    #[test]
414    fn test_read_element_physical_tag() {
415        let mesh = read_gmsh_v2(triangle_msh_src());
416        assert_eq!(mesh.elements[0].physical_tag, 1);
417    }
418
419    #[test]
420    fn test_read_element_nodes() {
421        let mesh = read_gmsh_v2(triangle_msh_src());
422        assert_eq!(mesh.elements[0].nodes, vec![1, 2, 3]);
423    }
424
425    #[test]
426    fn test_read_physical_group_count() {
427        let mesh = read_gmsh_v2(triangle_msh_src());
428        assert_eq!(mesh.physical_groups.len(), 1);
429    }
430
431    #[test]
432    fn test_read_physical_group_name() {
433        let mesh = read_gmsh_v2(triangle_msh_src());
434        assert_eq!(mesh.physical_groups[0].name, "Surface");
435    }
436
437    #[test]
438    fn test_read_physical_group_dimension() {
439        let mesh = read_gmsh_v2(triangle_msh_src());
440        assert_eq!(mesh.physical_groups[0].dimension, 2);
441    }
442
443    #[test]
444    fn test_read_empty_string() {
445        let mesh = read_gmsh_v2("");
446        assert_eq!(mesh.node_count(), 0);
447        assert_eq!(mesh.element_count(), 0);
448    }
449
450    // --- GmshElementType ---
451
452    #[test]
453    fn test_element_type_from_code_triangle() {
454        assert_eq!(
455            GmshElementType::from_code(2),
456            Some(GmshElementType::Triangle3)
457        );
458    }
459
460    #[test]
461    fn test_element_type_from_code_unknown() {
462        assert!(GmshElementType::from_code(999).is_none());
463    }
464
465    #[test]
466    fn test_element_type_node_count_tet() {
467        assert_eq!(GmshElementType::Tet4.node_count(), 4);
468    }
469
470    #[test]
471    fn test_element_type_node_count_triangle() {
472        assert_eq!(GmshElementType::Triangle3.node_count(), 3);
473    }
474
475    // --- write_gmsh_v2 ---
476
477    #[test]
478    fn test_write_contains_mesh_format() {
479        let mesh = GmshMesh::new();
480        let out = write_gmsh_v2(&mesh);
481        assert!(out.contains("$MeshFormat"));
482        assert!(out.contains("2.2 0 8"));
483    }
484
485    #[test]
486    fn test_write_contains_nodes_section() {
487        let mesh = read_gmsh_v2(triangle_msh_src());
488        let out = write_gmsh_v2(&mesh);
489        assert!(out.contains("$Nodes"));
490        assert!(out.contains("$EndNodes"));
491    }
492
493    #[test]
494    fn test_write_contains_elements_section() {
495        let mesh = read_gmsh_v2(triangle_msh_src());
496        let out = write_gmsh_v2(&mesh);
497        assert!(out.contains("$Elements"));
498        assert!(out.contains("$EndElements"));
499    }
500
501    // --- Roundtrip ---
502
503    #[test]
504    fn test_roundtrip_node_count() {
505        let mesh = read_gmsh_v2(triangle_msh_src());
506        let out = write_gmsh_v2(&mesh);
507        let mesh2 = read_gmsh_v2(&out);
508        assert_eq!(mesh2.node_count(), mesh.node_count());
509    }
510
511    #[test]
512    fn test_roundtrip_element_count() {
513        let mesh = read_gmsh_v2(triangle_msh_src());
514        let out = write_gmsh_v2(&mesh);
515        let mesh2 = read_gmsh_v2(&out);
516        assert_eq!(mesh2.element_count(), mesh.element_count());
517    }
518
519    #[test]
520    fn test_roundtrip_node_coords() {
521        let mesh = read_gmsh_v2(triangle_msh_src());
522        let out = write_gmsh_v2(&mesh);
523        let mesh2 = read_gmsh_v2(&out);
524        let n2 = mesh2.node_by_id(2).unwrap();
525        assert!((n2.coords[0] - 1.0).abs() < 1e-9);
526    }
527
528    #[test]
529    fn test_roundtrip_physical_group_name() {
530        let mesh = read_gmsh_v2(triangle_msh_src());
531        let out = write_gmsh_v2(&mesh);
532        let mesh2 = read_gmsh_v2(&out);
533        assert_eq!(mesh2.physical_groups[0].name, "Surface");
534    }
535
536    #[test]
537    fn test_roundtrip_element_type() {
538        let mesh = read_gmsh_v2(triangle_msh_src());
539        let out = write_gmsh_v2(&mesh);
540        let mesh2 = read_gmsh_v2(&out);
541        assert_eq!(mesh2.elements[0].element_type, GmshElementType::Triangle3);
542    }
543
544    // --- physical_group_nodes ---
545
546    #[test]
547    fn test_physical_group_nodes_correct() {
548        let mesh = read_gmsh_v2(triangle_msh_src());
549        let nodes = physical_group_nodes(&mesh, 1);
550        assert_eq!(nodes, vec![1, 2, 3]);
551    }
552
553    #[test]
554    fn test_physical_group_nodes_missing_tag() {
555        let mesh = read_gmsh_v2(triangle_msh_src());
556        let nodes = physical_group_nodes(&mesh, 999);
557        assert!(nodes.is_empty());
558    }
559
560    #[test]
561    fn test_physical_group_nodes_deduplicated() {
562        // Two elements sharing node 1
563        let src = "$MeshFormat\n2.2 0 8\n$EndMeshFormat\n\
564                   $Nodes\n4\n1 0 0 0\n2 1 0 0\n3 0 1 0\n4 1 1 0\n$EndNodes\n\
565                   $Elements\n2\n\
566                   1 2 2 1 0 1 2 3\n\
567                   2 2 2 1 0 2 3 4\n\
568                   $EndElements\n";
569        let mesh = read_gmsh_v2(src);
570        let nodes = physical_group_nodes(&mesh, 1);
571        // Nodes 1,2,3,4 — no duplicates
572        assert_eq!(nodes.len(), 4);
573    }
574
575    // --- convert_to_fem_mesh ---
576
577    #[test]
578    fn test_fem_mesh_node_count() {
579        let mesh = read_gmsh_v2(triangle_msh_src());
580        let fem = convert_to_fem_mesh(&mesh);
581        assert_eq!(fem.coords.len(), 3);
582    }
583
584    #[test]
585    fn test_fem_mesh_connectivity_count() {
586        let mesh = read_gmsh_v2(triangle_msh_src());
587        let fem = convert_to_fem_mesh(&mesh);
588        assert_eq!(fem.connectivity.len(), 1);
589    }
590
591    #[test]
592    fn test_fem_mesh_connectivity_zero_based() {
593        let mesh = read_gmsh_v2(triangle_msh_src());
594        let fem = convert_to_fem_mesh(&mesh);
595        // 1-based [1,2,3] → 0-based [0,1,2]
596        assert_eq!(fem.connectivity[0], vec![0, 1, 2]);
597    }
598
599    #[test]
600    fn test_fem_mesh_coords_correct() {
601        let mesh = read_gmsh_v2(triangle_msh_src());
602        let fem = convert_to_fem_mesh(&mesh);
603        // Node 2 has coords [1,0,0]
604        assert!((fem.coords[1][0] - 1.0).abs() < 1e-9);
605    }
606
607    #[test]
608    fn test_fem_mesh_empty() {
609        let mesh = GmshMesh::new();
610        let fem = convert_to_fem_mesh(&mesh);
611        assert!(fem.coords.is_empty());
612        assert!(fem.connectivity.is_empty());
613    }
614}