cardio 2023.1.2__py3-none-any.whl → 2025.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cardio/mesh.py CHANGED
@@ -1,39 +1,259 @@
1
+ # System
2
+ import enum
1
3
  import logging
2
- import os
3
4
 
4
- from vtkmodules.vtkIOGeometry import vtkOBJReader
5
- from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper, vtkRenderer
5
+ # Third Party
6
+ import numpy as np
7
+ import pydantic as pc
8
+ import vtk
6
9
 
7
- from . import Object
10
+ # Internal
11
+ from .object import Object
12
+ from .property_config import Representation, vtkPropertyConfig
13
+
14
+
15
+ class SurfaceType(enum.Enum):
16
+ """Surface coloring types for mesh rendering."""
17
+
18
+ SOLID = "solid"
19
+ SQUEEZ = "squeez"
20
+
21
+
22
+ def apply_elementwise(
23
+ array1: vtk.vtkFloatArray, array2: vtk.vtkFloatArray, func, name: str = "Result"
24
+ ) -> vtk.vtkFloatArray:
25
+ if array1.GetNumberOfTuples() != array2.GetNumberOfTuples():
26
+ raise ValueError(
27
+ f"Array lengths must match: {array1.GetNumberOfTuples()} != {array2.GetNumberOfTuples()}"
28
+ )
29
+
30
+ result_array = vtk.vtkFloatArray()
31
+ result_array.SetName(name)
32
+ result_array.SetNumberOfComponents(1)
33
+
34
+ for i in range(array1.GetNumberOfTuples()):
35
+ val1 = array1.GetValue(i)
36
+ val2 = array2.GetValue(i)
37
+ result = func(val1, val2)
38
+ result_array.InsertNextValue(result)
39
+
40
+ return result_array
41
+
42
+
43
+ def calculate_squeez(current_area: float, ref_area: float) -> float:
44
+ if ref_area > 0:
45
+ return (current_area / ref_area) ** 0.5
46
+ return 1.0
8
47
 
9
48
 
10
49
  class Mesh(Object):
11
- def __init__(self, cfg: str, renderer: vtkRenderer):
12
- super().__init__(cfg, renderer)
13
- self.actors: list[vtkActor] = []
50
+ """Mesh object with subdivision support."""
14
51
 
15
- self.color_r: float = cfg["color_r"]
16
- self.color_g: float = cfg["color_g"]
17
- self.color_b: float = cfg["color_b"]
52
+ pattern: str = pc.Field(
53
+ default="${frame}.obj", description="Filename pattern with $frame placeholder"
54
+ )
55
+ _actors: list[vtk.vtkActor] = pc.PrivateAttr(default_factory=list)
56
+ properties: vtkPropertyConfig = pc.Field(
57
+ default_factory=vtkPropertyConfig, description="Property configuration"
58
+ )
59
+ loop_subdivision_iterations: int = pc.Field(ge=0, le=5, default=0)
60
+ surface_type: SurfaceType = pc.Field(default=SurfaceType.SOLID)
61
+ ctf_min: float = pc.Field(ge=0.0, default=0.7)
62
+ ctf_max: float = pc.Field(ge=0.0, default=1.3)
18
63
 
19
- frame = 0
20
- while os.path.exists(self.path_for_frame(frame)):
64
+ @pc.model_validator(mode="after")
65
+ def initialize_mesh(self):
66
+ # Pass 1: Load all frames and determine topology consistency
67
+ frame_data = []
68
+ for frame, path in enumerate(self.path_list):
21
69
  logging.info(f"{self.label}: Loading frame {frame}.")
22
- reader = vtkOBJReader()
23
- mapper = vtkPolyDataMapper()
24
- actor = vtkActor()
25
- reader.SetFileName(self.path_for_frame(frame))
26
- mapper.SetInputConnection(reader.GetOutputPort())
27
- actor.SetMapper(mapper)
70
+ reader = vtk.vtkOBJReader()
71
+ reader.SetFileName(path)
28
72
  reader.Update()
