# SU2Reader.py # ----------------------------------------------------------------------------- # ParaView Python Reader Plugin for SU2 mesh # # Outputs: # Output 0: Volume Mesh (vtkUnstructuredGrid) # Output 1: Boundary Meshes (vtkMultiBlockDataSet, one block per marker) # # Design notes: # - Volume and marker connectivity follow SU2's VTK-style element sizes # - Trailing legacy IDs are ignored when present # - Marker names are kept for MultiBlock inspection # - No data arrays are attached (pure geometry) # - Parsing is vectorized with numpy.fromstring for large meshes; a per-line # fallback preserves precise error messages for malformed input. # ----------------------------------------------------------------------------- from paraview.util.vtkAlgorithm import ( smproxy, smproperty, smdomain, smhint, VTKPythonAlgorithmBase ) import vtk from vtk.util import numpy_support import numpy as np # vtkIdType size (4 or 8 bytes) depends on how VTK was built. # Match the numpy dtype to avoid an extra copy/conversion in numpy_to_vtkIdTypeArray. _ID_DTYPE = np.int64 if vtk.vtkIdTypeArray().GetDataTypeSize() == 8 else np.int32 # ----------------------------------------------------------------------------- # SU2 element type -> VTK cell type # ----------------------------------------------------------------------------- VTK_TYPE_MAP = { 3: 3, # line 5: 5, # triangle 9: 9, # quad 10: 10, # tet 12: 12, # hex 13: 13, # prism (wedge) 14: 14 # pyramid } # ----------------------------------------------------------------------------- # Expected node counts for SU2 elements # (used to read only the connectivity nodes and ignore trailing legacy IDs) # ----------------------------------------------------------------------------- SU2_NNODES = { 3: 2, 5: 3, 9: 4, 10: 4, 12: 8, 13: 6, 14: 5 } # ----------------------------------------------------------------------------- # Utility: remove all data arrays (geometry only) # ----------------------------------------------------------------------------- def _strip_arrays(ds): if ds is None: return ds.GetPointData().Initialize() ds.GetCellData().Initialize() ds.GetFieldData().Initialize() # ----------------------------------------------------------------------------- # Line / KV helpers # ----------------------------------------------------------------------------- def _clean_line(line): return line.split("%", 1)[0].strip() def _parse_kv_value(line, key): head, sep, tail = line.partition("=") if sep != "=" or head.strip() != key: raise RuntimeError(f"Expected '{key}=' line, got: {line}") value = tail.strip() if not value: raise RuntimeError(f"Missing value for {key}") return value def _parse_count_line(lines, index, key): if index >= len(lines): raise RuntimeError(f"Expected '{key}=' line, got EOF") return int(_parse_kv_value(lines[index], key)) def _require_data_line(lines, index, context): if index >= len(lines): raise RuntimeError(f"Unexpected EOF while reading {context}") return lines[index] # ----------------------------------------------------------------------------- # Per-line fallback parsers (used when bulk numpy parse cannot reshape uniformly) # ----------------------------------------------------------------------------- def _parse_point_line(line, ndime, point_index): parts = line.split() if len(parts) < ndime: raise RuntimeError( f"Point {point_index} has {len(parts)} values, expected at least {ndime}" ) return [float(value) for value in parts[:ndime]] def _parse_element_line(line, context, allowed_types=None): parts = [int(value) for value in line.split()] if not parts: raise RuntimeError(f"Empty {context} line") etype = parts[0] if etype not in SU2_NNODES: raise RuntimeError(f"Unsupported {context} type {etype}") if allowed_types is not None and etype not in allowed_types: raise RuntimeError(f"Unsupported {context} type {etype}") expected = 1 + SU2_NNODES[etype] if len(parts) < expected: raise RuntimeError( f"{context} type {etype} has {len(parts) - 1} nodes, expected {SU2_NNODES[etype]}" ) return etype, parts[1:expected] # ----------------------------------------------------------------------------- # Bulk parsers (numpy-vectorized for big meshes) # ----------------------------------------------------------------------------- def _bulk_parse_points(lines, start, npoin, ndime): """Parse npoin lines starting at lines[start] into a (npoin, 3) float64 array.""" if npoin == 0: return np.zeros((0, 3), dtype=np.float64) if start + npoin > len(lines): raise RuntimeError( f"Expected {npoin} point lines, only {len(lines) - start} available" ) pts = np.zeros((npoin, 3), dtype=np.float64) block = lines[start:start + npoin] # Fast path: uniform column count -> single np.fromstring on a joined buffer first_ncols = len(block[0].split()) if first_ncols >= ndime: try: arr = np.fromstring(" ".join(block), sep=" ", dtype=np.float64) except Exception: arr = None if arr is not None and arr.size == first_ncols * npoin: pts[:, :ndime] = arr.reshape(npoin, first_ncols)[:, :ndime] return pts # Fallback: per-line (preserves precise error messages on malformed rows) for k, line in enumerate(block): pts[k, :ndime] = _parse_point_line(line, ndime, k) return pts def _bulk_parse_elements(lines, start, nelem, allowed_types, context): """Parse nelem element lines into a list of (su2_etype, conns_np) groups. `conns_np` is a contiguous (count, nnodes) int64 array. Groups are returned in first-encounter order. For a homogeneous block (one element type, same column count on every row) this returns a single group via a single np.fromstring call -- the fast path used by typical CFD meshes. """ if nelem == 0: return [] if start + nelem > len(lines): raise RuntimeError( f"Expected {nelem} {context} lines, only {len(lines) - start} available" ) block = lines[start:start + nelem] # Fast path: assume homogeneous (uniform column count + single etype) first_ncols = len(block[0].split()) if first_ncols >= 2: try: arr = np.fromstring(" ".join(block), sep=" ", dtype=np.int64) except Exception: arr = None if arr is not None and arr.size == first_ncols * nelem: arr2 = arr.reshape(nelem, first_ncols) etypes = arr2[:, 0] if int(etypes.min()) == int(etypes.max()): etype = int(etypes[0]) if etype not in SU2_NNODES: raise RuntimeError(f"Unsupported {context} type {etype}") if allowed_types is not None and etype not in allowed_types: raise RuntimeError(f"Unsupported {context} type {etype}") n = SU2_NNODES[etype] if first_ncols < 1 + n: raise RuntimeError( f"{context} type {etype} has {first_ncols - 1} nodes, expected {n}" ) conns = np.ascontiguousarray(arr2[:, 1:1 + n]) return [(etype, conns)] # Fallback: per-line, group by etype preserving first-encounter order type_buf = {} order = [] for k, line in enumerate(block): etype, conn = _parse_element_line(line, f"{context} {k}", allowed_types) if etype not in type_buf: type_buf[etype] = [] order.append(etype) type_buf[etype].append(conn) return [(et, np.asarray(type_buf[et], dtype=np.int64)) for et in order] # ----------------------------------------------------------------------------- # Core SU2 reader # ----------------------------------------------------------------------------- def read_su2(path): with open(path, "r") as f: lines = [] for raw in f: line = _clean_line(raw) if line: lines.append(line) def find(key): for i, l in enumerate(lines): if l.startswith(key): return i return None # ---- NDIME ---- i = find("NDIME") if i is None: raise RuntimeError("NDIME not found") ndime = int(_parse_kv_value(lines[i], "NDIME")) if ndime not in (2, 3): raise RuntimeError(f"Unsupported NDIME={ndime}; expected 2 or 3") # ---- NPOIN ---- i = find("NPOIN") if i is None: raise RuntimeError("NPOIN not found") # Tolerate extended forms like 'NPOIN= 1234 5678' (only the count matters) npoin = int(_parse_kv_value(lines[i], "NPOIN").split()[0]) pts = _bulk_parse_points(lines, i + 1, npoin, ndime) # ---- NELEM ---- i_elem = find("NELEM") if i_elem is None: raise RuntimeError("NELEM not found") nelem = int(_parse_kv_value(lines[i_elem], "NELEM")) vol_groups = _bulk_parse_elements( lines, i_elem + 1, nelem, set(VTK_TYPE_MAP.keys()), "volume element" ) # ---- NMARK ---- i = find("NMARK") if i is None: raise RuntimeError("NMARK not found") nmark = int(_parse_kv_value(lines[i], "NMARK")) marker_groups = [] p = i + 1 allowed_marker_types = {3} if ndime == 2 else {5, 9} for _ in range(nmark): tag = _parse_kv_value(_require_data_line(lines, p, "MARKER_TAG"), "MARKER_TAG") p += 1 ne = _parse_count_line(lines, p, "MARKER_ELEMS") p += 1 groups = _bulk_parse_elements( lines, p, ne, allowed_marker_types, f"marker '{tag}' element" ) p += ne marker_groups.append((tag, groups)) return ndime, pts, vol_groups, marker_groups # ----------------------------------------------------------------------------- # Build vtkUnstructuredGrid from grouped (numpy) connectivity # ----------------------------------------------------------------------------- def build_ugrid(points, groups): """points: (N, 3) float64; groups: list of (su2_etype, (n_e, k) int array).""" ug = vtk.vtkUnstructuredGrid() # ---- Points: bulk insert ---- pts_arr = np.ascontiguousarray(points, dtype=np.float64) vtk_pts = vtk.vtkPoints() vtk_pts.SetData(numpy_support.numpy_to_vtk(pts_arr, deep=True)) ug.SetPoints(vtk_pts) # ---- Pre-pass: vectorized validation + size totals ---- npts = points.shape[0] n_cells = 0 total_ids = 0 for etype, conns in groups: n_e, k = conns.shape if n_e == 0: continue cmin = int(conns.min()) cmax = int(conns.max()) if cmin < 0 or cmax >= npts: raise RuntimeError( f"Node index out of range for cell type {etype}; " f"expected 0 <= nid < {npts}, got [{cmin}, {cmax}]" ) n_cells += n_e total_ids += n_e * (k + 1) if n_cells == 0: _strip_arrays(ug) return ug # ---- Build flat cell connectivity [k, id0, ..., id(k-1), k, id0, ...] ---- flat = np.empty(total_ids, dtype=_ID_DTYPE) ctypes_arr = np.empty(n_cells, dtype=np.uint8) flat_off = 0 cell_off = 0 for etype, conns in groups: n_e, k = conns.shape if n_e == 0: continue size = n_e * (k + 1) view = flat[flat_off:flat_off + size].reshape(n_e, k + 1) view[:, 0] = k view[:, 1:] = conns flat_off += size ctypes_arr[cell_off:cell_off + n_e] = VTK_TYPE_MAP[etype] cell_off += n_e id_arr = numpy_support.numpy_to_vtkIdTypeArray(flat, deep=True) cells = vtk.vtkCellArray() cells.SetCells(n_cells, id_arr) vtk_ctypes = numpy_support.numpy_to_vtk( ctypes_arr, deep=True, array_type=vtk.VTK_UNSIGNED_CHAR ) ug.SetCells(vtk_ctypes, cells) _strip_arrays(ug) return ug # ----------------------------------------------------------------------------- # Build a compacted boundary/surface grid for one marker # (point set is reduced to nodes actually referenced by the marker) # ----------------------------------------------------------------------------- def build_compacted(global_pts, groups): if not groups: return None groups = [(et, c) for et, c in groups if c.size] if not groups: return None # Union of referenced node IDs (sorted) -> remap via searchsorted (O(N log N)) all_ids = np.unique(np.concatenate([c.ravel() for _, c in groups])) if all_ids.size: npts = global_pts.shape[0] if int(all_ids.min()) < 0 or int(all_ids.max()) >= npts: raise RuntimeError( f"Marker node index out of range; expected 0 <= nid < {npts}" ) local_pts = global_pts[all_ids, :] new_groups = [(et, np.searchsorted(all_ids, c)) for et, c in groups] return build_ugrid(local_pts, new_groups) # ----------------------------------------------------------------------------- # ParaView Reader # ----------------------------------------------------------------------------- @smproxy.reader( name="SU2 Mesh Reader", extensions="su2", file_description="SU2 Mesh" ) @smproperty.xml(""" """) @smhint.xml(""" """) class SU2MeshReader(VTKPythonAlgorithmBase): def __init__(self): super().__init__(nInputPorts=0, nOutputPorts=2) self._filename = None @smproperty.stringvector(name="FileName") @smdomain.filelist() @smhint.filechooser(extensions="su2", file_description="SU2 mesh") def SetFileName(self, fname): if fname != self._filename: self._filename = fname self.Modified() def FillOutputPortInformation(self, port, info): if port == 0: info.Set(vtk.vtkDataObject.DATA_TYPE_NAME(), "vtkUnstructuredGrid") else: info.Set(vtk.vtkDataObject.DATA_TYPE_NAME(), "vtkMultiBlockDataSet") return 1 def RequestData(self, request, inInfo, outInfo): if not self._filename: return 0 ndime, pts, vol_groups, marker_groups = read_su2(self._filename) # ---- Output 0: Volume mesh ---- out0 = vtk.vtkUnstructuredGrid.GetData(outInfo, 0) out0.ShallowCopy(build_ugrid(pts, vol_groups)) # ---- Output 1: Boundary meshes (MultiBlock) ---- mb = vtk.vtkMultiBlockDataSet() blocks = [] for tag, groups in marker_groups: surf = build_compacted(pts, groups) if surf is not None: blocks.append((tag, surf)) mb.SetNumberOfBlocks(len(blocks)) for i, (tag, b) in enumerate(blocks): mb.SetBlock(i, b) mb.GetMetaData(i).Set(vtk.vtkCompositeDataSet.NAME(), tag) out1 = vtk.vtkMultiBlockDataSet.GetData(outInfo, 1) out1.ShallowCopy(mb) return 1