cardio 2025.9.0__py3-none-any.whl → 2025.10.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/scene.py CHANGED
@@ -1,54 +1,52 @@
1
1
  # System
2
2
  import logging
3
3
  import pathlib as pl
4
-
5
- # Type adapters for list types to improve CLI integration
6
- from typing import Annotated
4
+ import typing as ty
7
5
 
8
6
  # Third Party
9
7
  import numpy as np
10
8
  import pydantic as pc
11
9
  import pydantic_settings as ps
12
10
  import vtk
13
- from pydantic import Field, PrivateAttr, TypeAdapter, field_validator, model_validator
14
11
 
15
12
  # Internal
16
13
  from .mesh import Mesh
17
14
  from .segmentation import Segmentation
18
15
  from .types import RGBColor
16
+ from .utils import AngleUnit
19
17
  from .volume import Volume
20
18
 
21
- MeshListAdapter = TypeAdapter(list[Mesh])
22
- VolumeListAdapter = TypeAdapter(list[Volume])
23
- SegmentationListAdapter = TypeAdapter(list[Segmentation])
19
+ MeshListAdapter = pc.TypeAdapter(list[Mesh])
20
+ VolumeListAdapter = pc.TypeAdapter(list[Volume])
21
+ SegmentationListAdapter = pc.TypeAdapter(list[Segmentation])
24
22
 
25
23
  # Create annotated types for better CLI integration
