|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. |
| 3 | +# SPDX-FileContributor: Bertrand Denel, Paloma Martinez |
| 4 | +import numpy as np |
| 5 | +import numpy.typing as npt |
| 6 | +from typing_extensions import Any |
| 7 | + |
| 8 | +from vtkmodules.vtkCommonDataModel import vtkDataSet, VTK_TETRA |
| 9 | + |
| 10 | + |
| 11 | +def getCoordinatesDoublePrecision( mesh: vtkDataSet ) -> npt.NDArray[ np.float64 ]: |
| 12 | + """Get coordinates with double precision. |
| 13 | +
|
| 14 | + Args: |
| 15 | + mesh (vtkDataSet): Input mesh. |
| 16 | +
|
| 17 | + Returns: |
| 18 | + npt.NDArray[np.float64]: Coordinates |
| 19 | + """ |
| 20 | + points = mesh.GetPoints() |
| 21 | + nPoints = points.GetNumberOfPoints() |
| 22 | + |
| 23 | + coords = np.zeros( ( nPoints, 3 ), dtype=np.float64 ) |
| 24 | + for i in range( nPoints ): |
| 25 | + point = points.GetPoint( i ) |
| 26 | + coords[ i ] = [ point[ 0 ], point[ 1 ], point[ 2 ] ] |
| 27 | + |
| 28 | + return coords |
| 29 | + |
| 30 | + |
| 31 | +def extractTetConnectivity( mesh: vtkDataSet ) -> tuple[ npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ] ]: |
| 32 | + """Extract connectivity for all tetrahedra. |
| 33 | +
|
| 34 | + Args: |
| 35 | + mesh (vtkDataSet): Mesh to analyze. |
| 36 | +
|
| 37 | + Returns: |
| 38 | + tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: |
| 39 | + Cell IDS corresponding to tetrahedra, |
| 40 | + Connectivity of these cells |
| 41 | + """ |
| 42 | + ncells = mesh.GetNumberOfCells() |
| 43 | + tetrahedraIds = [] |
| 44 | + tetrahedraConnectivity = [] |
| 45 | + |
| 46 | + for cellID in range( ncells ): |
| 47 | + if mesh.GetCellType( cellID ) == VTK_TETRA: |
| 48 | + cell = mesh.GetCell( cellID ) |
| 49 | + pointIds = cell.GetPointIds() |
| 50 | + conn = [ pointIds.GetId( i ) for i in range( 4 ) ] |
| 51 | + tetrahedraIds.append( cellID ) |
| 52 | + tetrahedraConnectivity.append( conn ) |
| 53 | + |
| 54 | + return np.array( tetrahedraIds ), np.array( tetrahedraConnectivity ) |
| 55 | + |
| 56 | + |
| 57 | +def analyzeAllTets( coords: npt.NDArray[ np.float64 ], |
| 58 | + connectivity: npt.NDArray[ np.float64 ] ) -> dict[ str, dict[ str, Any ] ]: |
| 59 | + """Vectorized analysis of all tetrahedra. |
| 60 | +
|
| 61 | + This analysis computes the following metrics: volumes, aspect ratio, radius ratio, flatness ratio,shape quality, min and max edge, min and max dihedral angles, dihedral range. |
| 62 | +
|
| 63 | +
|
| 64 | + Args: |
| 65 | + coords (npt.NDArray[np.float64]): Tetrahedra coordinates. |
| 66 | + connectivity (npt.NDArray[np.float64]): Connectivity. |
| 67 | +
|
| 68 | + Returns: |
| 69 | + dict[str, dict[str, Any]]: Dictionary with keys 'volumes', 'aspectRatio', 'radiusRatio', 'flatnessRatio', 'shapeQuality', 'minEdge', 'maxEdge', 'minDihedral', 'maxDihedral', 'dihedralRange' |
| 70 | + """ |
| 71 | + # Get coordinates for all vertices |
| 72 | + v0 = coords[ connectivity[ :, 0 ] ] |
| 73 | + v1 = coords[ connectivity[ :, 1 ] ] |
| 74 | + v2 = coords[ connectivity[ :, 2 ] ] |
| 75 | + v3 = coords[ connectivity[ :, 3 ] ] |
| 76 | + |
| 77 | + # Compute edges |
| 78 | + e01 = v1 - v0 |
| 79 | + e02 = v2 - v0 |
| 80 | + e03 = v3 - v0 |
| 81 | + e12 = v2 - v1 |
| 82 | + e13 = v3 - v1 |
| 83 | + e23 = v3 - v2 |
| 84 | + |
| 85 | + # Edge lengths |
| 86 | + l01 = np.linalg.norm( e01, axis=1 ) |
| 87 | + l02 = np.linalg.norm( e02, axis=1 ) |
| 88 | + l03 = np.linalg.norm( e03, axis=1 ) |
| 89 | + l12 = np.linalg.norm( e12, axis=1 ) |
| 90 | + l13 = np.linalg.norm( e13, axis=1 ) |
| 91 | + l23 = np.linalg.norm( e23, axis=1 ) |
| 92 | + |
| 93 | + allEdges = np.stack( [ l01, l02, l03, l12, l13, l23 ], axis=1 ) |
| 94 | + minEdge = np.min( allEdges, axis=1 ) |
| 95 | + maxEdge = np.max( allEdges, axis=1 ) |
| 96 | + |
| 97 | + # Volumes |
| 98 | + cross_product = np.cross( e01, e02 ) |
| 99 | + volumes = np.abs( np.sum( cross_product * e03, axis=1 ) / 6.0 ) |
| 100 | + |
| 101 | + # Face areas |
| 102 | + face012 = 0.5 * np.linalg.norm( np.cross( e01, e02 ), axis=1 ) |
| 103 | + face013 = 0.5 * np.linalg.norm( np.cross( e01, e03 ), axis=1 ) |
| 104 | + face023 = 0.5 * np.linalg.norm( np.cross( e02, e03 ), axis=1 ) |
| 105 | + face123 = 0.5 * np.linalg.norm( np.cross( e12, e13 ), axis=1 ) |
| 106 | + |
| 107 | + allFaces = np.stack( [ face012, face013, face023, face123 ], axis=1 ) |
| 108 | + maxFaceArea = np.max( allFaces, axis=1 ) |
| 109 | + totalSurfaceArea = np.sum( allFaces, axis=1 ) |
| 110 | + |
| 111 | + # Aspect ratio |
| 112 | + minAltitude = 3.0 * volumes / np.maximum( maxFaceArea, 1e-15 ) |
| 113 | + aspectRatio = maxEdge / np.maximum( minAltitude, 1e-15 ) |
| 114 | + aspectRatio[ volumes < 1e-15 ] = np.inf |
| 115 | + |
| 116 | + # Radius ratio |
| 117 | + inradius = 3.0 * volumes / np.maximum( totalSurfaceArea, 1e-15 ) |
| 118 | + circumradius = ( maxEdge**3 ) / np.maximum( 24.0 * volumes, 1e-15 ) |
| 119 | + radiusRatio = circumradius / np.maximum( inradius, 1e-15 ) |
| 120 | + |
| 121 | + # Flatness ratio |
| 122 | + flatnessRatio = volumes / np.maximum( maxFaceArea * minEdge, 1e-15 ) |
| 123 | + |
| 124 | + # Shape quality |
| 125 | + sumEdgeShapeQuality = np.sum( allEdges**2, axis=1 ) |
| 126 | + shapeQuality = 12.0 * ( 3.0 * volumes )**( 2.0 / 3.0 ) / np.maximum( sumEdgeShapeQuality, 1e-15 ) |
| 127 | + shapeQuality[ volumes < 1e-15 ] = 0.0 |
| 128 | + |
| 129 | + # Dihedral angles |
| 130 | + def computeDihedralAngle( normal1: npt.NDArray[ np.float64 ], |
| 131 | + normal2: npt.NDArray[ np.float64 ] ) -> npt.NDArray[ np.float64 ]: |
| 132 | + """Compute dihedral angle between two face normals. |
| 133 | +
|
| 134 | + Args: |
| 135 | + normal1 (npt.NDArray[np.float64]): Normal vector 1 |
| 136 | + normal2 (npt.NDArray[np.float64]): Normal vector 2 |
| 137 | +
|
| 138 | + Returns: |
| 139 | + npt.NDArray[ np.float64 ]: Dihedral angle |
| 140 | + """ |
| 141 | + n1Norm = normal1 / np.maximum( np.linalg.norm( normal1, axis=1, keepdims=True ), 1e-15 ) |
| 142 | + n2Norm = normal2 / np.maximum( np.linalg.norm( normal2, axis=1, keepdims=True ), 1e-15 ) |
| 143 | + cosAngle = np.sum( n1Norm * n2Norm, axis=1 ) |
| 144 | + cosAngle = np.clip( cosAngle, -1.0, 1.0 ) |
| 145 | + angle = np.arccos( cosAngle ) * 180.0 / np.pi |
| 146 | + return angle |
| 147 | + |
| 148 | + # Face normals |
| 149 | + normal012: npt.NDArray[ np.float64 ] = np.cross( e01, e02 ) |
| 150 | + normal013: npt.NDArray[ np.float64 ] = np.cross( e01, e03 ) |
| 151 | + normal023: npt.NDArray[ np.float64 ] = np.cross( e02, e03 ) |
| 152 | + normal123: npt.NDArray[ np.float64 ] = np.cross( e12, e13 ) |
| 153 | + |
| 154 | + # Dihedral angles for each edge |
| 155 | + dihedral01 = computeDihedralAngle( normal012, normal013 ) |
| 156 | + dihedral02 = computeDihedralAngle( normal012, normal023 ) |
| 157 | + dihedral03 = computeDihedralAngle( normal013, normal023 ) |
| 158 | + dihedral12 = computeDihedralAngle( normal012, normal123 ) |
| 159 | + dihedral13 = computeDihedralAngle( normal013, normal123 ) |
| 160 | + dihedral23 = computeDihedralAngle( normal023, normal123 ) |
| 161 | + |
| 162 | + allDihedrals = np.stack( [ dihedral01, dihedral02, dihedral03, dihedral12, dihedral13, dihedral23 ], axis=1 ) |
| 163 | + |
| 164 | + minDihedral = np.min( allDihedrals, axis=1 ) |
| 165 | + maxDihedral = np.max( allDihedrals, axis=1 ) |
| 166 | + dihedralRange = maxDihedral - minDihedral |
| 167 | + |
| 168 | + return { |
| 169 | + 'volumes': volumes, |
| 170 | + 'aspectRatio': aspectRatio, |
| 171 | + 'radiusRatio': radiusRatio, |
| 172 | + 'flatnessRatio': flatnessRatio, |
| 173 | + 'shapeQuality': shapeQuality, |
| 174 | + 'minEdge': minEdge, |
| 175 | + 'maxEdge': maxEdge, |
| 176 | + 'minDihedral': minDihedral, |
| 177 | + 'maxDihedral': maxDihedral, |
| 178 | + 'dihedralRange': dihedralRange |
| 179 | + } |
| 180 | + |
| 181 | + |
| 182 | +# Combined quality score |
| 183 | +def computeQualityScore( aspectRatio: npt.NDArray[ np.float64 ], shapeQuality: npt.NDArray[ np.float64 ], |
| 184 | + edgeRatio: npt.NDArray[ np.float64 ], |
| 185 | + minDihedralAngle: npt.NDArray[ np.float64 ] ) -> npt.NDArray[ np.float64 ]: |
| 186 | + """Compute combined quality score (0-100) from aspect ratio, shape quality, minimal dihedral angle and edge ratio. |
| 187 | +
|
| 188 | + The quality score can be interpreted with the following scale: |
| 189 | + Excellent (>80) |
| 190 | + Good (60-80) |
| 191 | + Fair (30-60) |
| 192 | + Poor (≤30) |
| 193 | +
|
| 194 | + Args: |
| 195 | + aspectRatio(npt.NDArray[np.float64]): Aspect ratio |
| 196 | + shapeQuality(npt.NDArray[np.float64]): Shape quality |
| 197 | + edgeRatio(npt.NDArray[np.float64]): Edge ratio |
| 198 | + minDihedralAngle(npt.NDArray[np.float64]): Minimal edge ratio |
| 199 | +
|
| 200 | + Returns: |
| 201 | + np.float64: Quality score |
| 202 | + """ |
| 203 | + aspectRatioNorm = np.clip( 1.0 / ( aspectRatio / 1.73 ), 0, 1 ) |
| 204 | + shapeQualityNorm = shapeQuality |
| 205 | + edgeRatioNorm = np.clip( 1.0 / edgeRatio, 0, 1 ) |
| 206 | + dihedralMinNorm = np.clip( minDihedralAngle / 60.0, 0, 1 ) |
| 207 | + score = ( 0.3 * aspectRatioNorm + 0.4 * shapeQualityNorm + 0.2 * edgeRatioNorm + 0.1 * dihedralMinNorm ) * 100 |
| 208 | + |
| 209 | + return score |
0 commit comments