cardio 2025.12.0__tar.gz → 2026.1.0__tar.gz

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.
Files changed (30) hide show
  1. {cardio-2025.12.0 → cardio-2026.1.0}/PKG-INFO +2 -2
  2. {cardio-2025.12.0 → cardio-2026.1.0}/README.md +1 -1
  3. {cardio-2025.12.0 → cardio-2026.1.0}/pyproject.toml +2 -2
  4. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/__init__.py +1 -1
  5. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/logic.py +137 -103
  6. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/orientation.py +5 -0
  7. cardio-2026.1.0/src/cardio/rotation.py +211 -0
  8. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/scene.py +34 -8
  9. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/ui.py +137 -106
  10. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/utils.py +0 -7
  11. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume.py +21 -8
  12. {cardio-2025.12.0 → cardio-2026.1.0}/LICENSE +0 -0
  13. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/app.py +0 -0
  14. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/bone.toml +0 -0
  15. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/vascular_closed.toml +0 -0
  16. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/vascular_open.toml +0 -0
  17. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/xray.toml +0 -0
  18. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/blend_transfer_functions.py +0 -0
  19. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/color_transfer_function.py +0 -0
  20. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/mesh.py +0 -0
  21. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/object.py +0 -0
  22. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/piecewise_function.py +0 -0
  23. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/property_config.py +0 -0
  24. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/screenshot.py +0 -0
  25. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/segmentation.py +0 -0
  26. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/transfer_function_pair.py +0 -0
  27. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/types.py +0 -0
  28. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume_property.py +0 -0
  29. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume_property_presets.py +0 -0
  30. {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/window_level.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cardio
3
- Version: 2025.12.0
3
+ Version: 2026.1.0
4
4
  Summary: A simple web-based viewer for 3D and 4D ('cine') medical imaging data.
5
5
  Keywords: Medical,Imaging,3D,4D,Visualization
6
6
  Author: Davis Marc Vigneault
@@ -75,7 +75,7 @@ $ uv init
75
75
  $ uv add cardio
76
76
  $ . ./.venv/bin/activate
77
77
  (project) cardio --version
78
- cardio 2025.12.0
78
+ cardio 2026.1.0
79
79
  ```
80
80
 
81
81
  ### Developing
@@ -19,7 +19,7 @@ $ uv init
19
19
  $ uv add cardio
20
20
  $ . ./.venv/bin/activate
21
21
  (project) cardio --version
22
- cardio 2025.12.0
22
+ cardio 2026.1.0
23
23
  ```
24
24
 
25
25
  ### Developing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "cardio"
7
- version = "2025.12.0"
7
+ version = "2026.1.0"
8
8
  authors = [
9
9
  {name = "Davis Marc Vigneault", email = "davis.vigneault@gmail.com"}
10
10
  ]
@@ -62,7 +62,7 @@ repository = "https://github.com/sudomakeinstall/cardio"
62
62
  profile = "black"
63
63
 
64
64
  [tool.bumpver]
65
- current_version = "2025.12.0"
65
+ current_version = "2026.1.0"
66
66
  version_pattern = "YYYY.MM.INC0"
67
67
  commit_message = "ENH: Bump version from {old_version} => {new_version}"
68
68
  commit = true
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2025.12.0"
29
+ __version__ = "2026.1.0"
@@ -10,14 +10,22 @@ from .screenshot import Screenshot
10
10
  class Logic:
11
11
  def _get_visible_rotation_data(self):
12
12
  """Get rotation sequence and angles for visible rotations only."""
13
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
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 = []
14
20
  rotation_angles = {}
15
- for i in range(len(rotation_sequence)):
16
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
17
- if is_visible:
18
- rotation_angles[i] = getattr(
19
- self.server.state, f"mpr_rotation_angle_{i}", 0
20
- )
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
+
21
29
  return rotation_sequence, rotation_angles
22
30
 
23
31
  def __init__(self, server, scene: Scene):
@@ -39,6 +47,12 @@ class Logic:
39
47
  {"text": "Radians", "value": "radians"},
40
48
  ]
41
49
 
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
+
42
56
  # Initialize MPR origin (will be updated when active volume changes)
43
57
  self.server.state.mpr_origin = [0.0, 0.0, 0.0]
44
58
  self.server.state.mpr_crosshairs_enabled = self.scene.mpr_crosshairs_enabled
@@ -55,17 +69,9 @@ class Logic:
55
69
  self.update_mpr_window_level
56
70
  )
57
71
  self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
58
- self.server.state.change("mpr_rotation_sequence")(self.update_mpr_rotation)
72
+ self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
59
73
  self.server.state.change("angle_units")(self.sync_angle_units)
60
-
61
- # Add handlers for individual rotation angles and visibility
62
- for i in range(self.scene.max_mpr_rotations):
63
- self.server.state.change(f"mpr_rotation_angle_{i}")(
64
- self.update_mpr_rotation
65
- )
66
- self.server.state.change(f"mpr_rotation_visible_{i}")(
67
- self.update_mpr_rotation
68
- )
74
+ self.server.state.change("axis_convention")(self.sync_axis_convention)
69
75
 
