cardio 2025.8.1__py3-none-any.whl → 2025.9.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/__init__.py CHANGED
@@ -1,15 +1,16 @@
1
+ from . import window_level
1
2
  from .logic import Logic
2
3
  from .mesh import Mesh
3
4
  from .object import Object
4
5
  from .scene import Scene
5
6
  from .screenshot import Screenshot
6
7
  from .segmentation import Segmentation
7
- from .transfer_functions import (
8
- list_available_presets,
9
- load_preset,
10
- )
11
8
  from .ui import UI
12
9
  from .volume import Volume
10
+ from .volume_property_presets import (
11
+ list_volume_property_presets,
12
+ load_volume_property_preset,
13
+ )
13
14
 
14
15
  __all__ = [
15
16
  "Object",
@@ -20,8 +21,8 @@ __all__ = [
20
21
  "Screenshot",
21
22
  "UI",
22
23
  "Logic",
23
- "load_preset",
24
- "list_available_presets",
24
+ "load_volume_property_preset",
25
+ "list_volume_property_presets",
25
26
  ]
26
27
 
27
- __version__ = "2025.8.1"
28
+ __version__ = "2025.9.0"
cardio/app.py CHANGED
@@ -15,7 +15,7 @@ from .ui import UI
15
15
 
16
16
  class CardioApp(tm.app.TrameApp):
17
17
  def __init__(self, name=None):
18
- super().__init__(server=name, client_type="vue2")
18
+ super().__init__(server=name, client_type="vue3")
19
19
 
20
20
  # Add config file argument to Trame's parser
21
21
  self.server.cli.add_argument(
@@ -0,0 +1,84 @@
1
+ # System
2
+ import numpy as np
3
+
4
+ # Third Party
5
+ import vtk
6
+
7
+
8
+ def blend_transfer_functions(tfs, scalar_range=(-2000, 2000), num_samples=512):
9
+ """
10
+ Blend multiple transfer functions using volume rendering emission-absorption model.
11
+
12
+ Based on the volume rendering equation from:
13
+ - Levoy, M. "Display of Surfaces from Volume Data" IEEE Computer Graphics and Applications, 1988
14
+ - Kajiya, J.T. & Von Herzen, B.P. "Ray tracing volume densities" ACM SIGGRAPH Computer Graphics, 1984
15
+ - Engel, K. et al. "Real-time Volume Graphics" A K Peters, 2006, Chapter 2
16
+
17
+ The volume rendering integral: I = ∫ C(s) * μ(s) * T(s) ds
18
+ where C(s) = emission color, μ(s) = opacity, T(s) = transmission
19
+
20
+ For discrete transfer functions, this becomes:
21
+ - Total emission = Σ(color_i * opacity_i)
22
+ - Total absorption = Σ(opacity_i)
23
+ - Final color = total_emission / total_absorption
24
+ """
25
+ if len(tfs) == 1:
26
+ return tfs[0]
27
+
28
+ sample_points = np.linspace(
29
+ start=scalar_range[0],
30
+ stop=scalar_range[1],
31
+ num=num_samples,
32
+ )
33
+
34
+ # Initialize arrays to store blended values
35
+ blended_opacity = []
36
+ blended_color = []
37
+
38
+ for scalar_val in sample_points:
39
+ # Accumulate emission and absorption for volume rendering
40
+ total_emission = [0.0, 0.0, 0.0]
41
+ total_absorption = 0.0
42
+
43
+ for otf, ctf in tfs:
44
+ # Get opacity and color for this scalar value
45
+ layer_opacity = otf.GetValue(scalar_val)
46
+ layer_color = [0.0, 0.0, 0.0]
47
+ ctf.GetColor(scalar_val, layer_color)
48
+
49
+ # Volume rendering accumulation:
50
+ # Emission = color * opacity (additive)
51
+ # Absorption = opacity (multiplicative through transmission)
52
+ for i in range(3):
53
+ total_emission[i] += layer_color[i] * layer_opacity
54
+
55
+ total_absorption += layer_opacity
56
+
57
+ # Clamp values to reasonable ranges
58
+ total_absorption = min(total_absorption, 1.0)
59
+ for i in range(3):
60
+ total_emission[i] = min(total_emission[i], 1.0)
61
+
62
+ # For the final color, normalize emission by absorption if absorption > 0
63
+ if total_absorption > 0.001: # Avoid division by zero
64
+ final_color = [total_emission[i] / total_absorption for i in range(3)]
65
+ else:
66
+ final_color = [0.0, 0.0, 0.0]
67
+
68
+ # Clamp final colors
69
+ final_color = [min(c, 1.0) for c in final_color]
70
+
71
+ blended_opacity.append(total_absorption)
72
+ blended_color.append(final_color)
73
+
74
+ # Create new VTK transfer functions with blended values
75
+ blended_otf = vtk.vtkPiecewiseFunction()
76
+ blended_ctf = vtk.vtkColorTransferFunction()
77
+
78
+ for i, scalar_val in enumerate(sample_points):
79
+ blended_otf.AddPoint(scalar_val, blended_opacity[i])
80
+ blended_ctf.AddRGBPoint(
81
+ scalar_val, blended_color[i][0], blended_color[i][1], blended_color[i][2]
82
+ )
83
+
84
+ return blended_otf, blended_ctf
@@ -0,0 +1,29 @@
1
+ # Third Party
2
+ import pydantic as pc
3
+ import vtk
4
+
5
+ # Internal
6
+ from .types import RGBColor
7
+
8
+
9
+ class ColorTransferFunctionPoint(pc.BaseModel):
10
+ """A single point in a color transfer function."""
11
+
12
+ x: float = pc.Field(description="Scalar value")
13
+ color: RGBColor
14
+
15
+
16
+ class ColorTransferFunctionConfig(pc.BaseModel):
17
+ """Configuration for a VTK color transfer function."""
18
+
19
+ points: list[ColorTransferFunctionPoint] = pc.Field(
20
+ min_length=1, description="Points defining the color transfer function"
21
+ )
22
+
23
+ @property
24
+ def vtk_function(self) -> vtk.vtkColorTransferFunction:
25
+ """Create VTK color transfer function from this configuration."""
26
+ ctf = vtk.vtkColorTransferFunction()
27
+ for point in self.points:
28
+ ctf.AddRGBPoint(point.x, *point.color)
29
+ return ctf
cardio/logic.py CHANGED
@@ -12,9 +12,34 @@ class Logic:
12
12
  self.server = server
13
13
  self.scene = scene
14
14
 
15
+ # Initialize mpr_presets early to avoid undefined errors
16
+ self.server.state.mpr_presets = []
17
+
18
+ # Initialize volume items for MPR dropdown
19
+ self.server.state.volume_items = [
20
+ {"text": volume.label, "value": volume.label}
21
+ for volume in self.scene.volumes
22
+ ]
23
+
15
24
  self.server.state.change("frame")(self.update_frame)
16
25
  self.server.state.change("playing")(self.play)
17
- self.server.state.change("dark_mode")(self.sync_background_color)
26
+ self.server.state.change("theme_mode")(self.sync_background_color)
27
+ self.server.state.change("mpr_enabled")(self.sync_mpr_mode)
28
+ self.server.state.change("active_volume_label")(self.sync_active_volume)
29
+ self.server.state.change("axial_slice", "sagittal_slice", "coronal_slice")(
30
+ self.update_slice_positions
31
+ )
32
+ self.server.state.change("mpr_window", "mpr_level")(
33
+ self.update_mpr_window_level
34
+ )
35
+ self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
36
+ self.server.state.change("mpr_rotation_sequence")(self.update_mpr_rotation)
37
+
38
+ # Add handlers for individual rotation angles
39
+ for i in range(20):
40
+ self.server.state.change(f"mpr_rotation_angle_{i}")(
41
+ self.update_mpr_rotation
42
+ )
18
43
 
19
44
  # Initialize visibility state variables
20
45
  for m in self.scene.meshes:
@@ -98,6 +123,50 @@ class Logic:
98
123
  self.server.controller.decrement_frame = self.decrement_frame
99
124
  self.server.controller.screenshot = self.screenshot
100
125
  self.server.controller.reset_all = self.reset_all
126
+ self.server.controller.close_application = self.close_application
127
+
128
+ # MPR rotation controllers
129
+ self.server.controller.add_x_rotation = lambda: self.add_mpr_rotation("X")
130
+ self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
131
+ self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
132
+ self.server.controller.remove_rotation_event = self.remove_mpr_rotation
133
+ self.server.controller.reset_rotations = self.reset_mpr_rotations
134
+
135
+ # Initialize MPR state
136
+ self.server.state.mpr_enabled = self.scene.mpr_enabled
137
+ self.server.state.active_volume_label = self.scene.active_volume_label
138
+ self.server.state.axial_slice = self.scene.axial_slice
139
+ self.server.state.sagittal_slice = self.scene.sagittal_slice
140
+ self.server.state.coronal_slice = self.scene.coronal_slice
141
+ self.server.state.mpr_window = self.scene.mpr_window
142
+ self.server.state.mpr_level = self.scene.mpr_level
143
+ self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
144
+ self.server.state.mpr_rotation_sequence = self.scene.mpr_rotation_sequence
145
+
146
+ # Initialize MPR presets data
147
+ try:
148
+ from .window_level import presets
149
+
150
+ self.server.state.mpr_presets = [
151
+ {"text": preset.name, "value": key} for key, preset in presets.items()
152
+ ]
153
+ except Exception as e:
154
+ print(f"Error initializing MPR presets: {e}")
155
+ self.server.state.mpr_presets = []
156
+
157
+ # Initialize rotation angle states (up to 20 rotations like app.py)
158
+ for i in range(20):
159
+ setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
160
+ setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
161
+
162
+ # Apply initial preset to ensure window/level values are set correctly
163
+ # Only update state values, don't call update methods yet since MPR may not be enabled
164
+ from .window_level import presets
165
+
166
+ if self.scene.mpr_window_level_preset in presets:
167
+ preset = presets[self.scene.mpr_window_level_preset]
168
+ self.server.state.mpr_window = preset.window
169
+ self.server.state.mpr_level = preset.level
101
170
 
102
171
  # Initialize clipping state variables
103
172
  self._initialize_clipping_state()
@@ -122,8 +191,95 @@ class Logic:
122
191
  actor = segmentation.actors[frame % len(segmentation.actors)]
123
192
  actor.SetVisibility(True)
124
193
 
194
+ # Update MPR views if MPR is enabled
195
+ self.update_mpr_frame(frame)
196
+
125
197
  self.server.controller.view_update()
126
198
 
199
+ def update_mpr_frame(self, frame):
200
+ """Update MPR views to show the specified frame."""
201
+ if not getattr(self.server.state, "mpr_enabled", False):
202
+ return
203
+
204
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
205
+ if not active_volume_label:
206
+ return
207
+
208
+ # Find the active volume
209
+ active_volume = None
210
+ for volume in self.scene.volumes:
211
+ if volume.label == active_volume_label:
212
+ active_volume = volume
213
+ break
214
+
215
+ if not active_volume:
216
+ return
217
+
218
+ # Get or create MPR actors for the new frame
219
+ mpr_actors = active_volume.get_mpr_actors_for_frame(frame)
220
+
221
+ # Update each MPR renderer with the new frame's actors
222
+ if self.scene.axial_renderWindow:
223
+ axial_renderer = (
224
+ self.scene.axial_renderWindow.GetRenderers().GetFirstRenderer()
225
+ )
226
+ if axial_renderer:
227
+ axial_renderer.RemoveAllViewProps()
228
+ axial_renderer.AddActor(mpr_actors["axial"]["actor"])
229
+ mpr_actors["axial"]["actor"].SetVisibility(True)
230
+ # Apply current slice position and window/level
231
+ self._apply_current_mpr_settings(active_volume, frame)
232
+ axial_renderer.ResetCamera()
233
+
234
+ if self.scene.coronal_renderWindow:
235
+ coronal_renderer = (
236
+ self.scene.coronal_renderWindow.GetRenderers().GetFirstRenderer()
237
+ )
238
+ if coronal_renderer:
239
+ coronal_renderer.RemoveAllViewProps()
240
+ coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
241
+ mpr_actors["coronal"]["actor"].SetVisibility(True)
242
+ coronal_renderer.ResetCamera()
243
+
244
+ if self.scene.sagittal_renderWindow:
245
+ sagittal_renderer = (
246
+ self.scene.sagittal_renderWindow.GetRenderers().GetFirstRenderer()
247
+ )
248
+ if sagittal_renderer:
249
+ sagittal_renderer.RemoveAllViewProps()
250
+ sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
251
+ mpr_actors["sagittal"]["actor"].SetVisibility(True)
252
+ sagittal_renderer.ResetCamera()
253
+
254
+ def _apply_current_mpr_settings(self, active_volume, frame):
255
+ """Apply current slice positions and window/level to MPR actors."""
256
+ # Apply slice positions
257
+ axial_slice = getattr(self.server.state, "axial_slice", 0.5)
258
+ sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
259
+ coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
260
+
261
+ # Get rotation data
262
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
263
+ rotation_angles = {}
264
+ for i in range(len(rotation_sequence)):
265
+ rotation_angles[i] = getattr(
266
+ self.server.state, f"mpr_rotation_angle_{i}", 0
267
+ )
268
+
269
+ active_volume.update_slice_positions(
270
+ frame,
271
+ axial_slice,
272
+ sagittal_slice,
273
+ coronal_slice,
274
+ rotation_sequence,
275
+ rotation_angles,
276
+ )
277
+
278
+ # Apply window/level
279
+ window = getattr(self.server.state, "mpr_window", 400.0)
280
+ level = getattr(self.server.state, "mpr_level", 40.0)
281
+ active_volume.update_mpr_window_level(frame, window, level)
282
+
127
283
  @asynchronous.task
128
284
  async def play(self, playing, **kwargs):
129
285
  if not (self.server.state.incrementing or self.server.state.rotating):
@@ -159,11 +315,11 @@ class Logic:
159
315
 
160
316
  def sync_volume_presets(self, **kwargs):
161
317
  """Update volume transfer function presets based on UI selection."""
162
- from .transfer_functions import load_preset
318
+ from .volume_property_presets import load_volume_property_preset
163
319
 
164
320
  for v in self.scene.volumes:
165
321
  preset_name = self.server.state[f"volume_preset_{v.label}"]
166
- preset = load_preset(preset_name)
322
+ preset = load_volume_property_preset(preset_name)
167
323
 
168
324
  # Apply preset to all actors
169
325
  for actor in v.actors:
@@ -295,9 +451,9 @@ class Logic:
295
451
  self.server.state.bpr = 5
296
452
  self.server.controller.view_update()
297
453
 
298
- def sync_background_color(self, dark_mode, **kwargs):
454
+ def sync_background_color(self, theme_mode, **kwargs):
299
455
  """Sync VTK renderer background with dark mode."""
300
- if dark_mode:
456
+ if theme_mode == "dark":
301
457
  # Dark mode: use dark background from config
302
458
  self.scene.renderer.SetBackground(
303
459
  *self.scene.background.dark,
@@ -363,3 +519,257 @@ class Logic:
363
519
  setattr(self.server.state, f"clip_x_{s.label}", [bounds[0], bounds[1]])
364
520
  setattr(self.server.state, f"clip_y_{s.label}", [bounds[2], bounds[3]])
365
521
  setattr(self.server.state, f"clip_z_{s.label}", [bounds[4], bounds[5]])
522
+
523
+ def sync_mpr_mode(self, mpr_enabled, **kwargs):
524
+ """Handle MPR mode toggle."""
525
+ if (
526
+ mpr_enabled
527
+ and self.scene.volumes
528
+ and not self.server.state.active_volume_label
529
+ ):
530
+ # Auto-select first volume when MPR is enabled and no volume is selected
531
+ self.server.state.active_volume_label = self.scene.volumes[0].label
532
+
533
+ def sync_active_volume(self, active_volume_label, **kwargs):
534
+ """Handle active volume selection for MPR."""
535
+
536
+ if not active_volume_label or not self.server.state.mpr_enabled:
537
+ return
538
+
539
+ # Find the selected volume
540
+ active_volume = None
541
+ for volume in self.scene.volumes:
542
+ if volume.label == active_volume_label:
543
+ active_volume = volume
544
+ break
545
+
546
+ if not active_volume:
547
+ return
548
+
549
+ # Create MPR actors for current frame
550
+ current_frame = getattr(self.server.state, "frame", 0)
551
+ mpr_actors = active_volume.get_mpr_actors_for_frame(current_frame)
552
+
553
+ # Add MPR actors to their respective renderers
554
+ if self.scene.axial_renderWindow:
555
+ axial_renderer = (
556
+ self.scene.axial_renderWindow.GetRenderers().GetFirstRenderer()
557
+ )
558
+ if axial_renderer:
559
+ axial_renderer.RemoveAllViewProps() # Clear existing actors
560
+ axial_renderer.AddActor(mpr_actors["axial"]["actor"])
561
+ mpr_actors["axial"]["actor"].SetVisibility(True)
562
+ axial_renderer.ResetCamera()
563
+
564
+ if self.scene.coronal_renderWindow:
565
+ coronal_renderer = (
566
+ self.scene.coronal_renderWindow.GetRenderers().GetFirstRenderer()
567
+ )
568
+ if coronal_renderer:
569
+ coronal_renderer.RemoveAllViewProps()
570
+ coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
571
+ mpr_actors["coronal"]["actor"].SetVisibility(True)
572
+ coronal_renderer.ResetCamera()
573
+
574
+ if self.scene.sagittal_renderWindow:
575
+ sagittal_renderer = (
576
+ self.scene.sagittal_renderWindow.GetRenderers().GetFirstRenderer()
577
+ )
578
+ if sagittal_renderer:
579
+ sagittal_renderer.RemoveAllViewProps()
580
+ sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
581
+ mpr_actors["sagittal"]["actor"].SetVisibility(True)
582
+ sagittal_renderer.ResetCamera()
583
+
584
+ # Apply current window/level settings to the MPR actors
585
+ window = getattr(self.server.state, "mpr_window", 800.0)
586
+ level = getattr(self.server.state, "mpr_level", 200.0)
587
+ active_volume.update_mpr_window_level(current_frame, window, level)
588
+
589
+ # Update all views
590
+ self.server.controller.view_update()
591
+
592
+ def update_slice_positions(self, **kwargs):
593
+ """Update MPR slice positions when sliders change."""
594
+
595
+ if not getattr(self.server.state, "mpr_enabled", False):
596
+ return
597
+
598
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
599
+ if not active_volume_label:
600
+ return
601
+
602
+ # Find the active volume
603
+ active_volume = None
604
+ for volume in self.scene.volumes:
605
+ if volume.label == active_volume_label:
606
+ active_volume = volume
607
+ break
608
+
609
+ if not active_volume:
610
+ return
611
+
612
+ # Get current slice positions
613
+ axial_slice = getattr(self.server.state, "axial_slice", 0.5)
614
+ sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
615
+ coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
616
+ current_frame = getattr(self.server.state, "frame", 0)
617
+
618
+ # Get rotation data
619
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
620
+ rotation_angles = {}
621
+ for i in range(len(rotation_sequence)):
622
+ rotation_angles[i] = getattr(
623
+ self.server.state, f"mpr_rotation_angle_{i}", 0
624
+ )
625
+
626
+ # Update slice positions with rotation
627
+ active_volume.update_slice_positions(
628
+ current_frame,
629
+ axial_slice,
630
+ sagittal_slice,
631
+ coronal_slice,
632
+ rotation_sequence,
633
+ rotation_angles,
634
+ )
635
+
636
+ # Update all views
637
+ self.server.controller.view_update()
638
+
639
+ def update_mpr_window_level(self, **kwargs):
640
+ """Update MPR window/level when sliders change."""
641
+
642
+ if not getattr(self.server.state, "mpr_enabled", False):
643
+ return
644
+
645
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
646
+ if not active_volume_label:
647
+ return
648
+
649
+ # Find the active volume
650
+ active_volume = None
651
+ for volume in self.scene.volumes:
652
+ if volume.label == active_volume_label:
653
+ active_volume = volume
654
+ break
655
+
656
+ if not active_volume:
657
+ return
658
+
659
+ # Get current window/level values
660
+ window = getattr(self.server.state, "mpr_window", 400.0)
661
+ level = getattr(self.server.state, "mpr_level", 40.0)
662
+ current_frame = getattr(self.server.state, "frame", 0)
663
+
664
+ # Update window/level for MPR actors
665
+ active_volume.update_mpr_window_level(current_frame, window, level)
666
+
667
+ # Update all views
668
+ self.server.controller.view_update()
669
+
670
+ def update_mpr_preset(self, mpr_window_level_preset, **kwargs):
671
+ """Update MPR window/level when preset changes."""
672
+ from .window_level import presets
673
+
674
+ if mpr_window_level_preset in presets:
675
+ preset = presets[mpr_window_level_preset]
676
+ self.server.state.mpr_window = preset.window
677
+ self.server.state.mpr_level = preset.level
678
+
679
+ # Update the actual MPR views with new window/level
680
+ self.update_mpr_window_level()
681
+
682
+ def update_mpr_rotation(self, **kwargs):
683
+ """Update MPR views when rotation changes."""
684
+ if not getattr(self.server.state, "mpr_enabled", False):
685
+ return
686
+
687
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
688
+ if not active_volume_label:
689
+ return
690
+
691
+ # Find the active volume
692
+ active_volume = None
693
+ for volume in self.scene.volumes:
694
+ if volume.label == active_volume_label:
695
+ active_volume = volume
696
+ break
697
+
698
+ if not active_volume:
699
+ return
700
+
701
+ # Get current slice positions
702
+ axial_slice = getattr(self.server.state, "axial_slice", 0.5)
703
+ sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
704
+ coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
705
+ current_frame = getattr(self.server.state, "frame", 0)
706
+
707
+ # Get rotation data
708
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
709
+ rotation_angles = {}
710
+ for i in range(len(rotation_sequence)):
711
+ rotation_angles[i] = getattr(
712
+ self.server.state, f"mpr_rotation_angle_{i}", 0
713
+ )
714
+
715
+ # Update slice positions with rotation
716
+ active_volume.update_slice_positions(
717
+ current_frame,
718
+ axial_slice,
719
+ sagittal_slice,
720
+ coronal_slice,
721
+ rotation_sequence,
722
+ rotation_angles,
723
+ )
724
+
725
+ # Update all views
726
+ self.server.controller.view_update()
727
+
728
+ def add_mpr_rotation(self, axis):
729
+ """Add a new rotation to the MPR rotation sequence."""
730
+ import copy
731
+
732
+ current_sequence = copy.deepcopy(
733
+ getattr(self.server.state, "mpr_rotation_sequence", [])
734
+ )
735
+ current_sequence.append({"axis": axis, "angle": 0})
736
+ self.server.state.mpr_rotation_sequence = current_sequence
737
+ self.update_mpr_rotation_labels()
738
+
739
+ def remove_mpr_rotation(self, index):
740
+ """Remove a rotation at given index and all subsequent rotations."""
741
+ sequence = list(getattr(self.server.state, "mpr_rotation_sequence", []))
742
+ if 0 <= index < len(sequence):
743
+ sequence = sequence[:index]
744
+ self.server.state.mpr_rotation_sequence = sequence
745
+
746
+ # Reset angle states for all removed rotations
747
+ for i in range(index, 20):
748
+ setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
749
+
750
+ self.update_mpr_rotation_labels()
751
+
752
+ def reset_mpr_rotations(self):
753
+ """Reset all MPR rotations."""
754
+ self.server.state.mpr_rotation_sequence = []
755
+ for i in range(20):
756
+ setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
757
+ self.update_mpr_rotation_labels()
758
+
759
+ def update_mpr_rotation_labels(self):
760
+ """Update the rotation axis labels for display."""
761
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
762
+ for i, rotation in enumerate(rotation_sequence):
763
+ setattr(
764
+ self.server.state,
765
+ f"mpr_rotation_axis_{i}",
766
+ f"{rotation['axis']} ({i + 1})",
767
+ )
768
+ # Clear unused labels
769
+ for i in range(len(rotation_sequence), 20):
770
+ setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
771
+
772
+ @asynchronous.task
773
+ async def close_application(self):
774
+ """Close the application by stopping the server."""
775
+ await self.server.stop()
cardio/mesh.py CHANGED
@@ -167,9 +167,9 @@ class Mesh(Object):
167
167
 
168
168
  def color_transfer_function(self):
169
169
  ctf = vtk.vtkColorTransferFunction()
170
- ctf.AddRGBPoint(0.7, 0.0, 0.0, 1.0)
170
+ ctf.AddRGBPoint(self.ctf_min, 0.0, 0.0, 1.0)
171
171
  ctf.AddRGBPoint(1.0, 1.0, 0.0, 0.0)
172
- ctf.AddRGBPoint(1.3, 1.0, 1.0, 0.0)
172
+ ctf.AddRGBPoint(self.ctf_max, 1.0, 1.0, 0.0)
173
173
  return ctf
174
174
 
175
175
  def setup_scalar_coloring(self, mapper):
cardio/object.py CHANGED
@@ -28,7 +28,7 @@ class Object(pc.BaseModel):
28
28
  visible: bool = pc.Field(
29
29
  default=True, description="Whether object is initially visible"
30
30
  )
31
- clipping_enabled: bool = pc.Field(default=False)
31
+ clipping_enabled: bool = pc.Field(default=True)
32
32
 
33
33
  @pc.field_validator("label")
34
34
  @classmethod
@@ -69,12 +69,7 @@ class Object(pc.BaseModel):
69
69
  if self.pattern is not None and self.file_paths is not None:
70
70
  logging.info("Both pattern and file_paths specified; using file_paths.")
71
71
 
72
- # Validate all paths for traversal attacks and file existence
73
72
  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
73
  if not path.is_file():
79
74
  raise ValueError(f"File does not exist: {path}")
80
75
 
@@ -0,0 +1,29 @@
1
+ # Third Party
2
+ import pydantic as pc
3
+ import vtk
4
+
5
+ # Internal
6
+ from .types import ScalarComponent
7
+
8
+
9
+ class PiecewiseFunctionPoint(pc.BaseModel):
10
+ """A single point in a piecewise function."""
11
+
12
+ x: float = pc.Field(description="Scalar value")
13
+ y: ScalarComponent
14
+
15
+
16
+ class PiecewiseFunctionConfig(pc.BaseModel):
17
+ """Configuration for a VTK piecewise function (opacity)."""
18
+
19
+ points: list[PiecewiseFunctionPoint] = pc.Field(
20
+ min_length=1, description="Points defining the piecewise function"
21
+ )
22
+
23
+ @property
24
+ def vtk_function(self) -> vtk.vtkPiecewiseFunction:
25
+ """Create VTK piecewise function from this configuration."""
26
+ otf = vtk.vtkPiecewiseFunction()
27
+ for point in self.points:
28
+ otf.AddPoint(point.x, point.y)
29
+ return otf