29
- self.actors += [actor]
30
- frame += 1
31
-
32
- def setup_pipeline(self, frame: int):
33
- for a in self.actors:
34
- self.renderer.AddActor(a)
35
- a.SetVisibility(False)
36
- a.GetProperty().SetColor(self.color_r, self.color_g, self.color_b)
37
- if self.visible:
38
- self.actors[frame].SetVisibility(True)
39
- self.renderer.ResetCamera()
73
+
74
+ if self.loop_subdivision_iterations > 0:
75
+ subdivision_filter = vtk.vtkLoopSubdivisionFilter()
76
+ subdivision_filter.SetInputConnection(reader.GetOutputPort())
77
+ subdivision_filter.SetNumberOfSubdivisions(
78
+ self.loop_subdivision_iterations
79
+ )
80
+ subdivision_filter.Update()
81
+ polydata = subdivision_filter.GetOutput()
82
+ else:
83
+ polydata = reader.GetOutput()
84
+
85
+ if self.properties.representation == Representation.Surface:
86
+ polydata = self.calculate_mesh_areas(polydata)
87
+
88
+ frame_data.append(polydata)
89
+
90
+ consistent_topology = self._should_calculate_squeez(frame_data)
91
+
92
+ # Pass 2: Create actors with appropriate coloring
93
+ for frame, polydata in enumerate(frame_data):
94
+ mapper = self._setup_coloring(
95
+ polydata, frame_data, frame, consistent_topology
96
+ )
97
+
98
+ actor = vtk.vtkActor()
99
+ actor.SetMapper(mapper)
100
+
101
+ self._actors.append(actor)
102
+
103
+ return self
104
+
105
+ @property
106
+ def actors(self) -> list[vtk.vtkActor]:
107
+ return self._actors
108
+
109
+ def _setup_coloring(self, polydata, frame_data, frame, consistent_topology):
110
+ """Setup mesh coloring based on surface_type with smart fallback."""
111
+ if self.surface_type == SurfaceType.SQUEEZ:
112
+ if self._can_use_squeez_coloring(consistent_topology):
113
+ return self._setup_squeez_coloring(polydata, frame_data, frame)
114
+ else:
115
+ self._log_squeez_fallback()
116
+ return self._setup_solid_coloring(polydata)
117
+ else: # SurfaceType.SOLID
118
+ return self._setup_solid_coloring(polydata)
119
+
120
+ def _can_use_squeez_coloring(self, consistent_topology):
121
+ """Check if SQUEEZ coloring is possible."""
122
+ return (
123
+ self.properties.representation == Representation.Surface
124
+ and consistent_topology
125
+ )
126
+
127
+ def _log_squeez_fallback(self):
128
+ """Log warning when falling back from SQUEEZ to solid coloring."""
129
+ if self.properties.representation != Representation.Surface:
130
+ logging.warning(
131
+ f"SQUEEZ coloring requested for {self.label} but representation is not Surface, falling back to solid coloring"
132
+ )
133
+ else:
134
+ logging.warning(
135
+ f"SQUEEZ coloring requested for {self.label} but topology inconsistent across frames, falling back to solid coloring"
136
+ )
137
+
138
+ def _setup_solid_coloring(self, polydata):
139
+ """Setup solid coloring using VTK property colors."""
140
+ mapper = vtk.vtkPolyDataMapper()
141
+ mapper.SetInputData(polydata)
142
+ mapper.ScalarVisibilityOff()
143
+ return mapper
144
+
145
+ def _setup_squeez_coloring(self, polydata, frame_data, frame):
146
+ """Setup SQUEEZ coloring with scalar data."""
147
+ ref_quality_array = frame_data[0].GetCellData().GetArray("Area")
148
+ if frame == 0:
149
+ ratio_array = apply_elementwise(
150
+ ref_quality_array, ref_quality_array, lambda x, y: 1.0, "SQUEEZ"
151
+ )
152
+ else:
153
+ current_quality_array = polydata.GetCellData().GetArray("Area")
154
+ ratio_array = apply_elementwise(
155
+ current_quality_array,
156
+ ref_quality_array,
157
+ calculate_squeez,
158
+ "SQUEEZ",
159
+ )
160
+ polydata.GetCellData().AddArray(ratio_array)
161
+ polydata.GetCellData().SetActiveScalars("SQUEEZ")
162
+
163
+ mapper = vtk.vtkPolyDataMapper()
164
+ mapper.SetInputData(polydata)
165
+ mapper = self.setup_scalar_coloring(mapper)
166
+ return mapper
167
+
168
+ def color_transfer_function(self):
169
+ ctf = vtk.vtkColorTransferFunction()
170
+ ctf.AddRGBPoint(0.7, 0.0, 0.0, 1.0)
171
+ ctf.AddRGBPoint(1.0, 1.0, 0.0, 0.0)
172
+ ctf.AddRGBPoint(1.3, 1.0, 1.0, 0.0)
173
+ return ctf
174
+
175
+ def setup_scalar_coloring(self, mapper):
176
+ mapper.SetColorModeToMapScalars()
177
+ mapper.SetScalarModeToUseCellData()
178
+ mapper.SetLookupTable(self.color_transfer_function())
179
+ mapper.SetScalarRange(self.ctf_min, self.ctf_max)
180
+ mapper.ScalarVisibilityOn()
181
+ mapper.SetInterpolateScalarsBeforeMapping(False)
182
+ return mapper
183
+
184
+ def calculate_mesh_areas(self, polydata):
185
+ """Calculate area of each triangle/cell in the mesh using VTK mesh quality."""
186
+ # Ensure we have triangles
187
+ triangle_filter = vtk.vtkTriangleFilter()
188
+ triangle_filter.SetInputData(polydata)
189
+ triangle_filter.Update()
190
+ triangulated = triangle_filter.GetOutput()
191
+
192
+ # Use VTK mesh quality to calculate areas
193
+ mesh_quality = vtk.vtkMeshQuality()
194
+ mesh_quality.SetInputData(triangulated)
195
+ mesh_quality.SetTriangleQualityMeasureToArea()
196
+ mesh_quality.SetQuadQualityMeasureToArea()
197
+ mesh_quality.SaveCellQualityOn()
198
+ mesh_quality.Update()
199
+
200
+ quality_output = mesh_quality.GetOutput()
201
+ quality_array = quality_output.GetCellData().GetArray("Quality")
202
+ quality_array.SetName("Area")
203
+
204
+ return quality_output
205
+
206
+ def _should_calculate_squeez(self, frame_data: list) -> bool:
207
+ if (
208
+ self.properties.representation != Representation.Surface
209
+ or len(frame_data) <= 1
210
+ ):
211
+ return False
212
+
213
+ reference_frame = frame_data[0]
214
+ ref_area_array = reference_frame.GetCellData().GetArray("Area")
215
+
216
+ if ref_area_array is None:
217
+ return False
218
+
219
+ ref_num_cells = reference_frame.GetNumberOfCells()
220
+ ref_num_points = reference_frame.GetNumberOfPoints()
221
+ ref_num_areas = ref_area_array.GetNumberOfTuples()
222
+
223
+ # Check all frames have identical topology
224
+ for polydata in frame_data[1:]:
225
+ area_array = polydata.GetCellData().GetArray("Area")
226
+ if (
227
+ area_array is None
228
+ or polydata.GetNumberOfCells() != ref_num_cells
229
+ or polydata.GetNumberOfPoints() != ref_num_points
230
+ or area_array.GetNumberOfTuples() != ref_num_areas
231
+ ):
232
+ return False
233
+
234
+ return True
235
+
236
+ def configure_actors(self):
237
+ """Configure actor properties without adding to renderer."""
238
+ for actor in self._actors:
239
+ actor.SetVisibility(False)
240
+ actor.SetProperty(self.properties.vtk_property)
241
+
242
+ # Apply flat shading for SQUEEZ coloring
243
+ if self._is_using_squeez_coloring():
244
+ for actor in self._actors:
245
+ actor.GetProperty().SetInterpolationToFlat()
246
+
247
+ def _is_using_squeez_coloring(self):
248
+ """Check if SQUEEZ coloring is actually being used (not fell back to solid)."""
249
+ if self.surface_type != SurfaceType.SQUEEZ:
250
+ return False
251
+
252
+ # Check if any actor has SQUEEZ scalar data
253
+ for actor in self._actors:
254
+ mapper = actor.GetMapper()
255
+ if mapper.GetInput():
256
+ squeez_array = mapper.GetInput().GetCellData().GetArray("SQUEEZ")
257
+ if squeez_array is not None:
258
+ return True
259
+ return False
cardio/object.py CHANGED
@@ -1,13 +1,186 @@
1
- from vtkmodules.vtkRenderingCore import vtkRenderer
1
+ # System
2
+ import functools
3
+ import logging
4
+ import pathlib as pl
5
+ import re
6
+ import string
2
7
 
