cardio 2025.10.1__py3-none-any.whl → 2026.1.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
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2025.10.1"
29
+ __version__ = "2026.1.0"
cardio/app.py CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env python
2
2
 
3
- # Third Party
4
3
  import pydantic_settings as ps
5
4
  import trame as tm
6
5
  import trame.decorators
7
6
 
8
- # Internal
9
7
  from . import __version__
10
8
  from .logic import Logic
11
9
  from .scene import Scene
@@ -1,7 +1,4 @@
1
- # System
2
1
  import numpy as np
3
-
4
- # Third Party
5
2
  import vtk
6
3
 
7
4
 
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .types import RGBColor
7
5
 
8
6
 
cardio/logic.py CHANGED
@@ -8,6 +8,26 @@ from .screenshot import Screenshot
8
8
 
9
9
 
10
10
  class Logic:
11
+ def _get_visible_rotation_data(self):
12
+ """Get rotation sequence and angles for visible rotations only."""
13
+ rotation_data = getattr(
14
+ self.server.state, "mpr_rotation_data", {"angles_list": []}
15
+ )
16
+ angles_list = rotation_data.get("angles_list", [])
17
+
18
+ # Build rotation_sequence (list of {"axis": ...}) for visible rotations
19
+ rotation_sequence = []
20
+ rotation_angles = {}
21
+
22
+ visible_index = 0
23
+ for rotation in angles_list:
24
+ if rotation.get("visible", True):
25
+ rotation_sequence.append({"axis": rotation["axes"]})
26
+ rotation_angles[visible_index] = rotation["angles"][0]
27
+ visible_index += 1
28
+
29
+ return rotation_sequence, rotation_angles
30
+
11
31
  def __init__(self, server, scene: Scene):
12
32
  self.server = server
13
33
  self.scene = scene
@@ -27,34 +47,31 @@ class Logic:
27
47
  {"text": "Radians", "value": "radians"},
28
48
  ]
29
49
 
30
- # Initialize slice bounds (will be updated when active volume changes)
31
- self.server.state.axial_slice_bounds = [0.0, 100.0]
32
- self.server.state.sagittal_slice_bounds = [0.0, 100.0]
33
- self.server.state.coronal_slice_bounds = [0.0, 100.0]
50
+ # Initialize axis convention items for dropdown
51
+ self.server.state.axis_convention_items = [
52
+ {"text": "ITK (X=L, Y=P, Z=S)", "value": "itk"},
53
+ {"text": "Roma (X=S, Y=P, Z=L)", "value": "roma"},
54
+ ]
55
+
56
+ # Initialize MPR origin (will be updated when active volume changes)
57
+ self.server.state.mpr_origin = [0.0, 0.0, 0.0]
58
+ self.server.state.mpr_crosshairs_enabled = self.scene.mpr_crosshairs_enabled
34
59
 
35
60
  self.server.state.change("frame")(self.update_frame)
36
61
  self.server.state.change("playing")(self.play)
37
62
  self.server.state.change("theme_mode")(self.sync_background_color)
38
- self.server.state.change("mpr_enabled")(self.sync_mpr_mode)
39
63
  self.server.state.change("active_volume_label")(self.sync_active_volume)
