cardio 2025.10.1__py3-none-any.whl → 2025.12.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 +1 -1
- cardio/app.py +0 -2
- cardio/blend_transfer_functions.py +0 -3
- cardio/color_transfer_function.py +0 -2
- cardio/logic.py +131 -83
- cardio/mesh.py +0 -3
- cardio/object.py +0 -2
- cardio/orientation.py +1 -3
- cardio/piecewise_function.py +0 -2
- cardio/property_config.py +0 -3
- cardio/scene.py +18 -15
- cardio/segmentation.py +1 -4
- cardio/transfer_function_pair.py +0 -2
- cardio/types.py +0 -2
- cardio/ui.py +236 -90
- cardio/volume.py +161 -44
- cardio/volume_property.py +0 -2
- cardio/volume_property_presets.py +0 -3
- cardio/window_level.py +0 -2
- {cardio-2025.10.1.dist-info → cardio-2025.12.0.dist-info}/METADATA +7 -8
- cardio-2025.12.0.dist-info/RECORD +29 -0
- cardio-2025.10.1.dist-info/RECORD +0 -29
- {cardio-2025.10.1.dist-info → cardio-2025.12.0.dist-info}/WHEEL +0 -0
- {cardio-2025.10.1.dist-info → cardio-2025.12.0.dist-info}/entry_points.txt +0 -0
cardio/ui.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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 html
|
|
4
7
|
from trame.widgets import vtk as vtk_widgets
|
|
5
8
|
from trame.widgets import vuetify3 as vuetify
|
|
6
9
|
|
|
10
|
+
from .orientation import EulerAxis, euler_angle_to_rotation_matrix
|
|
7
11
|
from .scene import Scene
|
|
8
12
|
from .volume_property_presets import list_volume_property_presets
|
|
9
13
|
from .window_level import presets
|
|
@@ -37,10 +41,46 @@ class UI:
|
|
|
37
41
|
|
|
38
42
|
match event["type"]:
|
|
39
43
|
case "KeyPress":
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
key = event["key"]
|
|
45
|
+
current_time = time.time() * 1000
|
|
46
|
+
|
|
47
|
+
if key in self.last_keypress_time:
|
|
48
|
+
time_since_last = current_time - self.last_keypress_time[key]
|
|
49
|
+
if time_since_last < self.keypress_debounce_ms:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
self.last_keypress_time[key] = current_time
|
|
53
|
+
|
|
54
|
+
if key.isdigit():
|
|
55
|
+
key_num = int(key)
|
|
42
56
|
if key_num in presets.keys():
|
|
43
57
|
self.server.state.mpr_window_level_preset = key_num
|
|
58
|
+
elif key == "l":
|
|
59
|
+
current = getattr(self.server.state, "mpr_crosshairs_enabled", True)
|
|
60
|
+
self.server.state.mpr_crosshairs_enabled = not current
|
|
61
|
+
elif key == "h":
|
|
62
|
+
current = getattr(self.server.state, "help_overlay_visible", False)
|
|
63
|
+
self.server.state.help_overlay_visible = not current
|
|
64
|
+
elif key == "v":
|
|
65
|
+
current = getattr(self.server.state, "maximized_view", "")
|
|
66
|
+
self.server.state.maximized_view = (
|
|
67
|
+
"" if current == "volume" else "volume"
|
|
68
|
+
)
|
|
69
|
+
elif key == "a":
|
|
70
|
+
current = getattr(self.server.state, "maximized_view", "")
|
|
71
|
+
self.server.state.maximized_view = (
|
|
72
|
+
"" if current == "axial" else "axial"
|
|
73
|
+
)
|
|
74
|
+
elif key == "c":
|
|
75
|
+
current = getattr(self.server.state, "maximized_view", "")
|
|
76
|
+
self.server.state.maximized_view = (
|
|
77
|
+
"" if current == "coronal" else "coronal"
|
|
78
|
+
)
|
|
79
|
+
elif key == "s":
|
|
80
|
+
current = getattr(self.server.state, "maximized_view", "")
|
|
81
|
+
self.server.state.maximized_view = (
|
|
82
|
+
"" if current == "sagittal" else "sagittal"
|
|
83
|
+
)
|
|
44
84
|
|
|
45
85
|
case "LeftButtonPress":
|
|
46
86
|
self.left_dragging = True
|
|
@@ -99,32 +139,52 @@ class UI:
|
|
|
99
139
|
event["position"]["y"],
|
|
100
140
|
]
|
|
101
141
|
|
|
102
|
-
def
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
def _get_scroll_vector(self, view_name):
|
|
143
|
+
"""Get the current normal vector for a view after rotation."""
|
|
144
|
+
base_normals = {
|
|
145
|
+
"axial": np.array([0.0, 0.0, 1.0]),
|
|
146
|
+
"sagittal": np.array([1.0, 0.0, 0.0]),
|
|
147
|
+
"coronal": np.array([0.0, 1.0, 0.0]),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if view_name not in base_normals:
|
|
151
|
+
return np.array([0.0, 0.0, 1.0])
|
|
152
|
+
|
|
153
|
+
# Build cumulative rotation from visible rotations
|
|
154
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
155
|
+
cumulative_rotation = np.eye(3)
|
|
156
|
+
|
|
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]
|
|
162
|
+
rotation_matrix = euler_angle_to_rotation_matrix(
|
|
163
|
+
EulerAxis(rotation["axis"]), angle
|
|
113
164
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
bounds = getattr(
|
|
118
|
-
self.server.state, "coronal_slice_bounds", [0.0, 100.0]
|
|
119
|
-
)
|
|
120
|
-
slice_attr = "coronal_slice"
|
|
121
|
-
case _:
|
|
122
|
-
return
|
|
165
|
+
cumulative_rotation = cumulative_rotation @ rotation_matrix
|
|
166
|
+
|
|
167
|
+
return cumulative_rotation @ base_normals[view_name]
|
|
123
168
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
169
|
+
def _handle_slice_scroll(self, view_name, base_slice_delta):
|
|
170
|
+
"""Handle slice scrolling for a specific view using rotated scroll vectors."""
|
|
171
|
+
if view_name not in {"axial", "sagittal", "coronal"}:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Get current origin
|
|
175
|
+
origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
176
|
+
|
|
177
|
+
# Get scroll vector for this view (considering rotation)
|
|
178
|
+
scroll_vector = self._get_scroll_vector(view_name)
|
|
179
|
+
|
|
180
|
+
# Update origin along the rotated scroll direction
|
|
181
|
+
new_origin = [
|
|
182
|
+
origin[0] + base_slice_delta * scroll_vector[0],
|
|
183
|
+
origin[1] + base_slice_delta * scroll_vector[1],
|
|
184
|
+
origin[2] + base_slice_delta * scroll_vector[2],
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
self.server.state.mpr_origin = new_origin
|
|
128
188
|
|
|
129
189
|
def __init__(self, server, scene: Scene):
|
|
130
190
|
self.server = server
|
|
@@ -135,6 +195,10 @@ class UI:
|
|
|
135
195
|
self.window_sensitivity = 5.0
|
|
136
196
|
self.level_sensitivity = 2.0
|
|
137
197
|
self.slice_sensitivity = 1.0
|
|
198
|
+
self.last_keypress_time = {}
|
|
199
|
+
self.keypress_debounce_ms = 100
|
|
200
|
+
self.server.state.help_overlay_visible = False
|
|
201
|
+
self.server.state.maximized_view = ""
|
|
138
202
|
|
|
139
203
|
self.setup()
|
|
140
204
|
|
|
@@ -181,9 +245,9 @@ class UI:
|
|
|
181
245
|
)
|
|
182
246
|
|
|
183
247
|
with layout.content:
|
|
184
|
-
# Single VR view (
|
|
248
|
+
# Single VR view (volume maximized)
|
|
185
249
|
with vuetify.VContainer(
|
|
186
|
-
v_if="
|
|
250
|
+
v_if="maximized_view === 'volume'",
|
|
187
251
|
fluid=True,
|
|
188
252
|
classes="pa-0 fill-height",
|
|
189
253
|
):
|
|
@@ -197,9 +261,9 @@ class UI:
|
|
|
197
261
|
self.server.controller.view_reset_camera = view.reset_camera
|
|
198
262
|
self.server.controller.on_server_ready.add(view.update)
|
|
199
263
|
|
|
200
|
-
# Quad-view layout (MPR mode
|
|
264
|
+
# Quad-view layout (MPR mode - default)
|
|
201
265
|
with vuetify.VContainer(
|
|
202
|
-
v_if="
|
|
266
|
+
v_if="!maximized_view",
|
|
203
267
|
fluid=True,
|
|
204
268
|
classes="pa-0",
|
|
205
269
|
style="height: calc(100vh - 85px);",
|
|
@@ -263,6 +327,10 @@ class UI:
|
|
|
263
327
|
self.server.controller.on_server_ready.add(
|
|
264
328
|
self._update_all_mpr_views
|
|
265
329
|
)
|
|
330
|
+
# Finalize MPR initialization after UI is ready to avoid race condition
|
|
331
|
+
self.server.controller.on_server_ready.add(
|
|
332
|
+
self.server.controller.finalize_mpr_initialization
|
|
333
|
+
)
|
|
266
334
|
|
|
267
335
|
# Store individual view update functions
|
|
268
336
|
self.server.controller.axial_update = axial_view.update
|
|
@@ -270,21 +338,131 @@ class UI:
|
|
|
270
338
|
self.server.controller.sagittal_update = sagittal_view.update
|
|
271
339
|
self.server.controller.volume_update = volume_view.update
|
|
272
340
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
341
|
+
# Maximized axial view
|
|
342
|
+
with vuetify.VContainer(
|
|
343
|
+
v_if="maximized_view === 'axial'",
|
|
344
|
+
fluid=True,
|
|
345
|
+
classes="pa-0 fill-height",
|
|
346
|
+
):
|
|
347
|
+
axial_maximized_view = vtk_widgets.VtkRemoteView(
|
|
348
|
+
self.scene.axial_renderWindow,
|
|
349
|
+
interactor_events=("event_types", self.handled_events),
|
|
350
|
+
**self.event_listeners_for_view("axial"),
|
|
351
|
+
interactive_ratio=1,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Maximized coronal view
|
|
355
|
+
with vuetify.VContainer(
|
|
356
|
+
v_if="maximized_view === 'coronal'",
|
|
357
|
+
fluid=True,
|
|
358
|
+
classes="pa-0 fill-height",
|
|
359
|
+
):
|
|
360
|
+
coronal_maximized_view = vtk_widgets.VtkRemoteView(
|
|
361
|
+
self.scene.coronal_renderWindow,
|
|
362
|
+
interactor_events=("event_types", self.handled_events),
|
|
363
|
+
**self.event_listeners_for_view("coronal"),
|
|
364
|
+
interactive_ratio=1,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Maximized sagittal view
|
|
368
|
+
with vuetify.VContainer(
|
|
369
|
+
v_if="maximized_view === 'sagittal'",
|
|
370
|
+
fluid=True,
|
|
371
|
+
classes="pa-0 fill-height",
|
|
372
|
+
):
|
|
373
|
+
sagittal_maximized_view = vtk_widgets.VtkRemoteView(
|
|
374
|
+
self.scene.sagittal_renderWindow,
|
|
375
|
+
interactor_events=("event_types", self.handled_events),
|
|
376
|
+
**self.event_listeners_for_view("sagittal"),
|
|
377
|
+
interactive_ratio=1,
|
|
283
378
|
)
|
|
284
379
|
|
|
285
|
-
|
|
380
|
+
with vuetify.VDialog(
|
|
381
|
+
v_model=("help_overlay_visible", False),
|
|
382
|
+
max_width="700px",
|
|
383
|
+
scrim="rgba(0, 0, 0, 0.7)",
|
|
384
|
+
):
|
|
385
|
+
with vuetify.VCard(
|
|
386
|
+
classes="pa-6",
|
|
387
|
+
style="background: rgba(33, 33, 33, 0.95);",
|
|
388
|
+
):
|
|
389
|
+
vuetify.VCardTitle(
|
|
390
|
+
"Keyboard Shortcuts & Controls", classes="text-h5 mb-4"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
with vuetify.VCardText():
|
|
394
|
+
html.H3("Keyboard Shortcuts", classes="text-h6 mb-3")
|
|
395
|
+
with vuetify.VTable(density="compact", classes="mb-4"):
|
|
396
|
+
with html.Thead():
|
|
397
|
+
with html.Tr():
|
|
398
|
+
html.Th("Key")
|
|
399
|
+
html.Th("Action")
|
|
400
|
+
with html.Tbody():
|
|
401
|
+
with html.Tr():
|
|
402
|
+
html.Td("h")
|
|
403
|
+
html.Td("Toggle this help window")
|
|
404
|
+
with html.Tr():
|
|
405
|
+
html.Td("v")
|
|
406
|
+
html.Td("Toggle 3D volume view")
|
|
407
|
+
with html.Tr():
|
|
408
|
+
html.Td("a")
|
|
409
|
+
html.Td("Toggle axial view")
|
|
410
|
+
with html.Tr():
|
|
411
|
+
html.Td("c")
|
|
412
|
+
html.Td("Toggle coronal view")
|
|
413
|
+
with html.Tr():
|
|
414
|
+
html.Td("s")
|
|
415
|
+
html.Td("Toggle sagittal view")
|
|
416
|
+
with html.Tr():
|
|
417
|
+
html.Td("l")
|
|
418
|
+
html.Td("Toggle crosshairs")
|
|
419
|
+
|
|
420
|
+
html.H3("Window/Level Presets", classes="text-h6 mb-3")
|
|
421
|
+
with vuetify.VTable(density="compact", classes="mb-4"):
|
|
422
|
+
with html.Thead():
|
|
423
|
+
with html.Tr():
|
|
424
|
+
html.Th("Key")
|
|
425
|
+
html.Th("Preset")
|
|
426
|
+
html.Th("Window")
|
|
427
|
+
html.Th("Level")
|
|
428
|
+
with html.Tbody():
|
|
429
|
+
for key, preset in presets.items():
|
|
430
|
+
with html.Tr():
|
|
431
|
+
html.Td(str(key))
|
|
432
|
+
html.Td(preset.name)
|
|
433
|
+
html.Td(str(preset.window))
|
|
434
|
+
html.Td(str(preset.level))
|
|
435
|
+
|
|
436
|
+
html.H3("Mouse Controls (MPR Mode)", classes="text-h6 mb-3")
|
|
437
|
+
with vuetify.VTable(density="compact"):
|
|
438
|
+
with html.Thead():
|
|
439
|
+
with html.Tr():
|
|
440
|
+
html.Th("Action")
|
|
441
|
+
html.Th("Effect")
|
|
442
|
+
with html.Tbody():
|
|
443
|
+
with html.Tr():
|
|
444
|
+
html.Td("Left Drag ←/→")
|
|
445
|
+
html.Td("Narrow/widen window")
|
|
446
|
+
with html.Tr():
|
|
447
|
+
html.Td("Left Drag ↑/↓")
|
|
448
|
+
html.Td("Increase/decrease level")
|
|
449
|
+
with html.Tr():
|
|
450
|
+
html.Td("Right Drag ↑/↓")
|
|
451
|
+
html.Td("Scroll through slices")
|
|
452
|
+
|
|
453
|
+
with vuetify.VCardActions():
|
|
454
|
+
vuetify.VSpacer()
|
|
455
|
+
vuetify.VBtn(
|
|
456
|
+
"Close",
|
|
457
|
+
click="help_overlay_visible = false",
|
|
458
|
+
variant="text",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
with layout.drawer:
|
|
462
|
+
# Volume selection dropdown
|
|
463
|
+
if self.scene.volumes:
|
|
286
464
|
vuetify.VSelect(
|
|
287
|
-
v_if="
|
|
465
|
+
v_if="!maximized_view || maximized_view === 'volume'",
|
|
288
466
|
v_model=("active_volume_label", ""),
|
|
289
467
|
items=("volume_items", []),
|
|
290
468
|
item_title="text",
|
|
@@ -296,11 +474,11 @@ class UI:
|
|
|
296
474
|
|
|
297
475
|
# Window/Level controls for MPR
|
|
298
476
|
vuetify.VListSubheader(
|
|
299
|
-
"Window/Level", v_if="
|
|
477
|
+
"Window/Level", v_if="!maximized_view && active_volume_label"
|
|
300
478
|
)
|
|
301
479
|
|
|
302
480
|
vuetify.VSelect(
|
|
303
|
-
v_if="
|
|
481
|
+
v_if="!maximized_view && active_volume_label",
|
|
304
482
|
v_model=("mpr_window_level_preset", 7),
|
|
305
483
|
items=("mpr_presets", []),
|
|
306
484
|
item_title="text",
|
|
@@ -311,7 +489,7 @@ class UI:
|
|
|
311
489
|
)
|
|
312
490
|
|
|
313
491
|
vuetify.VSlider(
|
|
314
|
-
v_if="
|
|
492
|
+
v_if="!maximized_view && active_volume_label",
|
|
315
493
|
v_model="mpr_window",
|
|
316
494
|
min=1.0,
|
|
317
495
|
max=2000.0,
|
|
@@ -324,7 +502,7 @@ class UI:
|
|
|
324
502
|
)
|
|
325
503
|
|
|
326
504
|
vuetify.VSlider(
|
|
327
|
-
v_if="
|
|
505
|
+
v_if="!maximized_view && active_volume_label",
|
|
328
506
|
v_model="mpr_level",
|
|
329
507
|
min=-1000.0,
|
|
330
508
|
max=1000.0,
|
|
@@ -336,55 +514,23 @@ class UI:
|
|
|
336
514
|
thumb_label=True,
|
|
337
515
|
)
|
|
338
516
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
"
|
|
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,
|
|
517
|
+
vuetify.VCheckbox(
|
|
518
|
+
v_if="!maximized_view && active_volume_label",
|
|
519
|
+
v_model=("mpr_crosshairs_enabled", True),
|
|
520
|
+
label="Show Crosshairs",
|
|
375
521
|
dense=True,
|
|
376
|
-
hide_details=
|
|
377
|
-
|
|
522
|
+
hide_details=True,
|
|
523
|
+
classes="mt-2",
|
|
378
524
|
)
|
|
379
525
|
|
|
380
526
|
# MPR Rotation controls
|
|
381
527
|
vuetify.VListSubheader(
|
|
382
|
-
"Rotations", v_if="
|
|
528
|
+
"Rotations", v_if="!maximized_view && active_volume_label"
|
|
383
529
|
)
|
|
384
530
|
|
|
385
531
|
# Rotation buttons
|
|
386
532
|
with vuetify.VRow(
|
|
387
|
-
v_if="
|
|
533
|
+
v_if="!maximized_view && active_volume_label",
|
|
388
534
|
no_gutters=True,
|
|
389
535
|
classes="mb-2",
|
|
390
536
|
):
|
|
@@ -419,7 +565,7 @@ class UI:
|
|
|
419
565
|
# Reset rotations button
|
|
420
566
|
vuetify.VBtn(
|
|
421
567
|
"Reset",
|
|
422
|
-
v_if="
|
|
568
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
|
|
423
569
|
click=self.server.controller.reset_rotations,
|
|
424
570
|
small=True,
|
|
425
571
|
dense=True,
|
|
@@ -433,7 +579,7 @@ class UI:
|
|
|
433
579
|
# Individual rotation sliders
|
|
434
580
|
for i in range(self.scene.max_mpr_rotations):
|
|
435
581
|
with vuetify.VRow(
|
|
436
|
-
v_if=f"
|
|
582
|
+
v_if=f"!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
|
|
437
583
|
no_gutters=True,
|
|
438
584
|
classes="align-center mb-1",
|
|
439
585
|
):
|
|
@@ -475,7 +621,7 @@ class UI:
|
|
|
475
621
|
|
|
476
622
|
# Angle units selector
|
|
477
623
|
with vuetify.VRow(
|
|
478
|
-
v_if="
|
|
624
|
+
v_if="!maximized_view && active_volume_label",
|
|
479
625
|
no_gutters=True,
|
|
480
626
|
classes="align-center mb-2 mt-2",
|
|
481
627
|
):
|
|
@@ -495,7 +641,7 @@ class UI:
|
|
|
495
641
|
# Save rotations button
|
|
496
642
|
vuetify.VBtn(
|
|
497
643
|
"Save Rotations",
|
|
498
|
-
v_if="
|
|
644
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
|
|
499
645
|
click=self.server.controller.save_rotation_angles,
|
|
500
646
|
small=True,
|
|
501
647
|
dense=True,
|