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/ui.py CHANGED
@@ -1,9 +1,14 @@
1
1
  import functools as ft
2
+ import time
2
3
 
4
+ import numpy as np
3
5
  from trame.ui.vuetify3 import SinglePageWithDrawerLayout
6
+ from trame.widgets import client
7
+ from trame.widgets import html
4
8
  from trame.widgets import vtk as vtk_widgets
5
9
  from trame.widgets import vuetify3 as vuetify
6
10
 
11
+ from .orientation import AngleUnits, EulerAxis, euler_angle_to_rotation_matrix
7
12
  from .scene import Scene
8
13
  from .volume_property_presets import list_volume_property_presets
9
14
  from .window_level import presets
@@ -37,10 +42,46 @@ class UI:
37
42
 
38
43
  match event["type"]:
39
44
  case "KeyPress":
40
- if event["key"].isdigit():
41
- key_num = int(event["key"])
45
+ key = event["key"]
46
+ current_time = time.time() * 1000
47
+
48
+ if key in self.last_keypress_time:
49
+ time_since_last = current_time - self.last_keypress_time[key]
50
+ if time_since_last < self.keypress_debounce_ms:
51
+ return
52
+
53
+ self.last_keypress_time[key] = current_time
54
+
55
+ if key.isdigit():
56
+ key_num = int(key)
42
57
  if key_num in presets.keys():
43
58
  self.server.state.mpr_window_level_preset = key_num
59
+ elif key == "l":
60
+ current = getattr(self.server.state, "mpr_crosshairs_enabled", True)
61
+ self.server.state.mpr_crosshairs_enabled = not current
62
+ elif key == "h":
63
+ current = getattr(self.server.state, "help_overlay_visible", False)
64
+ self.server.state.help_overlay_visible = not current
65
+ elif key == "v":
66
+ current = getattr(self.server.state, "maximized_view", "")
67
+ self.server.state.maximized_view = (
68
+ "" if current == "volume" else "volume"
69
+ )
70
+ elif key == "a":
71
+ current = getattr(self.server.state, "maximized_view", "")
72
+ self.server.state.maximized_view = (
73
+ "" if current == "axial" else "axial"
74
+ )
75
+ elif key == "c":
76
+ current = getattr(self.server.state, "maximized_view", "")
77
+ self.server.state.maximized_view = (
78
+ "" if current == "coronal" else "coronal"
79
+ )
80
+ elif key == "s":
81
+ current = getattr(self.server.state, "maximized_view", "")
82
+ self.server.state.maximized_view = (
83
+ "" if current == "sagittal" else "sagittal"
84
+ )
44
85
 
45
86
  case "LeftButtonPress":
46
87
  self.left_dragging = True
@@ -99,32 +140,57 @@ class UI:
99
140
  event["position"]["y"],
100
141
  ]
101
142
 
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]
143
+ def _get_scroll_vector(self, view_name):
144
+ """Get the current normal vector for a view after rotation."""
145
+ base_normals = {
146
+ "axial": np.array([0.0, 0.0, 1.0]),
147
+ "sagittal": np.array([1.0, 0.0, 0.0]),
148
+ "coronal": np.array([0.0, 1.0, 0.0]),
149
+ }
150
+
151
+ if view_name not in base_normals:
152
+ return np.array([0.0, 0.0, 1.0])
153
+
154
+ # Build cumulative rotation from visible rotations
155
+ rotation_data = getattr(
156
+ self.server.state, "mpr_rotation_data", {"angles_list": []}
157
+ )
158
+ angles_list = rotation_data.get("angles_list", [])
159
+ cumulative_rotation = np.eye(3)
160
+
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]
168
+ rotation_matrix = euler_angle_to_rotation_matrix(
169
+ EulerAxis(rotation["axes"]), angle, angle_units
119
170
  )
120
- slice_attr = "coronal_slice"
121
- case _:
122
- return
171
+ cumulative_rotation = cumulative_rotation @ rotation_matrix
172
+
173
+ return cumulative_rotation @ base_normals[view_name]
174
+
175
+ def _handle_slice_scroll(self, view_name, base_slice_delta):
176
+ """Handle slice scrolling for a specific view using rotated scroll vectors."""
177
+ if view_name not in {"axial", "sagittal", "coronal"}:
178
+ return
123
179
 
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)
180
+ # Get current origin
181
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
182
+
183
+ # Get scroll vector for this view (considering rotation)
184
+ scroll_vector = self._get_scroll_vector(view_name)
185
+
186
+ # Update origin along the rotated scroll direction
187
+ new_origin = [
188
+ origin[0] + base_slice_delta * scroll_vector[0],
189
+ origin[1] + base_slice_delta * scroll_vector[1],
190
+ origin[2] + base_slice_delta * scroll_vector[2],
191
+ ]
192
+
193
+ self.server.state.mpr_origin = new_origin
128
194
 