40
- self.server.state.change("axial_slice", "sagittal_slice", "coronal_slice")(
41
- self.update_slice_positions
64
+ self.server.state.change("mpr_origin")(self.update_slice_positions)
65
+ self.server.state.change("mpr_crosshairs_enabled")(
66
+ self.sync_crosshairs_visibility
42
67
  )
43
68
  self.server.state.change("mpr_window", "mpr_level")(
44
69
  self.update_mpr_window_level
45
70
  )
46
71
  self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
47
- self.server.state.change("mpr_rotation_sequence")(self.update_mpr_rotation)
72
+ self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
48
73
  self.server.state.change("angle_units")(self.sync_angle_units)
49
-
50
- # Add handlers for individual rotation angles and visibility
51
- for i in range(self.scene.max_mpr_rotations):
52
- self.server.state.change(f"mpr_rotation_angle_{i}")(
53
- self.update_mpr_rotation
54
- )
55
- self.server.state.change(f"mpr_rotation_visible_{i}")(
56
- self.update_mpr_rotation
57
- )
74
+ self.server.state.change("axis_convention")(self.sync_axis_convention)
58
75
 
59
76
  # Initialize visibility state variables
60
77
  for m in self.scene.meshes:
@@ -140,25 +157,40 @@ class Logic:
140
157
  self.server.controller.save_rotation_angles = self.save_rotation_angles
141
158
  self.server.controller.reset_all = self.reset_all
142
159
  self.server.controller.close_application = self.close_application
160
+ self.server.controller.finalize_mpr_initialization = (
161
+ self.finalize_mpr_initialization
162
+ )
143
163
 
144
164
  # MPR rotation controllers
145
165
  self.server.controller.add_x_rotation = lambda: self.add_mpr_rotation("X")
146
166
  self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
147
167
  self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
148
168
  self.server.controller.remove_rotation_event = self.remove_mpr_rotation
169
+ self.server.controller.reset_rotation_angle = self.reset_rotation_angle
149
170
  self.server.controller.reset_rotations = self.reset_mpr_rotations
150
171
 
151
172
  # Initialize MPR state
152
173
  self.server.state.mpr_enabled = self.scene.mpr_enabled
153
- self.server.state.active_volume_label = self.scene.active_volume_label
154
- self.server.state.axial_slice = self.scene.axial_slice
155
- self.server.state.sagittal_slice = self.scene.sagittal_slice
156
- self.server.state.coronal_slice = self.scene.coronal_slice
174
+ # Initialize active_volume_label to empty string (actual value set after UI ready)
175
+ self.server.state.active_volume_label = ""
176
+ # Store the actual volume label to set later, avoiding race condition
177
+ self._pending_active_volume = (
178
+ self.scene.volumes[0].label
179
+ if self.scene.volumes and not self.scene.active_volume_label
180
+ else self.scene.active_volume_label
181
+ )
182
+ self.server.state.mpr_origin = list(self.scene.mpr_origin)
157
183
  self.server.state.mpr_window = self.scene.mpr_window
158
184
  self.server.state.mpr_level = self.scene.mpr_level
159
185
  self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
160
- self.server.state.mpr_rotation_sequence = self.scene.mpr_rotation_sequence
186
+
187
+ # Initialize rotation data from RotationSequence
188
+ self.server.state.mpr_rotation_data = (
189
+ self.scene.mpr_rotation_sequence.to_dict_for_ui()
190
+ )
191
+
161
192
  self.server.state.angle_units = self.scene.angle_units.value
193
+ self.server.state.axis_convention = self.scene.axis_convention.value
162
194
 
163
195
  # Initialize MPR presets data
164
196
  try:
@@ -171,12 +203,6 @@ class Logic:
171
203
  print(f"Error initializing MPR presets: {e}")
172
204
  self.server.state.mpr_presets = []
173
205
 
174
- # Initialize rotation angle states
175
- for i in range(self.scene.max_mpr_rotations):
176
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
177
- setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
178
- setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
179
-
180
206
  # Apply initial preset to ensure window/level values are set correctly
181
207
  # Only update state values, don't call update methods yet since MPR may not be enabled
182
208
  from .window_level import presets
@@ -236,6 +262,10 @@ class Logic:
236
262
  # Get or create MPR actors for the new frame
237
263
  mpr_actors = active_volume.get_mpr_actors_for_frame(frame)
238
264
 
265
+ # Get crosshair visibility state
266
+ crosshairs_visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
267
+ crosshairs = active_volume.crosshair_actors
268
+
239
269
  # Update each MPR renderer with the new frame's actors
240
270
  if self.scene.axial_renderWindow:
241
271
  axial_renderer = (
@@ -245,8 +275,11 @@ class Logic:
245
275
  axial_renderer.RemoveAllViewProps()
246
276
  axial_renderer.AddActor(mpr_actors["axial"]["actor"])
247
277
  mpr_actors["axial"]["actor"].SetVisibility(True)
248
- # Apply current slice position and window/level
249
- self._apply_current_mpr_settings(active_volume, frame)
278
+ # Re-add crosshairs
279
+ if crosshairs and "axial" in crosshairs:
280
+ for line_data in crosshairs["axial"].values():
281
+ axial_renderer.AddActor2D(line_data["actor"])
282
+ line_data["actor"].SetVisibility(crosshairs_visible)
250
283
  axial_renderer.ResetCamera()
251
284
 
252
285
  if self.scene.coronal_renderWindow:
@@ -257,6 +290,11 @@ class Logic:
257
290
  coronal_renderer.RemoveAllViewProps()
258
291
  coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
259
292
  mpr_actors["coronal"]["actor"].SetVisibility(True)
293
+ # Re-add crosshairs
294
+ if crosshairs and "coronal" in crosshairs:
295
+ for line_data in crosshairs["coronal"].values():
296
+ coronal_renderer.AddActor2D(line_data["actor"])
297
+ line_data["actor"].SetVisibility(crosshairs_visible)
260
298
  coronal_renderer.ResetCamera()
261
299
 
262
300
  if self.scene.sagittal_renderWindow:
@@ -267,30 +305,28 @@ class Logic:
267
305
  sagittal_renderer.RemoveAllViewProps()
268
306
  sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
269
307
  mpr_actors["sagittal"]["actor"].SetVisibility(True)
308
+ # Re-add crosshairs
309
+ if crosshairs and "sagittal" in crosshairs:
310
+ for line_data in crosshairs["sagittal"].values():
311
+ sagittal_renderer.AddActor2D(line_data["actor"])
312
+ line_data["actor"].SetVisibility(crosshairs_visible)
270
313
  sagittal_renderer.ResetCamera()
271
314
 
315
+ # Apply current slice position and window/level to all views
316
+ self._apply_current_mpr_settings(active_volume, frame)
317
+
272
318
  def _apply_current_mpr_settings(self, active_volume, frame):
273
319
  """Apply current slice positions and window/level to MPR actors."""
274
320
  # Apply slice positions
275
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
276
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
277
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
278
-
279
- # Get rotation data
280
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
281
- rotation_angles = {}
282
- for i in range(len(rotation_sequence)):
283
- rotation_angles[i] = getattr(
284
- self.server.state, f"mpr_rotation_angle_{i}", 0
285
- )
321
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
322
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
286
323
 
287
324
  active_volume.update_slice_positions(
288
325
  frame,
289
- axial_slice,
290
- sagittal_slice,
291
- coronal_slice,
326
+ origin,
292
327
  rotation_sequence,
293
328
  rotation_angles,
329
+ self.scene.angle_units,
294
330
  )
295
331
 
296
332
  # Apply window/level
@@ -462,62 +498,35 @@ class Logic:
462
498
 
463
499
  @asynchronous.task
464
500
  async def save_rotation_angles(self):
465
- """Save current rotation angles to a TOML file."""
466
- import tomlkit as tk
501
+ """Save current rotation angles to TOML file."""
502
+ from .rotation import RotationSequence
467
503
 
468
- # Get current timestamp
469
504
  timestamp = dt.datetime.now()
470
505
  timestamp_str = timestamp.strftime(self.scene.timestamp_format)
471
- iso_timestamp = timestamp.isoformat()
472
-
473
- # Get active volume label
474
506
  active_volume_label = getattr(self.server.state, "active_volume_label", "")
507
+
475
508
  if not active_volume_label:
476
- print("Warning: No active volume selected for rotation saving")
509
+ print("Warning: No active volume selected")
477
510
  return
478
511
 
479
- # Create directory structure
480
512
  save_dir = self.scene.rotations_directory / active_volume_label
481
513
  save_dir.mkdir(parents=True, exist_ok=True)
482
514
 
483
- # Create TOML structure
484
- doc = tk.document()
485
-
486
- # Metadata section
487
- metadata = tk.table()
488
- metadata["coordinate_system"] = self.scene.coordinate_system
489
- metadata["units"] = self.scene.angle_units.value
490
- metadata["timestamp"] = iso_timestamp
491
- metadata["volume_label"] = active_volume_label
492
- doc["metadata"] = metadata
493
-
494
- # Slice positions section
495
- slice_positions = tk.table()
496
- slice_positions["axial"] = getattr(self.server.state, "axial_slice", 0.5)
497
- slice_positions["sagittal"] = getattr(self.server.state, "sagittal_slice", 0.5)
498
- slice_positions["coronal"] = getattr(self.server.state, "coronal_slice", 0.5)
499
- doc["slice_positions"] = slice_positions
500
-
501
- # Rotations section (array of tables)
502
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
503
- rotations_array = tk.aot()
504
-
505
- for i, rotation_def in enumerate(rotation_sequence):
506
- rotation = tk.table()
507
- rotation["axis"] = rotation_def["axis"]
508
- angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
509
- rotation["angle"] = float(angle)
510
- rotation["visible"] = getattr(
511
- self.server.state, f"mpr_rotation_visible_{i}", True
512
- )
513
- rotations_array.append(rotation)
515
+ rotation_data = getattr(
516
+ self.server.state, "mpr_rotation_data", {"angles_list": []}
517
+ )
518
+ rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
514
519
 
515
- doc["rotations"] = rotations_array
520
+ rotation_seq.metadata.timestamp = timestamp.isoformat()
521
+ rotation_seq.metadata.volume_label = active_volume_label
522
+ rotation_seq.metadata.coordinate_system = self.scene.coordinate_system
516
523
 
517
- # Save to file
518
524
  output_path = save_dir / f"{timestamp_str}.toml"
519
- with open(output_path, "w") as f:
520
- f.write(tk.dumps(doc))
525
+ rotation_seq.to_file(
526
+ output_path,
527
+ target_convention=self.scene.axis_convention,
528
+ target_units=self.scene.angle_units,
529
+ )
521
530
 
522
531
  def reset_all(self):
523
532
  self.server.state.frame = 0
@@ -544,13 +553,55 @@ class Logic:
544
553
 
545
554
  def sync_angle_units(self, angle_units, **kwargs):
546
555
  """Sync angle units selection - updates the scene configuration."""
547
- from .utils import AngleUnit
556
+ import copy
557
+
558
+ import numpy as np
559
+
560
+ from .orientation import AngleUnits
561
+
562
+ # Get current units before changing
563
+ old_units = self.scene.angle_units
548
564
 
549
565
  # Update the scene's angle_units field based on UI selection
566
+ new_units = None
550
567
  if angle_units == "degrees":
551
- self.scene.angle_units = AngleUnit.DEGREES
568
+ new_units = AngleUnits.DEGREES
552
569
  elif angle_units == "radians":
553
- self.scene.angle_units = AngleUnit.RADIANS
570
+ new_units = AngleUnits.RADIANS
571
+
572
+ if new_units is None or old_units == new_units:
573
+ return
574
+
575
+ # Convert all existing rotation angles
576
+ rotation_data = getattr(
577
+ self.server.state, "mpr_rotation_data", {"angles_list": []}
578
+ )
579
+ if rotation_data.get("angles_list"):
580
+ updated_data = copy.deepcopy(rotation_data)
581
+
582
+ for rotation in updated_data["angles_list"]:
583
+ current_angle = rotation.get("angles", [0])[0]
584
+
585
+ # Convert based on old -> new units
586
+ if old_units == AngleUnits.DEGREES and new_units == AngleUnits.RADIANS:
587
+ rotation["angles"][0] = np.radians(current_angle)
588
+ elif (
589
+ old_units == AngleUnits.RADIANS and new_units == AngleUnits.DEGREES
590
+ ):
591
+ rotation["angles"][0] = np.degrees(current_angle)
592
+
593
+ self.server.state.mpr_rotation_data = updated_data
594
+
595
+ self.scene.angle_units = new_units
596
+
597
+ def sync_axis_convention(self, axis_convention, **kwargs):
598
+ """Sync axis convention selection - updates the scene configuration."""
599
+ from .orientation import AxisConvention
600
+
601
+ if axis_convention == "itk":
602
+ self.scene.axis_convention = AxisConvention.ITK
603
+ elif axis_convention == "roma":
604
+ self.scene.axis_convention = AxisConvention.ROMA
554
605
 
555
606
  def _initialize_clipping_state(self):
556
607
  """Initialize clipping state variables for all objects."""
@@ -633,32 +684,32 @@ class Logic:
633
684
  if not active_volume:
634
685
  return
635
686
 
636
- # Update slice bounds based on active volume
687
+ # Initialize origin to volume center (in LPS coordinates)
637
688
  try:
638
- bounds = active_volume.get_physical_bounds()
639
- self.server.state.axial_slice_bounds = [bounds[4], bounds[5]] # Z bounds
640
- self.server.state.sagittal_slice_bounds = [bounds[0], bounds[1]] # X bounds
641
- self.server.state.coronal_slice_bounds = [bounds[2], bounds[3]] # Y bounds
642
-
643
- # Initialize slice positions to volume center if they are currently 0.0 (scene defaults)
644
- if self.server.state.axial_slice == 0.0:
645
- self.server.state.axial_slice = (bounds[4] + bounds[5]) / 2 # Z center
646
- if self.server.state.sagittal_slice == 0.0:
647
- self.server.state.sagittal_slice = (
648
- bounds[0] + bounds[1]
649
- ) / 2 # X center
650
- if self.server.state.coronal_slice == 0.0:
651
- self.server.state.coronal_slice = (
652
- bounds[2] + bounds[3]
653
- ) / 2 # Y center
689
+ current_frame = getattr(self.server.state, "frame", 0)
690
+ volume_actor = active_volume.actors[current_frame]
691
+ image_data = volume_actor.GetMapper().GetInput()
692
+ center = image_data.GetCenter()
693
+
694
+ # Set origin to volume center if it's at default [0,0,0]
695
+ current_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
696
+ if current_origin == [0.0, 0.0, 0.0]:
697
+ self.server.state.mpr_origin = list(center)
654
698
  except (RuntimeError, IndexError) as e:
655
- print(f"Error: Cannot get bounds for volume '{active_volume_label}': {e}")
699
+ print(f"Error: Cannot get center for volume '{active_volume_label}': {e}")
656
700
  return
657
701
 
658
702
  # Create MPR actors for current frame
659
703
  current_frame = getattr(self.server.state, "frame", 0)
660
704
  mpr_actors = active_volume.get_mpr_actors_for_frame(current_frame)
661
705
 
706
+ # Create crosshair actors
707
+ crosshairs = active_volume.create_crosshair_actors(
708
+ colors=self.scene.mpr_crosshair_colors,
709
+ line_width=self.scene.mpr_crosshair_width,
710
+ )
711
+ crosshairs_visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
712
+
662
713
  # Add MPR actors to their respective renderers
663
714
  if self.scene.axial_renderWindow:
664
715
  axial_renderer = (
@@ -668,6 +719,10 @@ class Logic:
668
719
  axial_renderer.RemoveAllViewProps() # Clear existing actors
669
720
  axial_renderer.AddActor(mpr_actors["axial"]["actor"])
670
721
  mpr_actors["axial"]["actor"].SetVisibility(True)
722
+ # Add 2D crosshair overlay actors
723
+ for line_data in crosshairs["axial"].values():
724
+ axial_renderer.AddActor2D(line_data["actor"])
725
+ line_data["actor"].SetVisibility(crosshairs_visible)
671
726
  axial_renderer.ResetCamera()
672
727
 
673
728
  if self.scene.coronal_renderWindow:
@@ -678,6 +733,10 @@ class Logic:
678
733
  coronal_renderer.RemoveAllViewProps()
679
734
  coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
680
735
  mpr_actors["coronal"]["actor"].SetVisibility(True)
736
+ # Add 2D crosshair overlay actors
737
+ for line_data in crosshairs["coronal"].values():
738
+ coronal_renderer.AddActor2D(line_data["actor"])
739
+ line_data["actor"].SetVisibility(crosshairs_visible)
681
740
  coronal_renderer.ResetCamera()
682
741
 
683
742
  if self.scene.sagittal_renderWindow:
@@ -688,6 +747,10 @@ class Logic:
688
747
  sagittal_renderer.RemoveAllViewProps()
689
748
  sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
690
749
  mpr_actors["sagittal"]["actor"].SetVisibility(True)
750
+ # Add 2D crosshair overlay actors
751
+ for line_data in crosshairs["sagittal"].values():
752
+ sagittal_renderer.AddActor2D(line_data["actor"])
753
+ line_data["actor"].SetVisibility(crosshairs_visible)
691
754
  sagittal_renderer.ResetCamera()
692
755
 
693
756
  # Apply current window/level settings to the MPR actors
@@ -718,28 +781,19 @@ class Logic:
718
781
  if not active_volume:
719
782
  return
720
783
 
721
- # Get current slice positions
722
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
723
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
724
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
784
+ # Get current origin and frame
785
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
725
786
  current_frame = getattr(self.server.state, "frame", 0)
726
787
 
727
- # Get rotation data
728
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
729
- rotation_angles = {}
730
- for i in range(len(rotation_sequence)):
731
- rotation_angles[i] = getattr(
732
- self.server.state, f"mpr_rotation_angle_{i}", 0
733
- )
788
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
734
789
 
735
790
  # Update slice positions with rotation
736
791
  active_volume.update_slice_positions(
737
792
  current_frame,
738
- axial_slice,
739
- sagittal_slice,
740
- coronal_slice,
793
+ origin,
741
794
  rotation_sequence,
742
795
  rotation_angles,
796
+ self.scene.angle_units,
743
797
  )
744
798
 
745
799
  # Update all views
@@ -826,81 +880,109 @@ class Logic:
826
880
  if not active_volume:
827
881
  return
828
882
 
829
- # Get current slice positions
830
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
831
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
832
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
883
+ # Get current origin and frame
884
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
833
885
  current_frame = getattr(self.server.state, "frame", 0)
834
886
 
835
- # Get rotation data - include all visible rotations
836
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
837
- rotation_angles = {}
838
-
839
- # Include all visible rotations regardless of position
840
- for i in range(len(rotation_sequence)):
841
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
842
- if is_visible:
843
- rotation_angles[i] = getattr(
844
- self.server.state, f"mpr_rotation_angle_{i}", 0
845
- )
887
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
846
888
 
847
889
  # Update slice positions with rotation
848
890
  active_volume.update_slice_positions(
849
891
  current_frame,
850
- axial_slice,
851
- sagittal_slice,
852
- coronal_slice,
892
+ origin,
853
893
  rotation_sequence,
854
894
  rotation_angles,
895
+ self.scene.angle_units,
855
896
  )
856
897
 
857
898
  # Update all views
858
899
  self.server.controller.view_update()
859
900
 
901
+ def sync_crosshairs_visibility(self, **kwargs):
902
+ """Toggle crosshair visibility on all MPR views."""
903
+ if not getattr(self.server.state, "mpr_enabled", False):
904
+ return
905
+
906
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
907
+ if not active_volume_label:
908
+ return
909
+
910
+ # Find the active volume
911
+ active_volume = None
912
+ for volume in self.scene.volumes:
913
+ if volume.label == active_volume_label:
914
+ active_volume = volume
915
+ break
916
+
917
+ if not active_volume:
918
+ return
919
+
920
+ visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
921
+ active_volume.set_crosshairs_visible(visible)
922
+ self.server.controller.view_update()
923
+
860
924
  def add_mpr_rotation(self, axis):
861
925
  """Add a new rotation to the MPR rotation sequence."""
862
926
  import copy
863
927
 
864
- current_sequence = copy.deepcopy(
865
- getattr(self.server.state, "mpr_rotation_sequence", [])
928
+ current_data = copy.deepcopy(
929
+ getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
930
+ )
931
+ angles_list = current_data["angles_list"]
932
+ new_index = len(angles_list)
933
+
934
+ angles_list.append(
935
+ {
936
+ "axes": axis,
937
+ "angles": [0],
938
+ "visible": True,
939
+ "name": "",
940
+ "name_editable": True,
941
+ "deletable": True,
942
+ }
866
943
  )
867
- current_sequence.append({"axis": axis, "angle": 0})
868
- self.server.state.mpr_rotation_sequence = current_sequence
869
- self.update_mpr_rotation_labels()
944
+
945
+ self.server.state.mpr_rotation_data = current_data
870
946
 
871
947
  def remove_mpr_rotation(self, index):
872
- """Remove a rotation at given index and all subsequent rotations."""
873
- sequence = list(getattr(self.server.state, "mpr_rotation_sequence", []))
874
- if 0 <= index < len(sequence):
875
- sequence = sequence[:index]
876
- self.server.state.mpr_rotation_sequence = sequence
948
+ """Remove a rotation at given index."""
949
+ import copy
877
950
 
878
- # Reset angle states for all removed rotations
879
- for i in range(index, self.scene.max_mpr_rotations):
880
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
951
+ current_data = copy.deepcopy(
952
+ getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
953
+ )
954
+ angles_list = current_data["angles_list"]
881
955
 
882
- self.update_mpr_rotation_labels()
956
+ if 0 <= index < len(angles_list):
957
+ angles_list.pop(index)
958
+ current_data["angles_list"] = angles_list
959
+ self.server.state.mpr_rotation_data = current_data
960
+
961
+ def reset_rotation_angle(self, index):
962
+ """Reset the angle of a rotation at given index to zero."""
963
+ import copy
964
+
965
+ current_data = copy.deepcopy(
966
+ getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
967
+ )
968
+ angles_list = current_data["angles_list"]
969
+
970
+ if 0 <= index < len(angles_list):
971
+ angles_list[index]["angles"][0] = 0
972
+ current_data["angles_list"] = angles_list
973
+ self.server.state.mpr_rotation_data = current_data
883
974
 
884
975
  def reset_mpr_rotations(self):
885
976
  """Reset all MPR rotations."""
886
- self.server.state.mpr_rotation_sequence = []
887
- for i in range(self.scene.max_mpr_rotations):
888
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
889
- setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
890
- self.update_mpr_rotation_labels()
891
-
892
- def update_mpr_rotation_labels(self):
893
- """Update the rotation axis labels for display."""
894
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
895
- for i, rotation in enumerate(rotation_sequence):
896
- setattr(
897
- self.server.state,
898
- f"mpr_rotation_axis_{i}",
899
- f"{rotation['axis']} ({i + 1})",
900
- )
901
- # Clear unused labels
902
- for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
903
- setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
977
+ self.server.state.mpr_rotation_data = {"angles_list": []}
978
+
979
+ def finalize_mpr_initialization(self, **kwargs):
980
+ """Set the active volume label after UI is ready to avoid race condition."""
981
+ if hasattr(self, "_pending_active_volume") and self._pending_active_volume:
982
+ self.server.state.active_volume_label = self._pending_active_volume
983
+ # Manually trigger sync_active_volume since state change may not fire during on_server_ready
984
+ self.sync_active_volume(self._pending_active_volume)
985
+ delattr(self, "_pending_active_volume")
904
986
 
905
987
  @asynchronous.task
906
988
  async def close_application(self):