8
+ # Third Party
9
+ import pydantic as pc
10
+ import vtk
3
11
 
4
- class Object:
5
- def __init__(self, cfg: str, renderer: vtkRenderer):
6
- self.label: str = cfg["label"]
7
- self.directory: str = cfg["directory"]
8
- self.suffix: str = cfg["suffix"]
9
- self.visible: bool = cfg["visible"]
10
- self.renderer = renderer
12
+ from .utils import calculate_combined_bounds
11
13
 
12
- def path_for_frame(self, frame: int) -> str:
13
- return f"{self.directory}/{frame}.{self.suffix}"
14
+
15
+ class Object(pc.BaseModel):
16
+ """Base class for renderable objects with validated configuration."""
17
+
18
+ model_config = pc.ConfigDict(arbitrary_types_allowed=True, frozen=True)
19
+
20
+ label: str = pc.Field(description="Object identifier (only [a-zA-Z0-9_] allowed)")
21
+ directory: pl.Path = pc.Field(description="Directory containing object files")
22
+ pattern: str | None = pc.Field(
23
+ default=None, description="Filename pattern with ${frame} placeholder"
24
+ )
25
+ file_paths: list[str] | None = pc.Field(
26
+ default=None, description="Static list of file paths relative to directory"
27
+ )
28
+ visible: bool = pc.Field(
29
+ default=True, description="Whether object is initially visible"
30
+ )
31
+ clipping_enabled: bool = pc.Field(default=False)
32
+
33
+ @pc.field_validator("label")
34
+ @classmethod
35
+ def validate_label(cls, v: str) -> str:
36
+ """Validate that label contains only letters, numbers, and underscores."""
37
+ if not isinstance(v, str):
38
+ raise ValueError("label must be a string")
39
+
40
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
41
+ raise ValueError(
42
+ f"label '{v}' contains invalid characters. "
43
+ "Labels must contain only letters, numbers, and underscores."
44
+ )
45
+
46
+ return v
47
+
48
+ @pc.field_validator("pattern")
49
+ @classmethod
50
+ def validate_pattern(cls, v: str | None) -> str | None:
51
+ if v is None:
52
+ return v
53
+
54
+ if not isinstance(v, str):
55
+ raise ValueError("pattern must be a string")
56
+
57
+ if not re.match(r"^[a-zA-Z0-9_\-.${}]+$", v):
58
+ raise ValueError("Pattern contains unsafe characters")
59
+
60
+ if "${frame}" not in v and "$frame" not in v:
61
+ raise ValueError("Pattern must contain $frame placeholder")
62
+
63
+ return v
64
+
65
+ @pc.model_validator(mode="after")
66
+ def validate_pattern_or_file_paths(self):
67
+ if self.pattern is None and self.file_paths is None:
68
+ raise ValueError("Either pattern or file_paths must be provided")
69
+ if self.pattern is not None and self.file_paths is not None:
70
+ logging.info("Both pattern and file_paths specified; using file_paths.")
71
+
72
+ # Validate all paths for traversal attacks and file existence
73
+ for path in self.path_list:
74
+ if not path.resolve().is_relative_to(self.directory.resolve()):
75
+ raise ValueError(
76
+ f"Path {path} would access files outside base directory"
77
+ )
78
+ if not path.is_file():
79
+ raise ValueError(f"File does not exist: {path}")
80
+
81
+ return self
82
+
83
+ def path_for_frame(self, frame: int) -> pl.Path:
84
+ if self.pattern is None:
85
+ raise ValueError("Cannot use path_for_frame with static file_paths")
86
+ template = string.Template(self.pattern)
87
+ filename = template.safe_substitute(frame=frame)
88
+ return self.directory / filename
89
+
90
+ @functools.cached_property
91
+ def path_list(self) -> list[pl.Path]:
92
+ """Return list of file paths, using static paths if provided, otherwise dynamic pattern-based paths."""
93
+ if self.file_paths is not None:
94
+ return [self.directory / path for path in self.file_paths]
95
+
96
+ paths = []
97
+ frame = 0
98
+ while True:
99
+ path = self.path_for_frame(frame)
100
+ if not path.is_file():
101
+ break
102
+ paths.append(path)
103
+ frame += 1
104
+ return paths
105
+
106
+ @property
107
+ def combined_bounds(self) -> list[float]:
108
+ """Get combined bounds encompassing all actors."""
109
+ if not hasattr(self, "actors") or not self.actors:
110
+ return [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
111
+
112
+ return calculate_combined_bounds(self.actors)
113
+
114
+ @functools.cached_property
115
+ def clipping_planes(self) -> vtk.vtkPlanes:
116
+ """Generate clipping planes based on combined bounds of all actors."""
117
+ if not hasattr(self, "actors") or not self.actors:
118
+ return None
119
+
120
+ bounds = self.combined_bounds
121
+ planes = vtk.vtkPlanes()
122
+ self._create_clipping_planes_from_bounds(planes, bounds)
123
+ return planes
124
+
125
+ def _create_clipping_planes_from_bounds(self, planes: vtk.vtkPlanes, bounds):
126
+ """Create 6 clipping planes from box bounds."""
127
+
128
+ # Create 6 planes for the box faces
129
+ normals = [
130
+ [1, 0, 0],
131
+ [-1, 0, 0], # x-min, x-max
132
+ [0, 1, 0],
133
+ [0, -1, 0], # y-min, y-max
134
+ [0, 0, 1],
135
+ [0, 0, -1], # z-min, z-max
136
+ ]
137
+ origins = [
138
+ [bounds[0], 0, 0],
139
+ [bounds[1], 0, 0], # x-min, x-max
140
+ [0, bounds[2], 0],
141
+ [0, bounds[3], 0], # y-min, y-max
142
+ [0, 0, bounds[4]],
143
+ [0, 0, bounds[5]], # z-min, z-max
144
+ ]
145
+
146
+ points = vtk.vtkPoints()
147
+ norms = vtk.vtkDoubleArray()
148
+ norms.SetNumberOfComponents(3)
149
+ norms.SetName("Normals")
150
+
151
+ for normal, origin in zip(normals, origins):
152
+ points.InsertNextPoint(origin)
153
+ norms.InsertNextTuple(normal)
154
+
155
+ planes.SetPoints(points)
156
+ planes.SetNormals(norms)
157
+
158
+ def toggle_clipping(self, enabled: bool):
159
+ """Enable or disable clipping for all actors."""
160
+ if not hasattr(self, "actors"):
161
+ return
162
+
163
+ if enabled and self.clipping_planes:
164
+ # Apply clipping to all actors
165
+ for actor in self.actors:
166
+ mapper = actor.GetMapper()
167
+ mapper.SetClippingPlanes(self.clipping_planes)
168
+ else:
169
+ # Remove clipping from all actors
170
+ for actor in self.actors:
171
+ mapper = actor.GetMapper()
172
+ mapper.RemoveAllClippingPlanes()
173
+
174
+ def update_clipping_bounds(self, bounds):
175
+ """Update clipping bounds from UI controls."""
176
+ if not self.clipping_planes:
177
+ return
178
+
179
+ # Update clipping planes with new bounds
180
+ self._create_clipping_planes_from_bounds(self.clipping_planes, bounds)
181
+
182
+ # Apply to all actors if clipping is enabled
183
+ if self.clipping_enabled:
184
+ for actor in self.actors:
185
+ mapper = actor.GetMapper()
186
+ mapper.SetClippingPlanes(self.clipping_planes)
@@ -0,0 +1,56 @@
1
+ # System
2
+ import enum
3
+ import functools
4
+
5
+ # Third Party
6
+ import pydantic as pc
7
+ import vtk
8
+
9
+ # Internal
10
+ from .types import RGBColor, ScalarComponent
11
+
12
+
13
+ class Representation(enum.IntEnum):
14
+ """Mesh representation modes for VTK rendering."""
15
+
16
+ Points = 0
17
+ Wireframe = 1
18
+ Surface = 2
19
+
20
+
21
+ class Interpolation(enum.IntEnum):
22
+ """Mesh interpolation modes for VTK rendering."""
23
+
24
+ Flat = 0
25
+ Gouraud = 1
26
+ Phong = 2
27
+ PBR = 3
28
+
29
+
30
+ class vtkPropertyConfig(pc.BaseModel):
31
+ """Configuration for mesh rendering properties."""
32
+
33
+ representation: Representation = pc.Field(
34
+ default=Representation.Surface, description="Rendering representation mode"
35
+ )
36
+ color: RGBColor = (1.0, 1.0, 1.0)
37
+ edge_visibility: bool = pc.Field(default=False, description="Show edges")
38
+ vertex_visibility: bool = pc.Field(default=False, description="Show vertices")
39
+ shading: bool = pc.Field(default=True, description="Enable shading")
40
+ interpolation: Interpolation = pc.Field(
41
+ default=Interpolation.Gouraud, description="Interpolation mode"
42
+ )
43
+ opacity: ScalarComponent = 1.0
44
+
45
+ @functools.cached_property
46
+ def vtk_property(self) -> vtk.vtkProperty:
47
+ """Create a fully configured VTK property from this configuration."""
48
+ _vtk_property = vtk.vtkProperty()
49
+ _vtk_property.SetRepresentation(self.representation.value)
50
+ _vtk_property.SetColor(*self.color)
51
+ _vtk_property.SetEdgeVisibility(self.edge_visibility)
52
+ _vtk_property.SetVertexVisibility(self.vertex_visibility)
53
+ _vtk_property.SetShading(self.shading)
54
+ _vtk_property.SetInterpolation(self.interpolation.value)
55
+ _vtk_property.SetOpacity(self.opacity)
56
+ return _vtk_property