70
76
  # Initialize visibility state variables
71
77
  for m in self.scene.meshes:
@@ -160,6 +166,7 @@ class Logic:
160
166
  self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
161
167
  self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
162
168
  self.server.controller.remove_rotation_event = self.remove_mpr_rotation
169
+ self.server.controller.reset_rotation_angle = self.reset_rotation_angle
163
170
  self.server.controller.reset_rotations = self.reset_mpr_rotations
164
171
 
165
172
  # Initialize MPR state
@@ -176,8 +183,14 @@ class Logic:
176
183
  self.server.state.mpr_window = self.scene.mpr_window
177
184
  self.server.state.mpr_level = self.scene.mpr_level
178
185
  self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
179
- 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
+
180
192
  self.server.state.angle_units = self.scene.angle_units.value
193
+ self.server.state.axis_convention = self.scene.axis_convention.value
181
194
 
182
195
  # Initialize MPR presets data
183
196
  try:
@@ -190,12 +203,6 @@ class Logic:
190
203
  print(f"Error initializing MPR presets: {e}")
191
204
  self.server.state.mpr_presets = []
192
205
 
193
- # Initialize rotation angle states
194
- for i in range(self.scene.max_mpr_rotations):
195
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
196
- setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
197
- setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
198
-
199
206
  # Apply initial preset to ensure window/level values are set correctly
200
207
  # Only update state values, don't call update methods yet since MPR may not be enabled
201
208
  from .window_level import presets
@@ -319,6 +326,7 @@ class Logic:
319
326
  origin,
320
327
  rotation_sequence,
321
328
  rotation_angles,
329
+ self.scene.angle_units,
322
330
  )
323
331
 
324
332
  # Apply window/level
@@ -490,63 +498,35 @@ class Logic:
490
498
 
491
499
  @asynchronous.task
492
500
  async def save_rotation_angles(self):
493
- """Save current rotation angles to a TOML file."""
494
- import tomlkit as tk
501
+ """Save current rotation angles to TOML file."""
502
+ from .rotation import RotationSequence
495
503
 
496
- # Get current timestamp
497
504
  timestamp = dt.datetime.now()
498
505
  timestamp_str = timestamp.strftime(self.scene.timestamp_format)
499
- iso_timestamp = timestamp.isoformat()
500
-
501
- # Get active volume label
502
506
  active_volume_label = getattr(self.server.state, "active_volume_label", "")
507
+
503
508
  if not active_volume_label:
504
- print("Warning: No active volume selected for rotation saving")
509
+ print("Warning: No active volume selected")
505
510
  return
506
511
 
507
- # Create directory structure
508
512
  save_dir = self.scene.rotations_directory / active_volume_label
509
513
  save_dir.mkdir(parents=True, exist_ok=True)
510
514
 
511
- # Create TOML structure
512
- doc = tk.document()
513
-
514
- # Metadata section
515
- metadata = tk.table()
516
- metadata["coordinate_system"] = self.scene.coordinate_system
517
- metadata["units"] = self.scene.angle_units.value
518
- metadata["timestamp"] = iso_timestamp
519
- metadata["volume_label"] = active_volume_label
520
- doc["metadata"] = metadata
521
-
522
- # Origin position section
523
- origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
524
- origin_table = tk.table()
525
- origin_table["x"] = origin[0]
526
- origin_table["y"] = origin[1]
527
- origin_table["z"] = origin[2]
528
- doc["origin"] = origin_table
529
-
530
- # Rotations section (array of tables)
531
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
532
- rotations_array = tk.aot()
533
-
534
- for i, rotation_def in enumerate(rotation_sequence):
535
- rotation = tk.table()
536
- rotation["axis"] = rotation_def["axis"]
537
- angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
538
- rotation["angle"] = float(angle)
539
- rotation["visible"] = getattr(
540
- self.server.state, f"mpr_rotation_visible_{i}", True
541
- )
542
- 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)
543
519
 
544
- 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
545
523
 
546
- # Save to file
547
524
  output_path = save_dir / f"{timestamp_str}.toml"
548
- with open(output_path, "w") as f:
549
- 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
+ )
550
530
 
551
531
  def reset_all(self):
552
532
  self.server.state.frame = 0
@@ -573,13 +553,55 @@ class Logic:
573
553
 
574
554
  def sync_angle_units(self, angle_units, **kwargs):
575
555
  """Sync angle units selection - updates the scene configuration."""
576
- 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
577
564
 
578
565
  # Update the scene's angle_units field based on UI selection
566
+ new_units = None
579
567
  if angle_units == "degrees":
580
- self.scene.angle_units = AngleUnit.DEGREES
568
+ new_units = AngleUnits.DEGREES
581
569
  elif angle_units == "radians":
582
- 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
583
605
 
584
606
  def _initialize_clipping_state(self):
585
607
  """Initialize clipping state variables for all objects."""
@@ -771,6 +793,7 @@ class Logic:
771
793
  origin,
772
794
  rotation_sequence,
773
795
  rotation_angles,
796
+ self.scene.angle_units,
774
797
  )
775
798
 
776
799
  # Update all views