26
- MeshList = Annotated[
24
+ MeshList = ty.Annotated[
27
25
  list[Mesh],
28
- Field(
26
+ pc.Field(
29
27
  description='List of mesh objects. CLI usage: --meshes \'[{"label":"mesh1","directory":"./data/mesh1"}]\''
30
28
  ),
31
29
  ]
32
- VolumeList = Annotated[
30
+ VolumeList = ty.Annotated[
33
31
  list[Volume],
34
- Field(
32
+ pc.Field(
35
33
  description='Volume objects. CLI: --volumes \'[{"label":"vol1","directory":"./data/vol1"}]\''
36
34
  ),
37
35
  ]
38
- SegmentationList = Annotated[
36
+ SegmentationList = ty.Annotated[
39
37
  list[Segmentation],
40
- Field(
38
+ pc.Field(
41
39
  description='Segmentation objects. CLI: --segmentations \'[{"label":"seg1","directory":"./data/seg1"}]\''
42
40
  ),
43
41
  ]
44
42
 
45
43
 
46
44
  class Background(pc.BaseModel):
47
- light: RGBColor = Field(
45
+ light: RGBColor = pc.Field(
48
46
  default=(1.0, 1.0, 1.0),
49
47
  description="Background color in light mode. CLI usage: --background.light '[0.8,0.9,1.0]'",
50
48
  )
51
- dark: RGBColor = Field(
49
+ dark: RGBColor = pc.Field(
52
50
  default=(0.0, 0.0, 0.0),
53
51
  description="Background color in dark mode. CLI usage: --background.dark '[0.1,0.1,0.2]'",
54
52
  )
@@ -92,63 +90,83 @@ class Scene(ps.BaseSettings):
92
90
 
93
91
  project_name: str = "Cardio"
94
92
  current_frame: int = 0
95
- screenshot_directory: pl.Path = pl.Path("./data/screenshots")
96
- screenshot_subdirectory_format: str = "%Y-%m-%d-%H-%M-%S"
93
+ serialization_directory: pl.Path = pc.Field(
94
+ default=pl.Path("./data"),
95
+ description="Base directory for all serialized data (screenshots, exports, etc.)",
96
+ )
97
+ timestamp_format: str = pc.Field(
98
+ default="%Y-%m-%d-%H-%M-%S",
99
+ description="Timestamp format for serialized data subdirectories",
100
+ )
97
101
  rotation_factor: float = 3.0
98
- background: Background = Field(
102
+ background: Background = pc.Field(
99
103
  default_factory=Background,
100
104
  description='Background colors. CLI usage: \'{"light": [0.8, 0.9, 1.0], "dark": [0.1, 0.1, 0.2]}\'',
101
105
  )
102
- meshes: MeshList = Field(default_factory=list)
103
- volumes: VolumeList = Field(default_factory=list)
104
- segmentations: SegmentationList = Field(default_factory=list)
105
- mpr_enabled: bool = Field(
106
+ meshes: MeshList = pc.Field(default_factory=list)
107
+ volumes: VolumeList = pc.Field(default_factory=list)
108
+ segmentations: SegmentationList = pc.Field(default_factory=list)
109
+ mpr_enabled: bool = pc.Field(
106
110
  default=False,
107
111
  description="Enable multi-planar reconstruction (MPR) mode with quad-view layout",
108
112
  )
109
- active_volume_label: str = Field(
113
+ active_volume_label: str = pc.Field(
110
114
  default="",
111
115
  description="Label of the volume to use for multi-planar reconstruction",
112
116
  )
113
- axial_slice: float = Field(
114
- default=0.5, description="Axial slice position as fraction (0.0 to 1.0)"
117
+ axial_slice: float = pc.Field(
118
+ default=0.0,
119
+ description="Axial slice position in physical coordinates (LAS Z axis)",
115
120
  )
116
- sagittal_slice: float = Field(
117
- default=0.5, description="Sagittal slice position as fraction (0.0 to 1.0)"
121
+ sagittal_slice: float = pc.Field(
122
+ default=0.0,
123
+ description="Sagittal slice position in physical coordinates (LAS X axis)",
118
124
  )
119
- coronal_slice: float = Field(
120
- default=0.5, description="Coronal slice position as fraction (0.0 to 1.0)"
125
+ coronal_slice: float = pc.Field(
126
+ default=0.0,
127
+ description="Coronal slice position in physical coordinates (LAS Y axis)",
121
128
  )
122
- mpr_window: float = Field(
129
+ mpr_window: float = pc.Field(
123
130
  default=800.0, description="Window width for MPR image display"
124
131
  )
125
- mpr_level: float = Field(
132
+ mpr_level: float = pc.Field(
126
133
  default=200.0, description="Window level for MPR image display"
127
134
  )
128
- mpr_window_level_preset: int = Field(
135
+ mpr_window_level_preset: int = pc.Field(
129
136
  default=7, description="Window/level preset key for MPR views"
130
137
  )
131
- mpr_rotation_sequence: list = Field(
138
+ mpr_rotation_sequence: list = pc.Field(
132
139
  default_factory=list,
133
140
  description="Dynamic rotation sequence for MPR views - list of rotation steps",
134
141
  )
142
+ max_mpr_rotations: int = pc.Field(
143
+ default=20,
144
+ description="Maximum number of MPR rotations supported",
145
+ )
146
+ angle_units: AngleUnit = pc.Field(
147
+ default=AngleUnit.DEGREES,
148
+ description="Units for angle measurements in rotation serialization",
149
+ )
150
+ coordinate_system: str = pc.Field(
151
+ default="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
152
+ )
135
153
 
136
154
  # Field validators for JSON string inputs
137
- @field_validator("meshes", mode="before")
155
+ @pc.field_validator("meshes", mode="before")
138
156
  @classmethod
139
157
  def validate_meshes(cls, v):
140
158
  if isinstance(v, str):
141
159
  return MeshListAdapter.validate_json(v)
142
160
  return v
143
161
 
144
- @field_validator("volumes", mode="before")
162
+ @pc.field_validator("volumes", mode="before")
145
163
  @classmethod
146
164
  def validate_volumes(cls, v):
147
165
  if isinstance(v, str):
148
166
  return VolumeListAdapter.validate_json(v)
149
167
  return v
150
168
 
151
- @field_validator("segmentations", mode="before")
169
+ @pc.field_validator("segmentations", mode="before")
152
170
  @classmethod
153
171
  def validate_segmentations(cls, v):
154
172
  if isinstance(v, str):
@@ -156,18 +174,18 @@ class Scene(ps.BaseSettings):
156
174
  return v
157
175
 
158
176
  # VTK objects as private attributes
159
- _renderer: vtk.vtkRenderer = PrivateAttr(default_factory=vtk.vtkRenderer)
160
- _renderWindow: vtk.vtkRenderWindow = PrivateAttr(
177
+ _renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
178
+ _renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
161
179
  default_factory=vtk.vtkRenderWindow
162
180
  )
163
- _renderWindowInteractor: vtk.vtkRenderWindowInteractor = PrivateAttr(
181
+ _renderWindowInteractor: vtk.vtkRenderWindowInteractor = pc.PrivateAttr(
164
182
  default_factory=vtk.vtkRenderWindowInteractor
165
183
  )
166
184
 
167
185
  # MPR render windows
168
- _axial_renderWindow: vtk.vtkRenderWindow = PrivateAttr(default=None)
169
- _coronal_renderWindow: vtk.vtkRenderWindow = PrivateAttr(default=None)
170
- _sagittal_renderWindow: vtk.vtkRenderWindow = PrivateAttr(default=None)
186
+ _axial_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
187
+ _coronal_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
188
+ _sagittal_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
171
189
 
172
190
  @property
173
191
  def renderer(self) -> vtk.vtkRenderer:
@@ -193,7 +211,7 @@ class Scene(ps.BaseSettings):
193
211
  def sagittal_renderWindow(self) -> vtk.vtkRenderWindow:
194
212
  return self._sagittal_renderWindow
195
213
 
196
- @model_validator(mode="after")
214
+ @pc.model_validator(mode="after")
197
215
  def setup_scene(self):
198
216
  # Validate unique labels
199
217
  self._validate_unique_labels()
@@ -264,6 +282,16 @@ class Scene(ps.BaseSettings):
264
282
  "Active volume label specified but no volumes are available"
265
283
  )
266
284
 
285
+ @property
286
+ def screenshot_directory(self) -> pl.Path:
287
+ """Computed property that returns the screenshots subdirectory."""
288
+ return self.serialization_directory / "screenshots"
289
+
290
+ @property
291
+ def rotations_directory(self) -> pl.Path:
292
+ """Computed property that returns the rotations subdirectory."""
293
+ return self.serialization_directory / "rotations"
294
+
267
295
  @property
268
296
  def nframes(self) -> int:
269
297
  ns = []
@@ -316,6 +344,7 @@ class Scene(ps.BaseSettings):
316
344
 
317
345
  # Create and set interactor for axial
318
346
  axial_interactor = vtk.vtkRenderWindowInteractor()
347
+ axial_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
319
348
  self._axial_renderWindow.SetInteractor(axial_interactor)
320
349
 
321
350
  # Create coronal render window
@@ -327,6 +356,7 @@ class Scene(ps.BaseSettings):
327
356
 
328
357
  # Create and set interactor for coronal
329
358
  coronal_interactor = vtk.vtkRenderWindowInteractor()
359
+ coronal_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
330
360
  self._coronal_renderWindow.SetInteractor(coronal_interactor)
331
361
 
332
362
  # Create sagittal render window
@@ -338,6 +368,7 @@ class Scene(ps.BaseSettings):
338
368
 
339
369
  # Create and set interactor for sagittal
340
370
  sagittal_interactor = vtk.vtkRenderWindowInteractor()
371
+ sagittal_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
341
372
  self._sagittal_renderWindow.SetInteractor(sagittal_interactor)
342
373
 
343
374
  def hide_all_frames(self):
cardio/segmentation.py CHANGED
@@ -8,9 +8,11 @@ import pydantic as pc
8
8
  import vtk
9
9
 
10
10
  # Internal
11
+ from .orientation import (
12
+ reset_direction,
13
+ )
11
14
  from .object import Object
12
15
  from .property_config import vtkPropertyConfig
13
- from .utils import InterpolatorType, reset_direction
14
16
 
15
17
 
16
18
  class Segmentation(Object):
@@ -35,7 +37,7 @@ class Segmentation(Object):
35
37
 
36
38
  # Read and process segmentation image
37
39
  image = itk.imread(path)
38
- image = reset_direction(image, InterpolatorType.NEAREST)
40
+ image = reset_direction(image)
39
41
  vtk_image = itk.vtk_image_from_image(image)
40
42
 
41
43
  # Create SurfaceNets3D filter
cardio/ui.py CHANGED
@@ -1,15 +1,140 @@
1
+ import functools as ft
2
+
1
3
  from trame.ui.vuetify3 import SinglePageWithDrawerLayout
2
4
  from trame.widgets import vtk as vtk_widgets
3
5
  from trame.widgets import vuetify3 as vuetify
4
6
 
5
7
  from .scene import Scene
6
8
  from .volume_property_presets import list_volume_property_presets
9
+ from .window_level import presets
7
10
 
8
11
 
9
12
  class UI:
13
+ @property
14
+ def handled_events(self):
15
+ return [
16
+ "MouseMove",
17
+ "LeftButtonPress",
18
+ "LeftButtonRelease",
19
+ "RightButtonPress",
20
+ "RightButtonRelease",
21
+ "KeyPress",
22
+ ]
23
+
24
+ def event_listeners_for_view(self, view_name):
25
+ """Create event listeners for a specific view."""
26
+ result = {}
27
+ for event in self.handled_events:
28
+ callback = ft.partial(self.on_event, view_name=view_name)
29
+ result[event] = (callback, "[utils.vtk.event($event)]")
30
+ return result
31
+
32
+ def on_event(self, *args, view_name=None, **kwargs):
33
+ if not args:
34
+ return
35
+
36
+ event = args[0]
37
+
38
+ match event["type"]:
39
+ case "KeyPress":
40
+ if event["key"].isdigit():
41
+ key_num = int(event["key"])
42
+ if key_num in presets.keys():
43
+ self.server.state.mpr_window_level_preset = key_num
44
+
45
+ case "LeftButtonPress":
46
+ self.left_dragging = True
47
+ self._store_mouse_position(view_name, event)
48
+
49
+ case "LeftButtonRelease":
50
+ self.left_dragging = False
51
+
52
+ case "RightButtonPress":
53
+ self.right_dragging = True
54
+ self._store_mouse_position(view_name, event)
55
+
56
+ case "RightButtonRelease":
57
+ self.right_dragging = False
58
+
59
+ case "MouseMove" if self.left_dragging:
60
+ if (
61
+ view_name in {"axial", "sagittal", "coronal"}
62
+ and view_name in self.last_mouse_pos
63
+ and "position" in event
64
+ ):
65
+ current_pos = [event["position"]["x"], event["position"]["y"]]
66
+ dx = current_pos[0] - self.last_mouse_pos[view_name][0]
67
+ dy = current_pos[1] - self.last_mouse_pos[view_name][1]
68
+ self.last_mouse_pos[view_name] = current_pos
69
+
70
+ current_window = getattr(self.server.state, "mpr_window", 400.0)
71
+ current_level = getattr(self.server.state, "mpr_level", 40.0)
72
+
73
+ window_delta = -dx * self.window_sensitivity
74
+ level_delta = -dy * self.level_sensitivity
75
+ new_window = max(1.0, current_window + window_delta)
76
+ new_level = current_level + level_delta
77
+
78
+ self.server.state.mpr_window = new_window
79
+ self.server.state.mpr_level = new_level
80
+
81
+ case "MouseMove" if self.right_dragging:
82
+ if (
83
+ view_name in {"axial", "sagittal", "coronal"}
84
+ and view_name in self.last_mouse_pos
85
+ and "position" in event
86
+ ):
87
+ current_pos = [event["position"]["x"], event["position"]["y"]]
88
+ dy = current_pos[1] - self.last_mouse_pos[view_name][1]
89
+ self.last_mouse_pos[view_name] = current_pos
90
+
91
+ base_slice_delta = dy * self.slice_sensitivity
92
+ self._handle_slice_scroll(view_name, base_slice_delta)
93
+
94
+ def _store_mouse_position(self, view_name, event):
95
+ """Store mouse position for drag operations."""
96
+ if view_name and "position" in event:
97
+ self.last_mouse_pos[view_name] = [
98
+ event["position"]["x"],
99
+ event["position"]["y"],
100
+ ]
101
+
102
+ def _handle_slice_scroll(self, view_name, base_slice_delta):
103
+ """Handle slice scrolling for a specific view."""
104
+ match view_name:
105
+ case "axial":
106
+ current_slice = getattr(self.server.state, "axial_slice", 0.0)
107
+ bounds = getattr(self.server.state, "axial_slice_bounds", [0.0, 100.0])
108
+ slice_attr = "axial_slice"
109
+ case "sagittal":
110
+ current_slice = getattr(self.server.state, "sagittal_slice", 0.0)
111
+ bounds = getattr(
112
+ self.server.state, "sagittal_slice_bounds", [0.0, 100.0]
113
+ )
114
+ slice_attr = "sagittal_slice"
115
+ case "coronal":
116
+ current_slice = getattr(self.server.state, "coronal_slice", 0.0)
117
+ bounds = getattr(
118
+ self.server.state, "coronal_slice_bounds", [0.0, 100.0]
119
+ )
120
+ slice_attr = "coronal_slice"
121
+ case _:
122
+ return
123
+
124
+ min_bound, max_bound = min(bounds), max(bounds)
125
+ slice_delta = base_slice_delta if bounds[1] > bounds[0] else -base_slice_delta
126
+ new_slice = max(min_bound, min(max_bound, current_slice + slice_delta))
127
+ setattr(self.server.state, slice_attr, new_slice)
128
+
10
129
  def __init__(self, server, scene: Scene):
11
130
  self.server = server
12
131
  self.scene: Scene = scene
132
+ self.left_dragging = False
133
+ self.right_dragging = False
134
+ self.last_mouse_pos = {}
135
+ self.window_sensitivity = 5.0
136
+ self.level_sensitivity = 2.0
137
+ self.slice_sensitivity = 1.0
13
138
 
14
139
  self.setup()
15
140
 
@@ -63,7 +188,10 @@ class UI:
63
188
  classes="pa-0 fill-height",
64
189
  ):
65
190
  view = vtk_widgets.VtkRemoteView(
66
- self.scene.renderWindow, interactive_ratio=1
191
+ self.scene.renderWindow,
192
+ interactor_events=("event_types", self.handled_events),
193
+ **self.event_listeners_for_view("volume"),
194
+ interactive_ratio=1,
67
195
  )
68
196
  self.server.controller.view_update = view.update
69
197
  self.server.controller.view_reset_camera = view.reset_camera
@@ -88,15 +216,19 @@ class UI:
88
216
  axial_view = vtk_widgets.VtkRemoteView(
89
217
  self.scene.axial_renderWindow,
90
218
  style="height: 100%; width: 100%;",
219
+ interactor_events=("event_types", self.handled_events),
220
+ **self.event_listeners_for_view("axial"),
91
221
  interactive_ratio=1,
92
222
  )
93
223
  with vuetify.VCol(
94
224
  cols="6", classes="pa-1", style="height: 100%;"
95
225
  ):
96
- # Volume view (reuse existing render window)
226
+ # Volume view
97
227
  volume_view = vtk_widgets.VtkRemoteView(
98
228
  self.scene.renderWindow,
99
229
  style="height: 100%; width: 100%;",
230
+ interactor_events=("event_types", self.handled_events),
231
+ **self.event_listeners_for_view("volume_mpr"),
100
232
  interactive_ratio=1,
101
233
  )
102
234
 
@@ -109,6 +241,8 @@ class UI:
109
241
  coronal_view = vtk_widgets.VtkRemoteView(
110
242
  self.scene.coronal_renderWindow,
111
243
  style="height: 100%; width: 100%;",
244
+ interactor_events=("event_types", self.handled_events),
245
+ **self.event_listeners_for_view("coronal"),
112
246
  interactive_ratio=1,
113
247
  )
114
248
  with vuetify.VCol(
@@ -118,6 +252,8 @@ class UI:
118
252
  sagittal_view = vtk_widgets.VtkRemoteView(
119
253
  self.scene.sagittal_renderWindow,
120
254
  style="height: 100%; width: 100%;",
255
+ interactor_events=("event_types", self.handled_events),
256
+ **self.event_listeners_for_view("sagittal"),
121
257
  interactive_ratio=1,
122
258
  )
123
259
 
@@ -207,10 +343,9 @@ class UI:
207
343
 
208
344
  vuetify.VSlider(
209
345
  v_if="mpr_enabled && active_volume_label",
210
- v_model=("axial_slice", 0.5),
211
- min=0.0,
212
- max=1.0,
213
- step=0.01,
346
+ v_model=("axial_slice", 0.0),
347
+ min=("axial_slice_bounds[0]", 0.0),
348
+ max=("axial_slice_bounds[1]", 100.0),
214
349
  hint="A (I ↔ S)",
215
350
  persistent_hint=True,
216
351
  dense=True,
@@ -220,10 +355,9 @@ class UI:
220
355
 
221
356
  vuetify.VSlider(
222
357
  v_if="mpr_enabled && active_volume_label",
223
- v_model=("sagittal_slice", 0.5),
224
- min=0.0,
225
- max=1.0,
226
- step=0.01,
358
+ v_model=("sagittal_slice", 0.0),
359
+ min=("sagittal_slice_bounds[0]", 0.0),
360
+ max=("sagittal_slice_bounds[1]", 100.0),
227
361
  hint="S (R ↔ L)",
228
362
  persistent_hint=True,
229
363
  dense=True,
@@ -233,10 +367,9 @@ class UI:
233
367
 
234
368
  vuetify.VSlider(
235
369
  v_if="mpr_enabled && active_volume_label",
236
- v_model=("coronal_slice", 0.5),
237
- min=0.0,
238
- max=1.0,
239
- step=0.01,
370
+ v_model=("coronal_slice", 0.0),
371
+ min=("coronal_slice_bounds[0]", 0.0),
372
+ max=("coronal_slice_bounds[1]", 100.0),
240
373
  hint="C (P ↔ A)",
241
374
  persistent_hint=True,
242
375
  dense=True,
@@ -262,6 +395,7 @@ class UI:
262
395
  small=True,
263
396
  dense=True,
264
397
  outlined=True,
398
+ color="primary",
265
399
  )
266
400
  with vuetify.VCol(cols="4"):
267
401
  vuetify.VBtn(
@@ -270,6 +404,7 @@ class UI:
270
404
  small=True,
271
405
  dense=True,
272
406
  outlined=True,
407
+ color="primary",
273
408
  )
274
409
  with vuetify.VCol(cols="4"):
275
410
  vuetify.VBtn(
@@ -278,6 +413,7 @@ class UI:
278
413
  small=True,
279
414
  dense=True,
280
415
  outlined=True,
416
+ color="primary",
281
417
  )
282
418
 
283
419
  # Reset rotations button
@@ -291,22 +427,84 @@ class UI:
291
427
  color="warning",
292
428
  block=True,
293
429
  classes="mb-2",
430
+ prepend_icon="mdi-refresh",
294
431
  )
295
432
 
296
- # Individual rotation sliders (show up to 10)
297
- for i in range(10):
298
- vuetify.VSlider(
433
+ # Individual rotation sliders
434
+ for i in range(self.scene.max_mpr_rotations):
435
+ with vuetify.VRow(
299
436
  v_if=f"mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
300
- v_model=(f"mpr_rotation_angle_{i}", 0),
301
- min=-180,
302
- max=180,
303
- step=1,
304
- hint=(f"mpr_rotation_axis_{i}", f"Rotation {i + 1}"),
305
- persistent_hint=True,
306
- dense=True,
307
- hide_details=False,
308
- thumb_label=True,
309
- )
437
+ no_gutters=True,
438
+ classes="align-center mb-1",
439
+ ):
440
+ with vuetify.VCol(cols="8"):
441
+ vuetify.VSlider(
442
+ v_model=(f"mpr_rotation_angle_{i}", 0),
443
+ min=-180,
444
+ max=180,
445
+ step=1,
446
+ hint=(
447
+ f"mpr_rotation_axis_{i}",
448
+ f"Rotation {i + 1}",
449
+ ),
450
+ persistent_hint=True,
451
+ dense=True,
452
+ hide_details=False,
453
+ thumb_label=True,
454
+ )
455
+ with vuetify.VCol(cols="2"):
456
+ vuetify.VCheckbox(
457
+ v_model=(f"mpr_rotation_visible_{i}", True),
458
+ true_icon="mdi-eye",
459
+ false_icon="mdi-eye-off",
460
+ hide_details=True,
461
+ dense=True,
462
+ title="Toggle this rotation and all subsequent ones",
463
+ )
464
+ with vuetify.VCol(cols="2"):
465
+ vuetify.VBtn(
466
+ icon="mdi-delete",
467
+ click=ft.partial(
468
+ self.server.controller.remove_rotation_event, i
469
+ ),
470
+ small=True,
471
+ dense=True,
472
+ color="error",
473
+ title="Remove this rotation and all subsequent ones",
474
+ )
475
+
476
+ # Angle units selector
477
+ with vuetify.VRow(
478
+ v_if="mpr_enabled && active_volume_label",
479
+ no_gutters=True,
480
+ classes="align-center mb-2 mt-2",
481
+ ):
482
+ with vuetify.VCol(cols="4"):
483
+ vuetify.VLabel("Units:")
484
+ with vuetify.VCol(cols="8"):
485
+ vuetify.VSelect(
486
+ v_model=("angle_units", "degrees"),
487
+ items=("angle_units_items", []),
488
+ item_title="text",
489
+ item_value="value",
490
+ dense=True,
491
+ hide_details=True,
492
+ outlined=True,
493
+ )
494
+
495
+ # Save rotations button
496
+ vuetify.VBtn(
497
+ "Save Rotations",
498
+ v_if="mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
499
+ click=self.server.controller.save_rotation_angles,
500
+ small=True,
501
+ dense=True,
502
+ outlined=True,
503
+ color="success",
504
+ block=True,
505
+ classes="mb-2",
506
+ prepend_icon="mdi-content-save",
507
+ )
310
508
 
311
509
  vuetify.VDivider(classes="my-2")
312
510
 
@@ -412,10 +610,15 @@ class UI:
412
610
 
413
611
  with vuetify.VRow(justify="center", classes="my-3"):
414
612
  vuetify.VBtn(
415
- f"Capture Cine",
613
+ "Capture Cine",
416
614
  small=True,
615
+ dense=True,
616
+ outlined=True,
617
+ color="info",
618
+ block=True,
417
619
  click=self.server.controller.screenshot,
418
620
  title=f"Capture cine to {self.scene.screenshot_directory}",
621
+ prepend_icon="mdi-video",
419
622
  )
420
623
 
421
624
  vuetify.VListSubheader("Appearance and Visibility")
@@ -518,10 +721,6 @@ class UI:
518
721
 
519
722
  # Preset selection in collapsible panel
520
723
  available_presets = list_volume_property_presets()
521
- current_preset = self.server.state[f"volume_preset_{v.label}"]
522
- current_desc = available_presets.get(
523
- current_preset, current_preset
524
- )
525
724
 
526
725
  with vuetify.VExpansionPanels(
527
726
  v_model=f"preset_panel_{v.label}",