129
195
  def __init__(self, server, scene: Scene):
130
196
  self.server = server
@@ -135,6 +201,10 @@ class UI:
135
201
  self.window_sensitivity = 5.0
136
202
  self.level_sensitivity = 2.0
137
203
  self.slice_sensitivity = 1.0
204
+ self.last_keypress_time = {}
205
+ self.keypress_debounce_ms = 100
206
+ self.server.state.help_overlay_visible = False
207
+ self.server.state.maximized_view = ""
138
208
 
139
209
  self.setup()
140
210
 
@@ -181,9 +251,9 @@ class UI:
181
251
  )
182
252
 
183
253
  with layout.content:
184
- # Single VR view (default mode)
254
+ # Single VR view (volume maximized)
185
255
  with vuetify.VContainer(
186
- v_if="!mpr_enabled",
256
+ v_if="maximized_view === 'volume'",
187
257
  fluid=True,
188
258
  classes="pa-0 fill-height",
189
259
  ):
@@ -197,9 +267,9 @@ class UI:
197
267
  self.server.controller.view_reset_camera = view.reset_camera
198
268
  self.server.controller.on_server_ready.add(view.update)
199
269
 
200
- # Quad-view layout (MPR mode) - directly in content like app.py
270
+ # Quad-view layout (MPR mode - default)
201
271
  with vuetify.VContainer(
202
- v_if="mpr_enabled",
272
+ v_if="!maximized_view",
203
273
  fluid=True,
204
274
  classes="pa-0",
205
275
  style="height: calc(100vh - 85px);",
@@ -263,6 +333,10 @@ class UI:
263
333
  self.server.controller.on_server_ready.add(
264
334
  self._update_all_mpr_views
265
335
  )
336
+ # Finalize MPR initialization after UI is ready to avoid race condition
337
+ self.server.controller.on_server_ready.add(
338
+ self.server.controller.finalize_mpr_initialization
339
+ )
266
340
 
267
341
  # Store individual view update functions
268
342
  self.server.controller.axial_update = axial_view.update
@@ -270,121 +344,148 @@ class UI:
270
344
  self.server.controller.sagittal_update = sagittal_view.update
271
345
  self.server.controller.volume_update = volume_view.update
272
346
 
273
- with layout.drawer:
274
- # MPR Mode Toggle (only show when volumes are present)
275
- if self.scene.volumes:
276
- vuetify.VListSubheader("View Mode")
277
- vuetify.VCheckbox(
278
- v_model=("mpr_enabled", False),
279
- label="Multi-Planar Reconstruction (MPR)",
280
- title="Toggle between single 3D view and quad-view with slice planes",
281
- dense=True,
282
- hide_details=True,
347
+ # Maximized axial view
348
+ with vuetify.VContainer(
349
+ v_if="maximized_view === 'axial'",
350
+ fluid=True,
351
+ classes="pa-0 fill-height",
352
+ ):
353
+ axial_maximized_view = vtk_widgets.VtkRemoteView(
354
+ self.scene.axial_renderWindow,
355
+ interactor_events=("event_types", self.handled_events),
356
+ **self.event_listeners_for_view("axial"),
357
+ interactive_ratio=1,
283
358
  )
284
359
 
285
- # Volume selection dropdown (only show when MPR is enabled)
286
- vuetify.VSelect(
287
- v_if="mpr_enabled",
288
- v_model=("active_volume_label", ""),
289
- items=("volume_items", []),
290
- item_title="text",
291
- item_value="value",
292
- title="Select which volume to use for MPR",
293
- dense=True,
294
- hide_details=True,
360
+ # Maximized coronal view
361
+ with vuetify.VContainer(
362
+ v_if="maximized_view === 'coronal'",
363
+ fluid=True,
364
+ classes="pa-0 fill-height",
365
+ ):
366
+ coronal_maximized_view = vtk_widgets.VtkRemoteView(
367
+ self.scene.coronal_renderWindow,
368
+ interactor_events=("event_types", self.handled_events),
369
+ **self.event_listeners_for_view("coronal"),
370
+ interactive_ratio=1,
295
371
  )
296
372
 
297
- # Window/Level controls for MPR
298
- vuetify.VListSubheader(
299
- "Window/Level", v_if="mpr_enabled && active_volume_label"
373
+ # Maximized sagittal view
374
+ with vuetify.VContainer(
375
+ v_if="maximized_view === 'sagittal'",
376
+ fluid=True,
377
+ classes="pa-0 fill-height",
378
+ ):
379
+ sagittal_maximized_view = vtk_widgets.VtkRemoteView(
380
+ self.scene.sagittal_renderWindow,
381
+ interactor_events=("event_types", self.handled_events),
382
+ **self.event_listeners_for_view("sagittal"),
383
+ interactive_ratio=1,
300
384
  )
301
385
 
386
+ with vuetify.VDialog(
387
+ v_model=("help_overlay_visible", False),
388
+ max_width="700px",
389
+ scrim="rgba(0, 0, 0, 0.7)",
390
+ ):
391
+ with vuetify.VCard(
392
+ classes="pa-6",
393
+ style="background: rgba(33, 33, 33, 0.95);",
394
+ ):
395
+ vuetify.VCardTitle(
396
+ "Keyboard Shortcuts & Controls", classes="text-h5 mb-4"
397
+ )
398
+
399
+ with vuetify.VCardText():
400
+ html.H3("Keyboard Shortcuts", classes="text-h6 mb-3")
401
+ with vuetify.VTable(density="compact", classes="mb-4"):
402
+ with html.Thead():
403
+ with html.Tr():
404
+ html.Th("Key")
405
+ html.Th("Action")
406
+ with html.Tbody():
407
+ with html.Tr():
408
+ html.Td("h")
409
+ html.Td("Toggle this help window")
410
+ with html.Tr():
411
+ html.Td("v")
412
+ html.Td("Toggle 3D volume view")
413
+ with html.Tr():
414
+ html.Td("a")
415
+ html.Td("Toggle axial view")
416
+ with html.Tr():
417
+ html.Td("c")
418
+ html.Td("Toggle coronal view")
419
+ with html.Tr():
420
+ html.Td("s")
421
+ html.Td("Toggle sagittal view")
422
+ with html.Tr():
423
+ html.Td("l")
424
+ html.Td("Toggle crosshairs")
425
+
426
+ html.H3("Window/Level Presets", classes="text-h6 mb-3")
427
+ with vuetify.VTable(density="compact", classes="mb-4"):
428
+ with html.Thead():
429
+ with html.Tr():
430
+ html.Th("Key")
431
+ html.Th("Preset")
432
+ html.Th("Window")
433
+ html.Th("Level")
434
+ with html.Tbody():
435
+ for key, preset in presets.items():
436
+ with html.Tr():
437
+ html.Td(str(key))
438
+ html.Td(preset.name)
439
+ html.Td(str(preset.window))
440
+ html.Td(str(preset.level))
441
+
442
+ html.H3("Mouse Controls (MPR Mode)", classes="text-h6 mb-3")
443
+ with vuetify.VTable(density="compact"):
444
+ with html.Thead():
445
+ with html.Tr():
446
+ html.Th("Action")
447
+ html.Th("Effect")
448
+ with html.Tbody():
449
+ with html.Tr():
450
+ html.Td("Left Drag ←/→")
451
+ html.Td("Narrow/widen window")
452
+ with html.Tr():
453
+ html.Td("Left Drag ↑/↓")
454
+ html.Td("Increase/decrease level")
455
+ with html.Tr():
456
+ html.Td("Right Drag ↑/↓")
457
+ html.Td("Scroll through slices")
458
+
459
+ with vuetify.VCardActions():
460
+ vuetify.VSpacer()
461
+ vuetify.VBtn(
462
+ "Close",
463
+ click="help_overlay_visible = false",
464
+ variant="text",
465
+ )
466
+
467
+ with layout.drawer:
468
+ # Volume selection dropdown
469
+ if self.scene.volumes:
302
470
  vuetify.VSelect(
303
- v_if="mpr_enabled && active_volume_label",
304
- v_model=("mpr_window_level_preset", 7),
305
- items=("mpr_presets", []),
471
+ v_if="(!maximized_view || maximized_view === 'volume') && volume_items.length >= 2",
472
+ v_model=("active_volume_label", ""),
473
+ items=("volume_items", []),
306
474
  item_title="text",
307
475
  item_value="value",
308
- label="Preset",
476
+ title="Select which volume to use for MPR",
309
477
  dense=True,
310
478
  hide_details=True,
311
479
  )
312
480
 
313
- vuetify.VSlider(
314
- v_if="mpr_enabled && active_volume_label",
315
- v_model="mpr_window",
316
- min=1.0,
317
- max=2000.0,
318
- step=1.0,
319
- hint="Window",
320
- persistent_hint=True,
321
- dense=True,
322
- hide_details=False,
323
- thumb_label=True,
324
- )
325
-
326
- vuetify.VSlider(
327
- v_if="mpr_enabled && active_volume_label",
328
- v_model="mpr_level",
329
- min=-1000.0,
330
- max=1000.0,
331
- step=1.0,
332
- hint="Level",
333
- persistent_hint=True,
334
- dense=True,
335
- hide_details=False,
336
- thumb_label=True,
337
- )
338
-
339
- # Slice position controls (only show when MPR is enabled and volume is selected)
340
- vuetify.VListSubheader(
341
- "Slice Positions", v_if="mpr_enabled && active_volume_label"
342
- )
343
-
344
- vuetify.VSlider(
345
- v_if="mpr_enabled && active_volume_label",
346
- v_model=("axial_slice", 0.0),
347
- min=("axial_slice_bounds[0]", 0.0),
348
- max=("axial_slice_bounds[1]", 100.0),
349
- hint="A (I ↔ S)",
350
- persistent_hint=True,
351
- dense=True,
352
- hide_details=False,
353
- thumb_label=True,
354
- )
355
-
356
- vuetify.VSlider(
357
- v_if="mpr_enabled && active_volume_label",
358
- v_model=("sagittal_slice", 0.0),
359
- min=("sagittal_slice_bounds[0]", 0.0),
360
- max=("sagittal_slice_bounds[1]", 100.0),
361
- hint="S (R ↔ L)",
362
- persistent_hint=True,
363
- dense=True,
364
- hide_details=False,
365
- thumb_label=True,
366
- )
367
-
368
- vuetify.VSlider(
369
- v_if="mpr_enabled && active_volume_label",
370
- v_model=("coronal_slice", 0.0),
371
- min=("coronal_slice_bounds[0]", 0.0),
372
- max=("coronal_slice_bounds[1]", 100.0),
373
- hint="C (P ↔ A)",
374
- persistent_hint=True,
375
- dense=True,
376
- hide_details=False,
377
- thumb_label=True,
378
- )
379
-
380
481
  # MPR Rotation controls
381
482
  vuetify.VListSubheader(
382
- "Rotations", v_if="mpr_enabled && active_volume_label"
483
+ "Rotations", v_if="!maximized_view && active_volume_label"
383
484
  )
384
485
 
385
486
  # Rotation buttons
386
487
  with vuetify.VRow(
387
- v_if="mpr_enabled && active_volume_label",
488
+ v_if="!maximized_view && active_volume_label",
388
489
  no_gutters=True,
389
490
  classes="mb-2",
390
491
  ):
@@ -418,8 +519,8 @@ class UI:
418
519
 
419
520
  # Reset rotations button
420
521
  vuetify.VBtn(
421
- "Reset",
422
- v_if="mpr_enabled && 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",
423
524
  click=self.server.controller.reset_rotations,
424
525
  small=True,
425
526
  dense=True,
@@ -430,52 +531,109 @@ class UI:
430
531
  prepend_icon="mdi-refresh",
431
532
  )
432
533
 
433
- # Individual rotation sliders
434
- for i in range(self.scene.max_mpr_rotations):
435
- with vuetify.VRow(
436
- v_if=f"mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
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
- )
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()
475
633
 
476
634
  # Angle units selector
477
635
  with vuetify.VRow(
478
- v_if="mpr_enabled && active_volume_label",
636
+ v_if="!maximized_view && active_volume_label",
479
637
  no_gutters=True,
480
638
  classes="align-center mb-2 mt-2",
481
639
  ):
@@ -483,7 +641,7 @@ class UI:
483
641
  vuetify.VLabel("Units:")
484
642
  with vuetify.VCol(cols="8"):
485
643
  vuetify.VSelect(
486
- v_model=("angle_units", "degrees"),
644
+ v_model=("angle_units", "radians"),
487
645
  items=("angle_units_items", []),
488
646
  item_title="text",
489
647
  item_value="value",
@@ -492,10 +650,29 @@ class UI:
492
650
  outlined=True,
493
651
  )
494
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
+
495
672
  # Save rotations button
496
673
  vuetify.VBtn(
497
674
  "Save Rotations",
498
- v_if="mpr_enabled && 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",
499
676
  click=self.server.controller.save_rotation_angles,
500
677
  small=True,
501
678
  dense=True,
cardio/utils.py CHANGED
@@ -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