@@ -869,6 +892,7 @@ class Logic:
869
892
  origin,
870
893
  rotation_sequence,
871
894
  rotation_angles,
895
+ self.scene.angle_units,
872
896
  )
873
897
 
874
898
  # Update all views
@@ -901,46 +925,56 @@ class Logic:
901
925
  """Add a new rotation to the MPR rotation sequence."""
902
926
  import copy
903
927
 
904
- current_sequence = copy.deepcopy(
905
- 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
+ }
906
943
  )
907
- current_sequence.append({"axis": axis, "angle": 0})
908
- self.server.state.mpr_rotation_sequence = current_sequence
909
- self.update_mpr_rotation_labels()
944
+
945
+ self.server.state.mpr_rotation_data = current_data
910
946
 
911
947
  def remove_mpr_rotation(self, index):
912
- """Remove a rotation at given index and all subsequent rotations."""
913
- sequence = list(getattr(self.server.state, "mpr_rotation_sequence", []))
914
- if 0 <= index < len(sequence):
915
- sequence = sequence[:index]
916
- self.server.state.mpr_rotation_sequence = sequence
948
+ """Remove a rotation at given index."""
949
+ import copy
917
950
 
918
- # Reset angle states for all removed rotations
919
- for i in range(index, self.scene.max_mpr_rotations):
920
- 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"]
921
955
 
922
- 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
923
974
 
924
975
  def reset_mpr_rotations(self):
925
976
  """Reset all MPR rotations."""
926
- self.server.state.mpr_rotation_sequence = []
927
- for i in range(self.scene.max_mpr_rotations):
928
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
929
- setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
930
- self.update_mpr_rotation_labels()
931
-
932
- def update_mpr_rotation_labels(self):
933
- """Update the rotation axis labels for display."""
934
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
935
- for i, rotation in enumerate(rotation_sequence):
936
- setattr(
937
- self.server.state,
938
- f"mpr_rotation_axis_{i}",
939
- f"{rotation['axis']} ({i + 1})",
940
- )
941
- # Clear unused labels
942
- for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
943
- setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
977
+ self.server.state.mpr_rotation_data = {"angles_list": []}
944
978
 
945
979
  def finalize_mpr_initialization(self, **kwargs):
946
980
  """Set the active volume label after UI is ready to avoid race condition."""
@@ -11,6 +11,11 @@ class EulerAxis(Enum):
11
11
  Z = "Z"
12
12
 
13
13
 
14
+ class AxisConvention(Enum):
15
+ ITK = "itk" # X=Left, Y=Posterior, Z=Superior
16
+ ROMA = "roma" # X=Superior, Y=Posterior, Z=Left
17
+
18
+
14
19
  class AngleUnits(Enum):
15
20
  DEGREES = "degrees"
16
21
  RADIANS = "radians"
@@ -0,0 +1,211 @@
1
+ # System
2
+ import datetime as dt
3
+ import pathlib as pl
4
+ import typing as ty
5
+
6
+ # Third Party
7
+ import numpy as np
8
+ import pydantic as pc
9
+ import tomlkit as tk
10
+
11
+ # Internal
12
+ from .orientation import AngleUnits, AxisConvention
13
+
14
+
15
+ class RotationStep(pc.BaseModel):
16
+ """Single rotation step (stored in ITK convention, radians)."""
17
+
18
+ axes: ty.Literal["X", "Y", "Z"]
19
+ angle: float = 0.0
20
+ visible: bool = True
21
+ name: str = ""
22
+ name_editable: bool = True
23
+ deletable: bool = True
24
+
25
+ @pc.model_validator(mode="before")
26
+ @classmethod
27
+ def handle_legacy_format(cls, data):
28
+ """Handle legacy 'angles' list format and 'axis' field."""
29
+ if isinstance(data, dict):
30
+ if "angles" in data and "angle" not in data:
31
+ angles = data["angles"]
32
+ if isinstance(angles, list) and len(angles) > 0:
33
+ data["angle"] = angles[0]
34
+ else:
35
+ data["angle"] = angles
36
+ if "axis" in data and "axes" not in data:
37
+ data["axes"] = data["axis"]
38
+ return data
39
+
40
+ def to_convention(
41
+ self, convention: AxisConvention, units: AngleUnits
42
+ ) -> tuple[str, float]:
43
+ """Convert to target convention/units for serialization."""
44
+ axis = self.axes
45
+ angle = self.angle
46
+
47
+ if convention == AxisConvention.ROMA:
48
+ axis = {"X": "Z", "Y": "Y", "Z": "X"}[axis]
49
+ angle = -angle
50
+
51
+ if units == AngleUnits.DEGREES:
52
+ angle = np.degrees(angle)
53
+
54
+ return axis, angle
55
+
56
+ @classmethod
57
+ def from_convention(
58
+ cls,
59
+ axes: str,
60
+ angle: float,
61
+ convention: AxisConvention,
62
+ units: AngleUnits,
63
+ **kwargs,
64
+ ) -> "RotationStep":
65
+ """Create from target convention/units (for deserialization)."""
66
+ if units == AngleUnits.DEGREES:
67
+ angle = np.radians(angle)
68
+
69
+ if convention == AxisConvention.ROMA:
70
+ axes = {"X": "Z", "Y": "Y", "Z": "X"}[axes]
71
+ angle = -angle
72
+
73
+ return cls(axes=axes, angle=angle, **kwargs)
74
+
75
+
76
+ class RotationMetadata(pc.BaseModel):
77
+ """Metadata for TOML files."""
78
+
79
+ coordinate_system: ty.Literal["LPS"] = "LPS"
80
+ axis_convention: AxisConvention = AxisConvention.ITK
81
+ units: AngleUnits = AngleUnits.RADIANS
82
+ timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
83
+ volume_label: str = ""
84
+
85
+
86
+ class RotationSequence(pc.BaseModel):
87
+ """Complete rotation sequence (stored in ITK convention, radians)."""
88
+
89
+ model_config = pc.ConfigDict(frozen=False)
90
+
91
+ metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
92
+ angles_list: list[RotationStep] = pc.Field(default_factory=list)
93
+
94
+ @pc.field_validator("angles_list", mode="before")
95
+ @classmethod
96
+ def convert_legacy_list(cls, v):
97
+ """Convert legacy list of dicts to list of RotationStep."""
98
+ if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
99
+ return [RotationStep(**item) for item in v]
100
+ return v
101
+
102
+ def to_dict_for_ui(self) -> dict:
103
+ """Convert to UI format (always ITK/radians internally).
104
+
105
+ Note: UI expects 'angles' as a list for backward compatibility.
106
+ """
107
+ return {
108
+ "angles_list": [
109
+ {
110
+ "axes": step.axes,
111
+ "angles": [step.angle],
112
+ "visible": step.visible,
113
+ "name": step.name,
114
+ "name_editable": step.name_editable,
115
+ "deletable": step.deletable,
116
+ }
117
+ for step in self.angles_list
118
+ ]
119
+ }
120
+
121
+ @classmethod
122
+ def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
123
+ """Create from UI format (assumes ITK/radians)."""
124
+ angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
125
+ metadata = RotationMetadata(volume_label=volume_label)
126
+ return cls(metadata=metadata, angles_list=angles_list)
127
+
128
+ def to_toml(
129
+ self, target_convention: AxisConvention, target_units: AngleUnits
130
+ ) -> str:
131
+ """Serialize to TOML with conversions."""
132
+ doc = tk.document()
133
+
134
+ metadata_table = tk.table()
135
+ metadata_table["coordinate_system"] = self.metadata.coordinate_system
136
+ metadata_table["axis_convention"] = target_convention.value
137
+ metadata_table["units"] = target_units.value
138
+ metadata_table["timestamp"] = self.metadata.timestamp
139
+ metadata_table["volume_label"] = self.metadata.volume_label
140
+ doc["metadata"] = metadata_table
141
+
142
+ rotations_array = tk.aot()
143
+ for step in self.angles_list:
144
+ rotation = tk.table()
145
+ converted_axis, converted_angle = step.to_convention(
146
+ target_convention, target_units
147
+ )
148
+ rotation["axes"] = converted_axis
149
+ rotation["angle"] = converted_angle
150
+ rotation["visible"] = step.visible
151
+ rotation["name"] = step.name
152
+ rotation["name_editable"] = step.name_editable
153
+ rotation["deletable"] = step.deletable
154
+ rotations_array.append(rotation)
155
+
156
+ doc["angles_list"] = rotations_array
157
+
158
+ return tk.dumps(doc)
159
+
160
+ @classmethod
161
+ def from_toml(cls, toml_content: str) -> "RotationSequence":
162
+ """Deserialize from TOML with conversions."""
163
+ doc = tk.loads(toml_content)
164
+
165
+ metadata_dict = doc.get("metadata", {})
166
+ convention = AxisConvention(metadata_dict.get("axis_convention", "itk"))
167
+ units = AngleUnits(metadata_dict.get("units", "radians"))
168
+
169
+ metadata = RotationMetadata(
170
+ coordinate_system=metadata_dict.get("coordinate_system", "LPS"),
171
+ axis_convention=convention,
172
+ units=units,
173
+ timestamp=metadata_dict.get("timestamp", dt.datetime.now().isoformat()),
174
+ volume_label=metadata_dict.get("volume_label", ""),
175
+ )
176
+
177
+ angles_list_data = doc.get("angles_list", [])
178
+ angles_list = []
179
+ for item in angles_list_data:
180
+ axes = item.get("axes", "X")
181
+ angle = item.get("angle", 0.0)
182
+ step = RotationStep.from_convention(
183
+ axes=axes,
184
+ angle=angle,
185
+ convention=convention,
186
+ units=units,
187
+ visible=item.get("visible", True),
188
+ name=item.get("name", ""),
189
+ name_editable=item.get("name_editable", True),
190
+ deletable=item.get("deletable", True),
191
+ )
192
+ angles_list.append(step)
193
+
194
+ return cls(metadata=metadata, angles_list=angles_list)
195
+
196
+ @classmethod
197
+ def from_file(cls, path: pl.Path) -> "RotationSequence":
198
+ """Load from TOML file."""
199
+ with open(path, "r") as f:
200
+ return cls.from_toml(f.read())
201
+
202
+ def to_file(
203
+ self,
204
+ path: pl.Path,
205
+ target_convention: AxisConvention,
206
+ target_units: AngleUnits,
207
+ ):
208
+ """Save to TOML file."""
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ with open(path, "w") as f:
211
+ f.write(self.to_toml(target_convention, target_units))
@@ -8,9 +8,10 @@ import pydantic_settings as ps
8
8
  import vtk
9
9
 
10
10
  from .mesh import Mesh
11
+ from .orientation import AngleUnits, AxisConvention
12
+ from .rotation import RotationSequence
11
13
  from .segmentation import Segmentation
12
14
  from .types import RGBColor
13
- from .utils import AngleUnit
14
15
  from .volume import Volume
15
16
 
16
17
  MeshListAdapter = pc.TypeAdapter(list[Mesh])
@@ -124,23 +125,31 @@ class Scene(ps.BaseSettings):
124
125
  mpr_window_level_preset: int = pc.Field(
125
126
  default=7, description="Window/level preset key for MPR views"
126
127
  )
127
- mpr_rotation_sequence: list = pc.Field(
128
- default_factory=list,
129
- description="Dynamic rotation sequence for MPR views - list of rotation steps",
128
+ mpr_rotation_sequence: RotationSequence = pc.Field(
129
+ default_factory=RotationSequence,
130
+ description="Dynamic rotation sequence for MPR views",
131
+ )
132
+ mpr_rotation_file: pl.Path | None = pc.Field(
133
+ default=None,
134
+ description="Path to TOML file containing rotation configuration to load",
130
135
  )
131
136
  max_mpr_rotations: int = pc.Field(
132
137
  default=20,
133
138
  description="Maximum number of MPR rotations supported",
134
139
  )
135
- angle_units: AngleUnit = pc.Field(
136
- default=AngleUnit.DEGREES,
140
+ angle_units: AngleUnits = pc.Field(
141
+ default=AngleUnits.RADIANS,
137
142
  description="Units for angle measurements in rotation serialization",
138
143
  )
139
144
  coordinate_system: str = pc.Field(
140
- default="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
145
+ default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
146
+ )
147
+ axis_convention: AxisConvention = pc.Field(
148
+ default=AxisConvention.ITK,
149
+ description="Axis convention for saved rotations (ITK: X=L,Y=P,Z=S; Roma: X=S,Y=P,Z=L)",
141
150
  )
142
151
  mpr_crosshairs_enabled: bool = pc.Field(
143
- default=False, description="Show crosshair lines indicating slice intersections"
152
+ default=True, description="Show crosshair lines indicating slice intersections"
144
153
  )
145
154
  mpr_crosshair_colors: dict = pc.Field(
146
155
  default_factory=lambda: {
@@ -176,6 +185,23 @@ class Scene(ps.BaseSettings):
176
185
  return SegmentationListAdapter.validate_json(v)
177
186
  return v
178
187
 
188
+ @pc.model_validator(mode="after")
189
+ def load_rotation_file(self):
190
+ """Load rotation sequence from TOML file if specified."""
191
+ if self.mpr_rotation_file is not None and self.mpr_rotation_file.exists():
192
+ self.mpr_rotation_sequence = RotationSequence.from_file(
193
+ self.mpr_rotation_file
194
+ )
195
+ if (
196
+ not self.active_volume_label
197
+ and self.mpr_rotation_sequence.metadata.volume_label
198
+ ):
199
+ self.active_volume_label = (
200
+ self.mpr_rotation_sequence.metadata.volume_label
201
+ )
202
+
203
+ return self
204
+
179
205
  # VTK objects as private attributes
180
206
  _renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
181
207
  _renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
@@ -3,11 +3,12 @@ import time
3
3
 
4
4
  import numpy as np
5
5
  from trame.ui.vuetify3 import SinglePageWithDrawerLayout
6
+ from trame.widgets import client
6
7
  from trame.widgets import html
7
8
  from trame.widgets import vtk as vtk_widgets
8
9
  from trame.widgets import vuetify3 as vuetify
9
10
 
10
- from .orientation import EulerAxis, euler_angle_to_rotation_matrix
11
+ from .orientation import AngleUnits, EulerAxis, euler_angle_to_rotation_matrix
11
12
  from .scene import Scene
12
13
  from .volume_property_presets import list_volume_property_presets
13
14
  from .window_level import presets
@@ -151,16 +152,21 @@ class UI:
151
152
  return np.array([0.0, 0.0, 1.0])
152
153
 
153
154
  # Build cumulative rotation from visible rotations
154
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
155
+ rotation_data = getattr(
156
+ self.server.state, "mpr_rotation_data", {"angles_list": []}
157
+ )
158
+ angles_list = rotation_data.get("angles_list", [])
155
159
  cumulative_rotation = np.eye(3)
156
160
 
157
- for i in range(len(rotation_sequence)):
158
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
159
- if is_visible:
160
- angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
161
- rotation = rotation_sequence[i]
161
+ # Get current angle units
162
+ angle_units_str = getattr(self.server.state, "angle_units", "degrees")
163
+ angle_units = AngleUnits(angle_units_str)
164
+
165
+ for rotation in angles_list:
166
+ if rotation.get("visible", True):
167
+ angle = rotation.get("angles", [0])[0]
162
168
  rotation_matrix = euler_angle_to_rotation_matrix(
163
- EulerAxis(rotation["axis"]), angle
169
+ EulerAxis(rotation["axes"]), angle, angle_units
164
170
  )
165
171
  cumulative_rotation = cumulative_rotation @ rotation_matrix
166
172
 
@@ -462,7 +468,7 @@ class UI:
462
468
  # Volume selection dropdown
463
469
  if self.scene.volumes:
464
470
  vuetify.VSelect(
465
- v_if="!maximized_view || maximized_view === 'volume'",
471
+ v_if="(!maximized_view || maximized_view === 'volume') && volume_items.length >= 2",
466
472
  v_model=("active_volume_label", ""),
467
473
  items=("volume_items", []),
468
474
  item_title="text",
@@ -472,57 +478,6 @@ class UI:
472
478
  hide_details=True,
473
479
  )
474
480
 
475
- # Window/Level controls for MPR
476
- vuetify.VListSubheader(
477
- "Window/Level", v_if="!maximized_view && active_volume_label"
478
- )
479
-
480
- vuetify.VSelect(
481
- v_if="!maximized_view && active_volume_label",
482
- v_model=("mpr_window_level_preset", 7),
483
- items=("mpr_presets", []),
484
- item_title="text",
485
- item_value="value",
486
- label="Preset",
487
- dense=True,
488
- hide_details=True,
489
- )
490
-
491
- vuetify.VSlider(
492
- v_if="!maximized_view && active_volume_label",
493
- v_model="mpr_window",
494
- min=1.0,
495
- max=2000.0,
496
- step=1.0,
497
- hint="Window",
498
- persistent_hint=True,
499
- dense=True,
500
- hide_details=False,
501
- thumb_label=True,
502
- )
503
-
504
- vuetify.VSlider(
505
- v_if="!maximized_view && active_volume_label",
506
- v_model="mpr_level",
507
- min=-1000.0,
508
- max=1000.0,
509
- step=1.0,
510
- hint="Level",
511
- persistent_hint=True,
512
- dense=True,
513
- hide_details=False,
514
- thumb_label=True,
515
- )
516
-
517
- vuetify.VCheckbox(
518
- v_if="!maximized_view && active_volume_label",
519
- v_model=("mpr_crosshairs_enabled", True),
520
- label="Show Crosshairs",
521
- dense=True,
522
- hide_details=True,
523
- classes="mt-2",
524
- )
525
-
526
481
  # MPR Rotation controls
527
482
  vuetify.VListSubheader(
528
483
  "Rotations", v_if="!maximized_view && active_volume_label"
@@ -564,8 +519,8 @@ class UI:
564
519
 
565
520
  # Reset rotations button
566
521
  vuetify.VBtn(
567
- "Reset",
568
- v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
522
+ "Remove All Rotations",
523
+ v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
569
524
  click=self.server.controller.reset_rotations,
570
525
  small=True,
571
526
  dense=True,
@@ -576,48 +531,105 @@ class UI:
576
531
  prepend_icon="mdi-refresh",
577
532
  )
578
533
 
579
- # Individual rotation sliders
580
- for i in range(self.scene.max_mpr_rotations):
581
- with vuetify.VRow(
582
- v_if=f"!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
583
- no_gutters=True,
584
- classes="align-center mb-1",
585
- ):
586
- with vuetify.VCol(cols="8"):
587
- vuetify.VSlider(
588
- v_model=(f"mpr_rotation_angle_{i}", 0),
589
- min=-180,
590
- max=180,
591
- step=1,
592
- hint=(
593
- f"mpr_rotation_axis_{i}",
594
- f"Rotation {i + 1}",
595
- ),
596
- persistent_hint=True,
597
- dense=True,
598
- hide_details=False,
599
- thumb_label=True,
600
- )
601
- with vuetify.VCol(cols="2"):
602
- vuetify.VCheckbox(
603
- v_model=(f"mpr_rotation_visible_{i}", True),
604
- true_icon="mdi-eye",
605
- false_icon="mdi-eye-off",
606
- hide_details=True,
607
- dense=True,
608
- title="Toggle this rotation and all subsequent ones",
609
- )
610
- with vuetify.VCol(cols="2"):
611
- vuetify.VBtn(
612
- icon="mdi-delete",
613
- click=ft.partial(
614
- self.server.controller.remove_rotation_event, i
615
- ),
616
- small=True,
617
- dense=True,
618
- color="error",
619
- title="Remove this rotation and all subsequent ones",
620
- )
534
+ # Individual rotation sliders with DeepReactive
535
+ with client.DeepReactive("mpr_rotation_data"):
536
+ for i in range(self.scene.max_mpr_rotations):
537
+ with vuetify.VContainer(
538
+ v_if=f"!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > {i}",
539
+ fluid=True,
540
+ classes="pa-0 mb-2",
541
+ ):
542
+ with vuetify.VRow(no_gutters=True):
543
+ with vuetify.VCol(cols="12"):
544
+ vuetify.VTextField(
545
+ v_model=(
546
+ f"mpr_rotation_data.angles_list[{i}].name",
547
+ ),
548
+ placeholder="Name",
549
+ dense=True,
550
+ hide_details=True,
551
+ readonly=(
552
+ f"!mpr_rotation_data.angles_list[{i}].name_editable",
553
+ ),
554
+ __events=["keydown", "keyup", "keypress"],
555
+ keydown="$event.stopPropagation(); $event.stopImmediatePropagation();",
556
+ keyup="$event.stopPropagation(); $event.stopImmediatePropagation();",
557
+ keypress="$event.stopPropagation(); $event.stopImmediatePropagation();",
558
+ )
559
+ with vuetify.VRow(no_gutters=True):
560
+ with vuetify.VCol(cols="12"):
561
+ vuetify.VSlider(
562
+ v_model=(
563
+ f"mpr_rotation_data.angles_list[{i}].angles[0]",
564
+ ),
565
+ min=(
566
+ "angle_units === 'radians' ? -Math.PI : -180",
567
+ ),
568
+ max=(
569
+ "angle_units === 'radians' ? Math.PI : 180",
570
+ ),
571
+ step=(
572
+ "angle_units === 'radians' ? 0.01 : 1",
573
+ ),
574
+ dense=True,
575
+ hide_details=True,
576
+ thumb_label=True,
577
+ )
578
+ with vuetify.VRow(
579
+ no_gutters=True, classes="align-center"
580
+ ):
581
+ vuetify.VSpacer()
582
+ with vuetify.VCol(cols="4"):
583
+ vuetify.VSelect(
584
+ v_model=(
585
+ f"mpr_rotation_data.angles_list[{i}].axes",
586
+ ),
587
+ items=(["X", "Y", "Z"],),
588
+ dense=True,
589
+ hide_details=True,
590
+ label="Axis",
591
+ )
592
+ vuetify.VSpacer()
593
+ with vuetify.VCol(cols="auto"):
594
+ vuetify.VCheckbox(
595
+ v_model=(
596
+ f"mpr_rotation_data.angles_list[{i}].visible",
597
+ ),
598
+ true_icon="mdi-eye",
599
+ false_icon="mdi-eye-off",
600
+ hide_details=True,
601
+ dense=True,
602
+ title="Toggle this rotation",
603
+ )
604
+ vuetify.VSpacer()
605
+ with vuetify.VCol(cols="auto"):
606
+ vuetify.VBtn(
607
+ icon="mdi-restore",
608
+ click=ft.partial(
609
+ self.server.controller.reset_rotation_angle,
610
+ i,
611
+ ),
612
+ small=True,
613
+ dense=True,
614
+ title="Reset angle to zero",
615
+ )
616
+ vuetify.VSpacer()
617
+ with vuetify.VCol(cols="auto"):
618
+ vuetify.VBtn(
619
+ icon="mdi-delete",
620
+ click=ft.partial(
621
+ self.server.controller.remove_rotation_event,
622
+ i,
623
+ ),
624
+ small=True,
625
+ dense=True,
626
+ color="error",
627
+ title="Remove this rotation",
628
+ disabled=(
629
+ f"!mpr_rotation_data.angles_list[{i}].deletable",
630
+ ),
631
+ )
632
+ vuetify.VSpacer()
621
633
 
622
634
  # Angle units selector
623
635
  with vuetify.VRow(
@@ -629,7 +641,7 @@ class UI:
629
641
  vuetify.VLabel("Units:")
630
642
  with vuetify.VCol(cols="8"):
631
643
  vuetify.VSelect(
632
- v_model=("angle_units", "degrees"),
644
+ v_model=("angle_units", "radians"),
633
645
  items=("angle_units_items", []),
634
646
  item_title="text",
635
647
  item_value="value",
@@ -638,10 +650,29 @@ class UI:
638
650
  outlined=True,
639
651
  )
640
652
 
653
+ # Axis convention selector
654
+ with vuetify.VRow(
655
+ v_if="!maximized_view && active_volume_label",
656
+ no_gutters=True,
657
+ classes="align-center mb-2",
658
+ ):
659
+ with vuetify.VCol(cols="4"):
660
+ vuetify.VLabel("Convention:")
661
+ with vuetify.VCol(cols="8"):
662
+ vuetify.VSelect(
663
+ v_model=("axis_convention", "itk"),
664
+ items=("axis_convention_items", []),
665
+ item_title="text",
666
+ item_value="value",
667
+ dense=True,
668
+ hide_details=True,
669
+ outlined=True,
670
+ )
671
+
641
672
  # Save rotations button
642
673
  vuetify.VBtn(
643
674
  "Save Rotations",
644
- v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
675
+ v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
645
676
  click=self.server.controller.save_rotation_angles,
646
677
  small=True,
647
678
  dense=True,
@@ -10,13 +10,6 @@ class InterpolatorType(enum.Enum):
10
10
  NEAREST = "nearest"
11
11
 
12
12
 
13
- class AngleUnit(enum.Enum):
14
- """Units for angle measurements."""
15
-
16
- DEGREES = "degrees"
17
- RADIANS = "radians"
18
-
19
-
20
13
  def calculate_combined_bounds(actors):
21
14
  """Calculate combined bounds encompassing all VTK actors.
22
15
 
@@ -206,17 +206,17 @@ class Volume(Object):
206
206
  )
207
207
  elif view_name == "sagittal":
208
208
  view_crosshairs["line1"]["actor"].GetProperty().SetColor(
209
- *colors.get("axial", (0, 0, 1))
209
+ *colors.get("coronal", (0, 1, 0))
210
210
  )
211
211
  view_crosshairs["line2"]["actor"].GetProperty().SetColor(
212
- *colors.get("coronal", (0, 1, 0))
212
+ *colors.get("axial", (0, 0, 1))
213
213
  )
214
214
  else: # coronal
215
215
  view_crosshairs["line1"]["actor"].GetProperty().SetColor(
216
- *colors.get("axial", (0, 0, 1))
216
+ *colors.get("sagittal", (1, 0, 0))
217
217
  )
218
218
  view_crosshairs["line2"]["actor"].GetProperty().SetColor(
219
- *colors.get("sagittal", (1, 0, 0))
219
+ *colors.get("axial", (0, 0, 1))
220
220
  )
221
221
 
222
222
  crosshairs[view_name] = view_crosshairs
@@ -299,15 +299,20 @@ class Volume(Object):
299
299
  return bounds
300
300
 
301
301
  def _build_cumulative_rotation(
302
- self, rotation_sequence: list, rotation_angles: dict
302
+ self, rotation_sequence: list, rotation_angles: dict, angle_units=None
303
303
  ) -> np.ndarray:
304
304
  """Build cumulative rotation matrix from sequence of rotations."""
305
+ from .orientation import AngleUnits
306
+
307
+ if angle_units is None:
308
+ angle_units = AngleUnits.DEGREES
309
+
305
310
  cumulative_rotation = np.eye(3)
306
311
  if rotation_sequence and rotation_angles:
307
312
  for i, rotation in enumerate(rotation_sequence):
308
313
  angle = rotation_angles.get(i, 0)
309
314
  rotation_matrix = euler_angle_to_rotation_matrix(
310
- EulerAxis(rotation["axis"]), angle
315
+ EulerAxis(rotation["axis"]), angle, angle_units
311
316
  )
312
317
  cumulative_rotation = cumulative_rotation @ rotation_matrix
313
318
  return cumulative_rotation
@@ -317,6 +322,7 @@ class Volume(Object):
317
322
  view_name: str,
318
323
  rotation_sequence: list = None,
319
324
  rotation_angles: dict = None,
325
+ angle_units=None,
320
326
  ) -> np.ndarray:
321
327
  """Get the current normal vector for a view after rotation.
322
328
 
@@ -324,6 +330,7 @@ class Volume(Object):
324
330
  view_name: One of "axial", "sagittal", "coronal"
325
331
  rotation_sequence: List of rotation definitions
326
332
  rotation_angles: Dict mapping rotation index to angle
333
+ angle_units: AngleUnits enum (degrees or radians)
327
334
 
328
335
  Returns:
329
336
  3D unit vector representing the scroll direction for this view
@@ -338,7 +345,7 @@ class Volume(Object):
338
345
  return np.array([0.0, 0.0, 1.0])
339
346
 
340
347
  cumulative_rotation = self._build_cumulative_rotation(
341
- rotation_sequence, rotation_angles
348
+ rotation_sequence, rotation_angles, angle_units
342
349
  )
343
350
  return cumulative_rotation @ base_normals[view_name]
344
351
 
@@ -348,6 +355,7 @@ class Volume(Object):
348
355
  origin: list,
349
356
  rotation_sequence: list = None,
350
357
  rotation_angles: dict = None,
358
+ angle_units=None,
351
359
  ):
352
360
  """Update slice positions for MPR views with optional rotation.
353
361
 
@@ -356,7 +364,12 @@ class Volume(Object):
356
364
  origin: [x, y, z] position in LPS coordinates (shared by all views)
357
365
  rotation_sequence: List of rotation definitions
358
366
  rotation_angles: Dict mapping rotation index to angle
367
+ angle_units: AngleUnits enum (degrees or radians), defaults to DEGREES
359
368
  """
369
+ from .orientation import AngleUnits
370
+
371
+ if angle_units is None:
372
+ angle_units = AngleUnits.DEGREES
360
373
  if frame not in self._mpr_actors:
361
374
  return
362
375
 
@@ -367,7 +380,7 @@ class Volume(Object):
367
380
 
368
381
  # Build cumulative rotation matrix
369
382
  cumulative_rotation = self._build_cumulative_rotation(
370
- rotation_sequence, rotation_angles
383
+ rotation_sequence, rotation_angles, angle_units
371
384
  )
372
385
 
373
386
  # Apply rotation to base transforms
File without changes
File without changes
File without changes