Skip to content

visualdynamics.gui.main_window

main_window

The main application window: the project tree, the 3-D, plot and table views beside it, and the display unit system.

Classes:

Name Description
MainWindow

The application window: a project tree, and panes that show

Classes

MainWindow

MainWindow(offscreen_3d: bool = False)

Bases: QMainWindow

The application window: a project tree, and panes that show whatever is selected in it.

The window holds a Project, the same object a script builds, with the same verbs on it, so a project built in the window and one built by a script are the same project and each opens the other's files. Every button runs one of those verbs and writes a line in the status bar. Nothing is computed in this file that a script cannot compute without it.

What the window adds is the display: which pane suits what is selected, which reading of it is wanted, and the editing of the things that are edited rather than computed, such as units, channel tables, geometry and reports.

Build the window, with an empty project.

offscreen_3d replaces the embedded VTK views with off-screen plotters. VTK's Qt widget cannot run under Qt's offscreen platform, so the headless tests use this to run the full render path.

Methods:

Name Description
set_playing

Start or stop the animation.

choose_appearance

File → Appearance: remember choice ('light', 'dark' or

apply_theme

Apply the light or dark theme name to every part of the

closeEvent

Close every VTK view (the scene, the 3-D data view and the MAC

show_run_log

Show the Run Log dock, built the first time and put back where

set_log_only

Show the Run Log alone, filling the window, with the project

add_object

Add obj to the project under a unique name, give it a row in

show_object

Give an object already in the project its row in the tree, and

add_solid_block_act

Open the add pane for a solid block in the selected geometry:

add_solid_cylinder_act

Open the add pane for a solid cylinder in the selected

add_solid_section_act

Open the add pane for a solid section in the selected

add_solid_cone_act

Open the add pane for a cone in the selected geometry: a

add_solid_sphere_act

Open the add pane for a sphere in the selected geometry: a

add_solid_plate_act

Open the add pane for a plate in the selected geometry: a

add_solid_disk_act

Open the add pane for a disk in the selected geometry: a

combine_solids_act

Combine the picked solids into one by operation ('union',

separate_solids_act

Take the picked booleans and patterns apart, with the

mesh_solids_act

Open the Mesh Solids pane on the picked solids.

close_mesh_solids_pane

Close the Mesh Solids pane and remove its preview. With

pattern_solids_act

Open the Pattern pane on the picked solids.

close_pattern_pane

Close the Pattern pane and remove its preview. With redraw,

current_reference

(kind, object, detail) for the current tree item, where

current_object

The object the current tree item belongs to (a sub-item gives

selected_references

(kind, name, object, detail) for every selected tree item.

object_item

The object's row for item (the current item by default),

delete_selected

Delete what is selected.

acts_for

The acts the selection can take, as a list of `(verb, label,

save_animation

Save the 3-D view's animation as an MP4, the way it plays: this

refresh_selected

Recompute the selected stale object. This is the bar's

generate_report_act

The bar's Generate Report.

refresh_object

Recompute the stale object name in place, as a click on its

linked_group

The names in name's object group, name included, or None

link_role

The role of name's object group: 'Basis', or None for an

set_link_role

Give name's object group the role role ('Basis'), or clear

linked_geometry

(geometry name, geometry) for the geometry in name's object

unlink_selected

Take the selected objects out of their object groups. A group

set_project_type

Set the project type, or clear it with None.

work_up

The tree bar's Automatic: run the project's work_up, give

delete_entity_rows

Delete the rows selected in the geometry table being edited.

define_units

Open the units pane for what is selected.

set_active_geometry

Make name (the selected object by default) the active

refresh_compatibility

Check the project's objects against the active geometry again,

show_units_panel

Open the units pane on the object name, beside the plot.

rename_selected

Start editing the selected object's name in the tree.

rename_object

Rename the object old to new from code, and return the name

edit_entities

Edit the selected part of a geometry in a table beside the 3-D

set_add_mode

Turn on or off adding entities by clicking in the 3-D view,

merge_groups_act

Merge element groups groups of geometry name with the

tie_selected

Tie the selected elements, either to the nearest nodes of an

set_rotate_mode

Turn on or off the rings for turning the coordinate system

reset_rotation

Set the coordinate system being turned back to no rotation,

hover_at

Light up the entity under the pixel (x, y), and return it

select_entity

Select the table row for an entity picked in the 3-D view, and

about

Show the About box: the version, the copyright, and the alpha

check_for_updates

File → Check for Updates.

dragEnterEvent

Accept a drag of files anywhere on the window.

event

Move keyboard focus to a hidden widget while the window is

truncate_rattlesnake_file

File → Truncate Rattlesnake File.

import_paths

Import several files, report any failures once at the end, and

save_test

Save the whole project, every object under its name, as one

copy_selected

Cmd/Ctrl+C in the tree: copy the selected objects to the

paste_objects

Cmd/Ctrl+V in the tree: paste whatever the clipboard holds

save_selected

Save the selected object, in the format chosen in the save

render_current

Draw everything selected: geometries are drawn together, and

name_object_group

Name the object group member is in, and show the name on its

add_matches

Add the selected MAC squares to the matched modes of this pair

generate_signal_act

Open Generate Signal on the selected object, or put it away

close_signal_pane

Put the Generate Signal pane away, keeping what it showed.

modal_response_act

Open Compute Modal Responses on the selected time history or

close_modal_pane

Put the Compute Modal Responses pane away.

make_realizable

Repair the selected CSD or specification to the nearest

make_field_specification

Write the specification the Field Limit reading shows into the

compute_spectra

Compute averaged spectra from the selected time history, one

project_onto_basis

Project the other selected mode set onto the Basis set's DOFs,

export_state_space_act

Open Export State-Space Model on the selected shape set and

close_state_space_pane

Put the Export State-Space Model pane away.

transform_act

The bar's Transform to Modal Coordinates.

expand_act

The bar's Expand to Physical Responses.

transform_selection

Transform the selected data through the selected shape set,

generate_typed_report

Generate the reports the project's type calls for: one, or for

extract_sine_levels

Extract each specification tone's level from the time history

compute_psds

Compute averaged auto-power spectral densities from the

compute_octave

Compute the selected PSD, CPSD or specification on proportional

compute_frfs

Compute FRFs from the selected time history, and add them

compute_multiple_coherence

Compute the multiple coherence of the selected time history,

compute_srs

Compute shock response spectra from the selected time history,

filter_data

Filter every channel of the selected time history and add the

truncate_data

Cut every channel of the selected time history to its span, and

resample_data

Take the selected time history to the lower rate the resample

generate_rigid_body_modes

Make the six rigid-body mode shapes of the selected geometry,

mate_surfaces_act

Open Mate Surfaces on element groups groups of geometry

edit_mated_surfaces_act

Open Mate Surfaces on a kept set, to change its nodes, how it

mate_node_at

The node of the picked patch's side being picked whose place is

toggle_mate_node

Add node to the picked patch's side being picked, or take it

close_mate_pane

Close the Mate Surfaces pane, its marks and its clicks.

plan_instrumentation_act

Open Plan Instrumentation on the selected shape set, on the

plan_surface_at

The surface under the device pixel (x, y) that a click can

toggle_plan_surface

Leave surface out of the places sensors may go, or put it

close_instrumentation_pane

Put Plan Instrumentation away; nothing is added.

merge_nodes_act

Open the Merge Coincident Nodes pane on element groups

align_meshes_act

Open Align Meshes, in the same pane as the merge, on element

toggle_merge_place

Leave place index out of the merge, or put it back, as a

merge_place_at

The index of the place whose mark is under the pixel

close_merge_pane

Close the merge pane, removing its marks and its handling of

new_geometry_act

Add an empty geometry to the project, in the display length

new_channel_table_act

Add an empty channel table to the project and select it. Its

add_channel_act

Add a blank channel to the selected channel table, numbered

edit_solid_pane

Open the add pane on solid solid_id of geometry name, as its

close_mesh_pane

Close the add pane, removing its preview and the gizmo.

set_creator_name

File → Creator Name…: set the name an exported ESCDF file

set_view_act

Make the view on screen the selected geometry's default view.

solve_modes_act

Solve the normal modes of the selected geometry, from its

convert_to_specification_act

The bar's Convert to Specification: the selected PSD or CPSD

convert_to_psd_act

The bar's Convert to PSD (Convert to CPSD with cross terms):

close_lines_pane

Put the Convert to PSD/CPSD pane away.

integrate_history

Integrate the selected time history once (acceleration to

differentiate_history

Differentiate the selected time history once (displacement to

compute_cpsds

Compute the full cross-spectral matrix of the selected time

generate_report

Add a new Report object from a starter template, select it to

export_report_template

Save the selected report on its own, as a template another

reports_from_runs

File → Reports from Runs…: run visualdynamics.run_report over

export_report

Export the selected report as one self-contained HTML file.

export_report_pdf

Export the selected report as a paginated PDF

issue_report

Issue the selected report as a PDF under its number and

attach_to_report

Add a file to the data index from the report editor: the

unindex_from_report

Take a file out of the data index from the report editor;

merge_selected

Replace the selected objects with one object that combines

start_modal_fit

Start fitting real normal modes to the selected FRF object, one

refine_all_modes

Fit the residues of every confirmed mode again, together, with

find_next_mode

Move the cursor to the next mode to fit: the largest CMIF peak

fit_pending_mode

Fit the pending mode at the cursor without confirming it, and

Attributes:

Name Type Description
objects Project

The project's objects: a mapping of name to object.

speed float

How fast the animation runs, as a multiple of the normal rate.

editing_family str | None

The element family being added to, read from what the table

element_type tuple[int, int]

(type code, node count) of the element the clicks build. The

Source code in src/visualdynamics/gui/main_window.py
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
def __init__(self, offscreen_3d: bool = False) -> None:
    """Build the window, with an empty project.

    `offscreen_3d` replaces the embedded VTK views with off-screen
    plotters. VTK's Qt widget cannot run under Qt's offscreen
    platform, so the headless tests use this to run the full render
    path."""
    super().__init__()
    # before any web view: a window built by a script, without the
    # launcher, needs the same one graphics system
    from .opengl import one_graphics_system

    one_graphics_system()
    # the status line as selectable text, before anything adds to the bar
    self.setStatusBar(MessageBar(self))
    from .project_tree import start_trace
    from .tooltips import install as wrap_long_tooltips

    start_trace()               # the drop log begins with the window
    wrap_long_tooltips()        # a sentence-long tooltip wraps
    #: where keyboard focus waits out the window's inactive spells —
    #: see `event` for the macOS drop bug this dodges. Zero-size on
    #: purpose: the bug poisons the focused widget's rect, and this
    #: rect cannot be hit.
    self._focus_park: QWidget = QWidget(self)
    self._focus_park.setFixedSize(0, 0)
    self._focus_park.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
    self._parked_focus: QWidget | None = None
    # The QWindow sees platform drag events before any widget
    # dispatch, so its line splits the world in two: present here
    # and absent from a widget is Qt losing it; absent here is the
    # platform never delivering it. Installed on our own QWindow
    # alone — an application-wide filter touched Chromium's
    # machinery and segfaulted.
    self._drag_watch = _WindowDragWatch(self)
    self.setWindowTitle('Visual Dynamics')
    self.resize(1280, 800)
    self.setAcceptDrops(True)
    self.unit_system: UnitSystem = DEFAULT_SYSTEM
    # the window shows a Project and owns nothing the Project owns:
    # objects, links, type and active geometry are its fields,
    # reached through the properties below, so clicking and
    # scripting act on one structure rather than two that agree
    self.project: Project = Project()
    #: name -> RecordGrid, while that object's row is expanded
    self.record_grids: dict[str, Any] = {}
    self._rows_wired = False  # is rows_deleted connected right now
    #: name -> the file it was read from
    self.object_sources: dict[str, str] = {}
    #: name -> what that file called it
    self.object_keys: dict[str, str] = {}
    #: the last compatibility check over the project
    self.report: Any = None
    self._status_text = ''

    from . import check_qt_binding

    check_qt_binding()   # a mismatch is unreadable once it reaches a layout
    import pyqtgraph as pg

    # the appearance chosen for this launch, remembered, or the
    # platform's — in that order (gui/preferences.py) — worn by the
    # whole application first, so the chrome and the drawn parts
    # are built to the same scheme. Not refreshing the rest: this
    # window's widgets read the palette as they are made, and other
    # windows are not this constructor's to touch (a crash on CI,
    # 2026-09-27: preferences.wear_appearance)
    # the style before the palette: a new style brings its own
    wear_item_checks()
    wear_appearance(refresh=False)
    self.theme_name: str = chosen_scheme()
    colors = resolve_theme(self.theme_name)
    pg.setConfigOption('background', colors['plot_background'])
    pg.setConfigOption('foreground', colors['plot_foreground'])

    # panes stack top to bottom: geometry above its data
    self.views: QSplitter = QSplitter(Qt.Orientation.Vertical)
    #: the live deflection, while data is shown on a geometry
    self.animator: Any = None
    self._playing = False
    self._rendering = False       # drop frames rather than queue them
    self._shape_mode = False      # phase sweep, versus stepping samples
    #: (geometry name, entity kind) while an edit table is open
    self.editing: tuple[str, str] | None = None
    #: clicking in the view creates things
    self.add_mode: bool = False
    #: what narrows the table being edited: ('family', name) or
    #: ('group', (id, ...)) — the element groups picked, one or
    #: several (2026-10-10) — None for a category (2026-09-30)
    self.editing_scope: tuple | None = None
    #: {id of a geometry: (fingerprint, fem.group_masses)}, so a click
    #: on an unchanged geometry does not build its model again; the
    #: fingerprint, not the id, decides whether the masses still hold
    self._mass_cache: dict[str, tuple] = {}
    self._picked_nodes = []       # nodes gathered for an element
    self._shape_source = None     # (geometry, shape set) while a mode plays
    #: (geometry name, the proposed set) while the rigid-body
    #: reading previews on the scene
    self._rigid_preview = None
    self._picker = None           # entity under the cursor, while editing
    self._projector = None
    self._hovered = None
    self._hover_mesh = None
    self._picked_mesh = None
    #: the solid the add pane is placing, while it is open: kind, geometry name, the display unit and whether the
    #: geometry has one, and the frame (basis rows and the center)
    #: the box is drawn in, in the display unit (2026-10-02)
    self._mesh: dict | None = None
    #: Merge Coincident Nodes while its pane is open: the geometry,
    #: its element groups, the tolerance in SI, the places found and
    #: the kept node of each place left out (2026-10-11)
    self._merging: dict | None = None
    self._merge_observer = None
    #: set while a merge rebuilds the tree and picks its groups again,
    #: so the selection change does not put the pane away
    self._merge_holding = False
    # Tie's first patch, while the second is being picked
    self._tie_patch: list[int] | None = None
    self._framed = ()          # what the camera was last framed for
    self._rotating = None      # the ring gesture in progress
    self._gizmo_reading = None  # the drag's number, while it lasts
    self._sliding = None       # the arrow gesture in progress (2026-10-02)
    self._rotate_observers = []
    self._gizmo_hovered = None  # ('ring' or 'arrow', axis) under the cursor
    self._last_axis = 2        # the ring a typed angle turns about
    self._pick_observers = []
    self._cursor = None
    self._cursor_dimension = None
    self._cursor_abscissa = None
    self._frame_index = 0
    # the same position, unrounded: see `_advance`
    self._frame_position = 0.0
    self._phase_position = 0.0
    # the 3-D view is a pane of its own: it owns the render window, the
    # annotations drawn around whatever is in it, and the bar they hang
    # from. What goes *into* the scene needs the project, so that stays
    # here and is added to the same bar.
    #: kept for the 3-D surfaces built later than the panes — the
    #: MAC bars' plotter is made on first use, long after __init__
    self._offscreen_3d: bool = offscreen_3d
    self.scene: ScenePane = ScenePane(self.theme_name,
                                      offscreen=offscreen_3d)
    self._build_view_toolbar(self.scene.toolbar)
    # the 2-D data view is a pane of its own: it owns the plot, the bar
    # that says how to read it, and that choice — and tells us when the
    # choice moves rather than acting on it
    self.data_pane: DataPane = DataPane(self.theme_name,
                                        offscreen=offscreen_3d)
    #: which object the *pager* is counting pages for — shared by
    #: both readings, since it is one control on one bar. Separate
    #: from `_waterfall_object` below, which answers a different
    #: question: where the 3-D camera belongs.
    self._paged_object: str | None = None
    #: which object the waterfall camera was placed for. The camera
    #: is placed when this changes and at no other time — a reread
    #: (units switch, record pick, component change) redraws the
    #: scene under the view the user set
    self._waterfall_object: str | None = None
    #: drags the stage's averaging span and shock windows; built
    #: with the stage plotter on first use
    self._stage_dragger = None
    #: coalesces report rebuilds during a drag — see
    #: `_report_content_changed`
    self._report_settle: QTimer = QTimer(self)
    self._report_settle.setSingleShot(True)
    self._report_settle.timeout.connect(self._report_content_changed)
    #: how to restate the stage's filter twin when the panel
    #: moves — set by `_stage_filter_preview`, which is the
    #: only thing that knows this render's extents
    self._stage_filter_redraw: Any = None
    #: {name: why} for derived objects whose source's settings have
    #: moved — read by the badges, refreshed at every settings edit
    self._stale: dict[str, str] = {}
    self.data_pane.reread.connect(self.render_current)
    self.data_pane.copied.connect(self._show_status)
    self.scene.copied.connect(self._show_status)
    self.data_pane.log_frequency_action.triggered.connect(
        self._frequency_axis_toggled)
    self.data_pane.drive_points_toggled.connect(self._toggle_drive_points)
    self.data_pane.residual_toggled.connect(self._toggle_fit_residual)
    self.data_pane.pair_mode_chosen.connect(self._set_pair_mode)
    self.data_pane.averaging_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.averaging_panel.changed.connect(self._averaging_edited)
    self.data_pane.shocks_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.filter_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.filter_panel.changed.connect(self._filtering_edited)
    self.data_pane.sine_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.sine_panel.changed.connect(self._sine_edited)
    self.data_pane.sine_panel.apply_asked.connect(
        lambda: self.extract_sine_levels())
    # a dwell read in the sine view is journaled as the call that
    # draws it, as every parameterized reading is
    self.data_pane.sine_panel.dwell.read.connect(self._journal_dwell)
    self.data_pane.sine_panel.distortion.read.connect(
        self._journal_distortion)
    self.data_pane.sine_panel.frequency.read.connect(
        self._journal_frequency)
    # and the mixed-mode data sheet, the same way, read on the
    # worker with the bar up, since tracking the tones of a long
    # record takes seconds
    self.data_pane.sine_panel.mixed.read.connect(self._journal_mixed)
    self.data_pane.sine_panel.mixed.runner = self._run_long
    self.data_pane.sine_panel.mixed.said.connect(self._show_status)
    self.data_pane.truncate_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.truncate_panel.changed.connect(
        self._truncation_edited)
    self.data_pane.octave_toggled.connect(
        lambda _wanted: self.render_current())
    # the sweep-rate reading and its modes redraw at once: the plot
    # and the table are the reading of the settings being made
    self.data_pane.sweep_rate_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.sweep_rate_panel.changed.connect(
        lambda _settings: self.render_current())
    # and the profile reading the same way: the three rows and the
    # crossovers are the reading of the settings being made
    self.data_pane.sine_profile_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.sine_profile_panel.changed.connect(
        lambda _settings: self.render_current())
    self.data_pane.octave_panel.changed.connect(
        lambda _per: self.render_current())
    # the points rule's band and rule redraw the judgement at once:
    # the plot is the preview of the settings being made
    self.data_pane.srs_tolerance_panel.changed.connect(
        lambda _settings: self.render_current())
    # the lines combined and the count of a line redraw the Chance
    # reading at once, for the same reason
    self.data_pane.chance_panel.changed.connect(
        lambda _settings: self.render_current())
    # the signature comparison's modes and limits redraw it at once:
    # the plot is the preview of the settings being made
    self.data_pane.signature_panel.changed.connect(
        lambda _settings: self.render_current())
    # the matrix comparison's tolerances redraw the grid at once:
    # the grid is the preview of the settings being made
    self.data_pane.matrix_panel.changed.connect(
        lambda _settings: self.render_current())
    # the multi-point reading's strategy and bands redraw it at
    # once: the plot is the preview of the settings being made
    self.data_pane.multi_point_panel.changed.connect(
        lambda _settings: self.render_current())
    self.data_pane.octave_panel.apply_asked.connect(
        self.compute_octave)
    # the tone readout: the toggle redraws, and a window chosen in
    # its pane re-reads the tone under the cursor where it stands
    self.data_pane.tone_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.tone_panel.changed.connect(self._tone_window_chosen)
    # and the transmissibility behind an FRF: the toggle redraws
    self.data_pane.transmissibility_toggled.connect(
        lambda _wanted: self.render_current())
    # the confidence band: the toggle redraws, and so does a level
    # or a count chosen in its pane, since the band is its preview
    self.data_pane.confidence_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.confidence_panel.changed.connect(
        self._confidence_chosen)
    # the rms check: the toggle redraws, and a time history or a
    # threshold chosen in its pane checks again where it stands
    self.data_pane.rms_check_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.rms_check_panel.changed.connect(
        self._rms_check_chosen)
    # and the two matrix checks: the toggles redraw, and the
    # realizability reading's button makes the repaired matrix
    self.data_pane.conditioning_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.realizability_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.realizability_panel.apply_asked.connect(
        self.make_realizable)
    # and the drive prediction of an FRF beside a specification
    self.data_pane.prediction_toggled.connect(
        lambda _wanted: self.render_current())
    # a channel's row clicked in the overload pane draws its trace
    self.data_pane.overload_panel.picked.connect(self._overload_picked)
    # and the notch prediction of a transmissibility beside its
    # specification: the toggle redraws (a choice in its pane does
    # too, through the pane's reread)
    self.data_pane.notch_toggled.connect(
        lambda _wanted: self.render_current())
    # and the readings behind a multiple coherence and a transformed
    # density, drawn in place of them: the toggles redraw
    self.data_pane.partial_coherence_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.transformation_toggled.connect(
        lambda _wanted: self.render_current())
    # the field limit: the toggle and every setting redraw the
    # reading, and Make Specification writes what it shows
    self.data_pane.field_limit_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.field_limit_panel.changed.connect(
        lambda _settings: self.render_current())
    self.data_pane.field_limit_panel.make_asked.connect(
        self.make_field_specification)
    # the fatigue reading: the toggle and every setting in its pane
    # redraw it, worked out again only when what it reads changed
    self.data_pane.fatigue_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.fatigue_panel.changed.connect(self._fatigue_chosen)
    # the acts, asked for where their settings are set (Brandon,
    # 2026-08-28): each button is a second entrance to the same
    # verb the bar's act offers, so provenance, badges and the
    # scripting API are untouched
    self.data_pane.filter_panel.apply_asked.connect(self.filter_data)
    self.data_pane.truncate_panel.apply_asked.connect(
        self.truncate_data)
    self.data_pane.resample_panel.apply_asked.connect(self.resample_data)
    # the specification draft: every edit re-previews, the button
    # makes the object. A specification opened to edit is drawn on
    # the plot, so its toggle sits on the plot's own bar
    self.data_pane.author_panel.changed.connect(self._author_edited)
    self.data_pane.author_panel.form_asked.connect(self._author_form_asked)
    self.data_pane.author_panel.constraint_asked.connect(
        self._author_constraint_asked)
    self.data_pane.author_panel.entry_asked.connect(self._author_entry_asked)
    self.data_pane.author_panel.areas_asked.connect(
        lambda _on: self.render_current())
    self.author_data_action: QAction = QAction(control_icon('edit'),
                                               'Edit', self)
    self.author_data_action.setCheckable(True)
    self.author_data_action.setToolTip(
        'Open this specification as a sheet — breakpoints, cross '
        'terms, bands — and replace it')
    self.author_data_action.triggered.connect(self._author_toggled)
    self.data_pane.toolbar.addAction(self.author_data_action)
    self.author_data_action.setVisible(False)
    # the scene's own reading: the rigid-body preview and its act
    self.scene.modes_toggled.connect(
        lambda _wanted: self.render_current())
    self.scene.modes_panel.changed.connect(self._rigid_edited)
    self.scene.modes_panel.kind_changed.connect(
        lambda _kind: self.render_current())
    self.scene.modes_panel.solve_asked.connect(self.solve_modes_act)
    self.scene.mesh_panel.changed.connect(self._mesh_edited)
    self.scene.mesh_panel.add_asked.connect(self._mesh_add)
    self.scene.mesh_panel.close_asked.connect(self.close_mesh_pane)
    self.scene.mesh_panel.square_asked.connect(self._mesh_square_up)
    self.scene.merge_panel.changed.connect(self._merge_refresh)
    self._meshing: dict | None = None
    self.scene.mesh_solids_panel.changed.connect(self._mesh_preview_asked)
    #: Mate Surfaces while its pane is open: the geometry, the groups,
    #: the patches with their nodes as picked, and the kept set being
    #: edited, if any (Brandon, 2026-10-20)
    self._mating: dict | None = None
    self._mate_observer = None
    self._mate_hover_observer = None
    self._mate_holding = False
    #: the sets of mated surfaces picked in the tree, marked in the view
    self._mates_shown: list = []
    self.scene.mate_panel.tolerance_changed.connect(self._mate_find)
    self.scene.mate_panel.search_changed.connect(self._mate_search_changed)
    self.scene.mate_panel.changed.connect(self._mate_refresh)
    self.scene.mate_panel.keep_asked.connect(self._mate_keep)
    self.scene.mate_panel.all_asked.connect(lambda: self._mate_side_all(True))
    self.scene.mate_panel.none_asked.connect(lambda: self._mate_side_all(False))
    self.scene.mate_panel.close_asked.connect(self.close_mate_pane)
    # Plan Instrumentation: a count or a direction chooses again, a
    # mode or a group finds the places again; both a moment after the
    # last change, so holding an arrow does not compute every step
    self._plan_timer = QTimer(self)
    self._plan_timer.setSingleShot(True)
    self._plan_timer.setInterval(150)
    self._plan_timer.timeout.connect(self._plan_now)
    panel = self.scene.instrumentation_panel
    panel.changed.connect(self._plan_timer.start)
    panel.places_changed.connect(self._plan_timer.start)
    panel.keep_asked.connect(self._plan_keep)
    panel.all_asked.connect(lambda: self._plan_all_surfaces(True))
    panel.none_asked.connect(lambda: self._plan_all_surfaces(False))
    panel.close_asked.connect(self.close_instrumentation_pane)
    self._plan_observers: list = []
    #: the preview waits for the size box to settle: each change
    #: meshed at once, and at a fine size a spin of the box was one
    #: mesh and one drawing per step on this thread (Brandon,
    #: 2026-10-18: a 10 by 10 by 3 in block at 0.1 in, 163,560
    #: bricks, two seconds each and the wheel spinning)
    self._mesh_settle: QTimer = QTimer(self)
    self._mesh_settle.setSingleShot(True)
    self._mesh_settle.timeout.connect(self._mesh_solids_refresh)
    self.scene.mesh_solids_panel.mesh_asked.connect(self._mesh_solids_apply)
    self.scene.mesh_solids_panel.detect_asked.connect(self._mesh_solids_detect)
    self.scene.mesh_solids_panel.close_asked.connect(
        self.close_mesh_solids_pane)
    self._patterning: dict | None = None
    self.scene.pattern_panel.changed.connect(self._pattern_refresh)
    self.scene.pattern_panel.pattern_asked.connect(self._pattern_apply)
    self.scene.pattern_panel.close_asked.connect(self.close_pattern_pane)
    self.scene.merge_panel.all_asked.connect(self._merge_all)
    self.scene.merge_panel.none_asked.connect(self._merge_none)
    self.scene.merge_panel.merge_asked.connect(self._merge_apply)
    self.scene.merge_panel.close_asked.connect(self.close_merge_pane)
    self.scene.modes_panel.apply_asked.connect(
        self.generate_rigid_body_modes)
    self.data_pane.shock_panel.srs_asked.connect(self.compute_srs)
    self.data_pane.averaging_panel.psds_asked.connect(
        self.compute_psds)
    self.data_pane.averaging_panel.cpsds_asked.connect(
        self.compute_cpsds)
    self.data_pane.averaging_panel.spectra_asked.connect(
        self.compute_spectra)
    self.data_pane.averaging_panel.frfs_asked.connect(
        self.compute_frfs)
    self.data_pane.averaging_panel.coherence_asked.connect(
        self.compute_multiple_coherence)
    self.data_pane.shock_panel.changed.connect(self._shocks_edited)
    self.data_pane.shock_panel.length_mode_changed.connect(
        self._shock_length_mode)
    self.data_pane.shock_panel.detect_asked.connect(self._detect_shocks)
    self.data_pane.shock_panel.add_asked.connect(self._add_shock)
    self.data_pane.spectra_view_chosen.connect(
        lambda _which: self.render_current())
    self.data_pane.comparison_chosen.connect(
        lambda _which: self.render_current())
    self.data_pane.scaling_edited.connect(self._comparison_scale_edited)
    # the channel box and the channel table ask the same question,
    # so whichever was touched last is the answer. Connected before
    # the redraw below, because it has to have taken effect by the
    # time the drawing reads it
    self.data_pane.pair_chosen.connect(
        lambda: setattr(self, '_replication_pairs', []))
    self.data_pane.pair_chosen.connect(
        lambda: setattr(self, '_compliance_channels', []))
    self.data_pane.pair_chosen.connect(self.render_current)
    self.data_pane.replication_chosen.connect(
        lambda _which: self.render_current())
    # the segment marks and tolerances redraw the judgment at once:
    # the plot is the preview of the marks being made
    self.data_pane.segments_panel.changed.connect(
        lambda _settings: self.render_current())
    self.data_pane.event_chosen.connect(self._event_chosen)
    self.data_pane.srs_chosen.connect(
        lambda _which: self.render_current())
    # ...and from anywhere, for when the tree has the keyboard and
    # plain arrows are its own
    for keys, step in (('Alt+Up', -1), ('Alt+Down', 1)):
        shortcut = QShortcut(QKeySequence(keys), self)
        shortcut.activated.connect(
            lambda step=step: self.data_pane.step_pair(step))
    #: the frames drawn on the time history, one overlay per plot the
    #: history was split across — force and acceleration get a row each
    self.averaging_overlays: list[Any] = []
    self.filter_overlays: list[Any] = []
    self.truncation_overlays: list[Any] = []
    self.octave_previews: list[Any] = []
    #: the tone readout on the plot (`_draw_tone`): the spectrum, the
    #: cursor, the overlay and how the tone is read; None while the
    #: reading is not up
    self._tone: dict[str, Any] | None = None
    #: a window chosen in the tone pane, (name, Tukey or Kaiser
    #: parameter or None), by spectrum name, over the one its
    #: averaging records; and where each spectrum's cursor was left,
    #: so a redraw finds it where it was
    self._tone_windows: dict[str, tuple[str, float | None]] = {}
    self._tone_frequencies: dict[str, float] = {}
    #: the transmissibility reading on an FRF's magnitude
    #: (`_draw_transmissibility`): the reading, the cursor and the
    #: overlay; None while it is not up. And where each FRF
    #: record's cursor was left, so a redraw finds it there
    self._transmissibility: dict[str, Any] | None = None
    self._transmissibility_frequencies: dict[tuple[str, int],
                                             float] = {}
    #: the confidence band on the plot (`_draw_confidence`): the
    #: (plot, item) pairs drawn, and the spectrum they belong to
    self.confidence_previews: list[Any] = []
    self._confidence_name: str | None = None
    #: the confidence the band is drawn at, one choice for every
    #: spectrum, since it is how sure the reader wants to be rather
    #: than a property of the data; and a count typed in the pane,
    #: by spectrum name, over the one the spectrum carries
    self._confidence_level: float = CONFIDENCE_LEVELS[0]
    self._confidence_dofs: dict[str, float] = {}
    #: the rms check on the plot (`_draw_rms_check`): the spectrum,
    #: the record, the label in the plot's corner and the time
    #: history it is checked against; None while it is not up
    self._rms_check: dict[str, Any] | None = None
    #: the composite reading being made on the worker
    #: (`_render_multi_point`): a reading that comes back after a
    #: newer drawing took this is dropped
    self._multi_point_token: object | None = None
    #: (what it was asked of, the reading) for the last composite
    #: made, shown again when a redraw asks the same
    self._multi_point_held: tuple[tuple, Any] | None = None
    #: the time history and threshold chosen in the rms check's
    #: pane, by spectrum name, so a redraw keeps them
    self._rms_check_settings: dict[str, dict[str, Any]] = {}
    #: the matrix check on the plot (`_render_matrix_check`): which
    #: one, the reading, its cursor; None while neither is up. And
    #: where each object's cursor was left, by (check, name)
    self._matrix_check: dict[str, Any] | None = None
    self._matrix_check_frequencies: dict[tuple[str, str], float] = {}
    #: the reading last made, as (check, object, its ordinate, the
    #: reading): it is read on the worker (a repair of a large
    #: target is seconds), so a cursor moved, a theme changed or a
    #: drawing made again reads the one held. Let go when neither
    #: check is up. And the (check, object) being read while the
    #: worker runs, with whether a drawing came in meanwhile
    self._held_matrix_check: tuple | None = None
    self._matrix_check_reading_for: tuple | None = None
    self._matrix_check_redraw: bool = False
    #: the field limit on the plot (`_render_field_limit`): the set,
    #: the settings and the envelope Make Specification writes;
    #: None while it is not up or was refused
    self._field_limit: dict[str, Any] | None = None
    #: the settings chosen in the fatigue pane, by (name, record), so
    #: a redraw and a return to the record keep them
    self._fatigue_settings: dict[tuple[str, int], dict[str, Any]] = {}
    #: the record the fatigue reading is drawn for, (name, record),
    #: so a setting chosen in its pane is kept for that record
    self._fatigue_shown: tuple[str, int] | None = None
    #: the last fatigue reading worked out, with what it was worked
    #: out from, so a redraw with nothing changed draws it again
    #: rather than counting every resonator again
    self._fatigue_cache: tuple[tuple, Any, Any] | None = None
    #: and the shock windows, the same way
    self.shock_overlays: list[Any] = []
    #: which comparison the plot is drawing, so the compliance table
    #: can mark its row; and the guard that stops the two of them
    #: from chasing each other
    self._plotted_pair = None
    self._syncing_compliance = False
    #: the bar chart currently up, when the comparison is being read
    #: as one rather than as spectra
    self.bar_chart: Any = None
    self.table: CopyPasteTableView = CopyPasteTableView()
    self.table.edits_applied.connect(self._report_edits)
    # the table with, in fit mode, its Confirm bar underneath
    self.table_pane: QWidget = QWidget()
    table_layout = QVBoxLayout(self.table_pane)
    table_layout.setContentsMargins(0, 0, 0, 0)
    table_layout.setSpacing(0)
    # the auto-MAC sits to the right of a mode table — how distinct
    # the modes are belongs beside the list of them. Square, because
    # the matrix is; the frame keeps it that way whatever the pane does
    self.mac_view: Any = pg.GraphicsLayoutWidget()
    self.mac_frame: _RatioFrame = _RatioFrame(self.mac_view)
    self.mac_frame.hide()
    self.mac_view.scene().sigMouseClicked.connect(self._mac_clicked)
    # comparing shape sets is often about the MAC alone: a bar over
    # the pane toggles the mode table on the left away
    self.table_bar: QToolBar = QToolBar()
    self.table_bar.setIconSize(QSize(20, 20))
    self.mode_table_action: QAction = QAction(control_icon('table'),
                                     'Mode Table', self)
    self.mode_table_action.setCheckable(True)
    self.mode_table_action.setChecked(True)
    self.mode_table_action.setToolTip(
        'Show the table beside the MAC — the mode list for one '
        'set, the matched-modes table while comparing two')
    self.mode_table_action.triggered.connect(self._views_toggled)
    # a lone shape set's three views, each on or off, one always up
    # (Brandon, 2026-10-22): the mode table above, the MAC and the
    # shape values themselves
    self.mac_action: QAction = QAction(control_icon('mac'), 'MAC', self)
    self.mac_action.setCheckable(True)
    self.mac_action.setChecked(True)
    self.mac_action.setToolTip(
        'Show the MAC of the set against itself: how distinct its '
        'modes are')
    self.mac_action.triggered.connect(self._views_toggled)
    self.shape_values_action: QAction = QAction(
        control_icon('shape_values'), 'Shape Values', self)
    self.shape_values_action.setCheckable(True)
    self.shape_values_action.setToolTip(
        "Show the shape values: a row per DOF and a column per mode")
    self.shape_values_action.triggered.connect(self._views_toggled)
    #: whether a lone shape set's views are up, so a toggle redraws
    #: them rather than only showing or hiding the table
    self._shape_views_up = False
    # the MAC's own 3-D reading: the matrix as bars, height and
    # color both the value, and the default here too (Brandon's
    # call, made once the bars could be picked like the grid)
    self.mac_bars_action: QAction = QAction(control_icon('waterfall'),
                                   '3D', self)
    self.mac_bars_action.setCheckable(True)
    self.mac_bars_action.setChecked(True)
    self.mac_bars_action.setToolTip(
        'The MAC as 3-D bars, height and color the value — '
        'click picks a pair, Shift adds it, exactly as on the grid')
    self.mac_bars_action.triggered.connect(self._rerender_mac)
    self._wire_reading_choice()
    # the 2D/3D toggle leads this bar, as it leads the data pane's
    # (Brandon, 2026-08-30): the same control sits in the same
    # place whichever view is up
    self.table_bar.addAction(self.mac_bars_action)
    self.table_bar.addAction(self.mode_table_action)
    self.table_bar.addAction(self.shape_values_action)
    # where the values are read and in which form (Brandon,
    # 2026-10-23): every DOF of the set or a channel choice of the
    # table in its group, and the shapes or a transformation
    self.values_dofs_combo: QComboBox = QComboBox()
    self.values_dofs_combo.setToolTip(
        'Where the values are read: every DOF of the set, or a choice '
        'of the channel table in its group')
    self.values_dofs_combo.currentIndexChanged.connect(
        lambda _index: self.render_current())
    self.values_dofs_action: QAction = self.table_bar.addWidget(
        self.values_dofs_combo)
    self.values_form_combo: QComboBox = QComboBox()
    for label, form in (('Shapes Φ', 'shapes'),
                        ('Response transformation Φ⁺', 'response'),
                        ('Reference transformation Φᵀ', 'reference')):
        self.values_form_combo.addItem(label, form)
    self.values_form_combo.setToolTip(
        'The shapes at the DOFs (u = Φq), or a transformation there: '
        'the response transformation Φ⁺ (q = Φ⁺u), applied to '
        'motions, or the reference transformation Φᵀ, applied to '
        'forces and drive signals. A motion reference, such as a '
        'base-excitation accelerometer, transforms as a response')
    self.values_form_combo.currentIndexChanged.connect(
        lambda _index: self.render_current())
    self.values_form_action: QAction = self.table_bar.addWidget(
        self.values_form_combo)
    self.table_bar.addAction(self.mac_action)
    self.mac_bars_action.setVisible(False)
    self._hide_shape_values()
    self.mac_action.setVisible(False)
    # writing a specification at a channel table's control
    # channels: the reading of a lone table that makes an object.
    # Its pane sits beside the plot, which previews the autospectra
    # as they are typed. It opened on a shape set too, at its modal
    # coordinates (PLAN.md, "The virtual point arc"), and wore the
    # pencil; Brandon, 2026-10-22: the channel table only, wearing
    # the specification's own glyph
    self.author_action: QAction = QAction(
        control_icon('new_specification'), 'Create Specification', self)
    self.author_action.setCheckable(True)
    # named for what it makes, not where (Brandon, 2026-10-30): it
    # opens at picked rows as well as at the control channels. The
    # bar shows icons only, so the name leads the tooltip, as it
    # does on every act (`panes.offer_acts`)
    self.author_action.setToolTip(
        'Create Specification — write one at the picked channels, '
        'or at the control channels when none are picked: its '
        'breakpoints, every cross term and the bands')
    self.author_action.triggered.connect(self._author_toggled)
    # a channel table's control loops (Brandon, 2026-10-28): which
    # environment's control channels the Control column shows, and
    # the table itself or one of that loop's transformations
    self.environment_combo: QComboBox = QComboBox()
    self.environment_combo.setToolTip(
        "Which environment's control channels the Control column "
        'shows, and whose transformation the view drop-down offers')
    self.environment_combo.currentIndexChanged.connect(
        lambda _index: self.render_current())
    self.environment_action: QAction = self.table_bar.addWidget(
        self.environment_combo)
    self.table_view_combo: QComboBox = QComboBox()
    self.table_view_combo.setToolTip(
        'The channels, or what the controller put over them: the '
        'response transformation, a row per controlled response over '
        'the control channels, or the reference transformation over '
        'the drives')
    self.table_view_combo.currentIndexChanged.connect(
        lambda _index: self.render_current())
    self.table_view_action: QAction = self.table_bar.addWidget(
        self.table_view_combo)
    self._hide_table_views()
    self.table_bar.addAction(self.author_action)
    self.author_action.setVisible(False)
    self._author_wanted: bool = False
    #: the Generate Signal pane while it is open: the object it is
    #: open on and the settings shown; None while it is shut
    self._signaling: dict | None = None
    #: the settings last shown for each source, so reopening the
    #: pane on an object starts where it was left
    self._signal_settings: dict[str, Any] = {}
    #: the SRS synthesis last iterated for the pane, as (what it was
    #: made from, the synthesis or the reason there is none), so a
    #: redraw draws it rather than iterating again
    self._synthesis_held: tuple | None = None
    #: an SRS synthesis takes seconds of passes: it is iterated on
    #: the worker once the settings have rested, not on every redraw
    self._synthesis_settle: QTimer = QTimer(self)
    self._synthesis_settle.setSingleShot(True)
    self._synthesis_settle.timeout.connect(self._signal_synthesize)
    self.data_pane.signal_panel.changed.connect(self._signal_edited)
    self.data_pane.signal_panel.apply_asked.connect(self._signal_apply)
    #: the Compute Modal Responses pane while it is open: what it is
    #: open on (history, set, modes, records) and the quantity shown
    self._modaling: dict | None = None
    self._modal_quantity: str = 'acceleration'
    self.data_pane.modal_response_panel.changed.connect(
        self._modal_quantity_chosen)
    self.data_pane.modal_response_panel.apply_asked.connect(
        self._modal_apply)
    #: the Convert to PSD/CPSD pane while it is open on a
    #: specification at breakpoints: its name and the spacing set
    self._lining: dict | None = None
    self.data_pane.lines_panel.changed.connect(self._lines_spacing_chosen)
    self.data_pane.lines_panel.apply_asked.connect(self._lines_apply)
    #: the Export State-Space Model pane while it is open: the (set,
    #: table) it is open on and the frequencies set
    self._staging: dict | None = None
    self.data_pane.state_space_panel.changed.connect(
        self._state_space_edited)
    self.data_pane.state_space_panel.apply_asked.connect(
        self._state_space_apply)
    #: the replication fit last read, as (record, its samples, the
    #: inputs, the fit): a cutoff column picked only changes the
    #: case drawn, and reading the fit again was 0.23 s of filters
    #: and integrations for numbers already held. Let go whenever
    #: the fit is not up, so a record left behind is not kept alive
    self._held_fit: tuple | None = None
    #: and the stroke reading's integration, the same way: a rating
    #: moved reads the held arrays against the new rating
    self._held_stroke: tuple | None = None
    #: and the overload check's readings, the same way: the step to
    #: the next level, dragged on the chart, judges them again
    self._held_overload: tuple | None = None
    #: and the modes the signature comparison found, the same way:
    #: a limit moved does not move them, and finding them again was
    #: a tenth of a second on every tick of a limit's box
    self._held_modes: tuple | None = None
    #: and the matrix comparison last read, the same way: a
    #: tolerance moved judges its cross terms again and reads nothing
    #: twice, where reading the levels again was most of a second on
    #: an eight-channel matrix
    self._held_matrix: tuple | None = None
    #: the grid being drawn a few cells at a time between turns of
    #: the event loop (`_draw_matrix_steps`); None when it is done
    #: or a drawing since has replaced it
    self._matrix_drawing: Any = None
    #: and the drive prediction last made, as (FRF, its ordinate,
    #: specification, its ordinate, the arguments, the prediction):
    #: made on the worker, and drawn again from here when nothing it
    #: was made of has changed. Let go when the reading is not up
    self._held_prediction: tuple | None = None
    #: and the partial coherence and rigid-body residual last read,
    #: by kind, as (name, object, its ordinate, the reading): each
    #: is read on the worker, and drawn again from here while its
    #: object stands. Let go when the reading is not up
    self._held_readings: dict[str, tuple] = {}
    #: why the sheet just asked for could not be opened, said once
    #: on the status line of the render that stood it down
    self._author_refusal: str | None = None
    #: the draft per object it was opened on, kept while the
    #: window lives
    self._drafts: dict[str, Any] = {}
    #: which objects each cached draft was opened on — so a write
    #: through one door (the whole specification) drops the drafts
    #: cached under its other doors (one picked channel), which
    #: otherwise kept showing the object as it was (Brandon,
    #: 2026-09-06: the sub-items had not updated back to ±3 dB)
    self._draft_names: dict[str, tuple[str, ...]] = {}
    #: what the reading is open on: ('shapes' | 'spec' | 'table', name)
    self._author_door: tuple[str, str] | None = None
    #: the specification a channel-table door made, by door key — a render between the making and the deferred
    #: switch of selection must not make a second one
    self._author_made: dict[str, str] = {}
    #: the 3-D MAC surface, built on first use, and which comparison
    #: its camera was placed for — placed on a change, kept on a
    #: reread, the same standing rule as every scene
    self._mac_bars_page: QWidget | None = None
    self._mac_bars_plotter_obj: Any = None
    self._mac_bars_shown: Any = None
    #: (rows, columns) of the bars last drawn — what a picked mesh
    #: cell is decoded against
    self._mac_bars_grid: tuple[int, int] | None = None
    # comparing two sets that live on a geometry: the picked pair
    # can animate overlaid, phase-aligned — the toggle remembers
    self.overlay_action: QAction = QAction(control_icon('overlay'),
                                  'Overlay Animation', self)
    self.overlay_action.setCheckable(True)
    self.overlay_action.setChecked(True)
    self.overlay_action.setToolTip(
        'Animate the picked pair of modes over each other, the '
        'second phase-aligned to the first')
    self.overlay_action.triggered.connect(
        lambda _checked: self.render_current())
    self.table_bar.addAction(self.overlay_action)
    self.overlay_action.setVisible(False)
    # committing picked MAC squares builds the matched-modes
    # object, the way fitting builds the shape set
    self.add_matches_action: QAction = QAction(control_icon('add'),
                                      'Add Matches', self)
    self.add_matches_action.setToolTip(
        'Add the selected MAC squares to the matched modes for '
        'this pair of sets — created on first use')
    self.add_matches_action.triggered.connect(self.add_matches)
    self.table_bar.addAction(self.add_matches_action)
    self.add_matches_action.setVisible(False)
    self.table_bar.hide()
    table_layout.addWidget(self.table_bar)
    # a splitter, not a row: how much of the pane the mode list wants
    # against how big the MAC should be is a judgment about the data
    # in front of you, so it is a divider to drag rather than a ratio
    # we picked
    self.tables_row: QSplitter = QSplitter(Qt.Orientation.Horizontal)
    self.tables_row.setChildrenCollapsible(False)
    self.tables_row.addWidget(self.table)
    # a lone shape set's values, between its mode table and its MAC
    self.shape_values_table: CopyPasteTableView = CopyPasteTableView()
    self.shape_values_table.hide()
    #: a lone shape set's transformation drawn on its geometry, above
    #: the tables (Brandon, 2026-10-23): (geometry name, geometry)
    #: while the layout asks for it, and (geometry, the matrix) while
    #: a row's arrows can be drawn
    self._values_on: tuple | None = None
    self._values_arrows: tuple | None = None
    self.tables_row.addWidget(self.shape_values_table)
    self.tables_row.addWidget(self.mac_frame)
    self.tables_row.setSizes([500, 500])
    # the report editor borrows this pane: the rendered report
    # itself, editable — created on first use, Chromium is heavy
    self.report_editor: Any = None
    self._table_layout = table_layout
    #: the Run Log dock (`gui.run_log_dock`), built the first time it
    #: is asked for, and whether the window shows the log alone
    self.run_log_dock: Any = None
    self.run_log_only: bool = False
    #: the last notch and drive predictions drawn, by kind ('limit',
    #: 'drive'), each with the objects it was made from: what the Run
    #: Log shows beside what is read (`RunLogPanel.offer_prediction`)
    self.run_log_predictions: dict[str, tuple[Any, str]] = {}
    # the splitter takes the height going spare; without the
    # stretch the button bar below it claimed an even share and
    # stood a quarter of the window tall
    table_layout.addWidget(self.tables_row, 1)
    self.fit_bar: QWidget = QWidget()
    fit_layout = QHBoxLayout(self.fit_bar)
    fit_layout.setContentsMargins(6, 4, 6, 4)
    fit_layout.addStretch(1)
    # Left of Find Mode, and first into the layout, because the
    # bar is right-aligned by the stretch above: the group grows
    # and shrinks at its *left* edge, so Refine All appearing after
    # the second mode does not shift Find Mode and Confirm Mode out
    # from under a cursor already on them. Added last, it pushed
    # both of them left the moment it showed up.
    #
    # Sequential fitting never goes back: the first of a close pair
    # was fit on data still containing the second. This goes back —
    # poles held, every mode's residues re-fit together. Offered
    # only once there are two modes to influence each other.
    self.refine_modes_button: QPushButton = QPushButton('Refine All')
    self.refine_modes_button.setToolTip(
        'Re-fit every confirmed mode\'s shape together, holding the '
        'frequencies and dampings — close modes stop borrowing from '
        'each other')
    self.refine_modes_button.clicked.connect(self.refine_all_modes)
    fit_layout.addWidget(self.refine_modes_button)
    self.find_mode_button: QPushButton = QPushButton('Find Mode')
    self.find_mode_button.setToolTip(
        'Move the cursor to the largest CMIF peak left in the '
        'residual, within the frequencies on screen — the next '
        'mode to fit. Zoom to narrow the search.')
    self.find_mode_button.clicked.connect(self.find_next_mode)
    fit_layout.addWidget(self.find_mode_button)
    self.confirm_mode_button: QPushButton = QPushButton('Confirm Mode')
    self.confirm_mode_button.setToolTip(
        'Fit a real normal mode at the cursor and move it to the next '
        'largest residual peak')
    self.confirm_mode_button.clicked.connect(self.confirm_fit_mode)
    fit_layout.addWidget(self.confirm_mode_button)
    table_layout.addWidget(self.fit_bar)
    self.fit_bar.hide()
    for pane in (self.scene, self.data_pane, self.table_pane):
        self.views.addWidget(pane)
    self.data_pane.hide()
    self.table_pane.hide()
    # declaring units sits beside the views rather than over them: the
    # plot is how you tell what a channel is, so it has to stay visible
    #: (name, object) while the imported-units pane is up
    self.units_target: tuple[str, Any] | None = None
    #: (channel, playing) pairs the replication grid is pointing
    #: the plot at, kept here rather than in the table because the
    #: table is rebuilt on every drawing and the choice is not
    self._replication_pairs = []
    #: DOF pairs the compliance table is pointing the plot at — the
    #: same arrangement the transient grid has, for a comparison
    #: with no repeats to spread across
    self._compliance_channels = []
    #: set while a redraw is already queued, so a drag across the
    #: grid draws once rather than once per cell it crosses
    self._replication_pending = False
    #: the grid on screen and the model showing it, so a drawing
    #: that changes nothing about them leaves the user's selection
    #: alone rather than rebuilding it from this end's idea of it
    self._replication_holder = None
    self._replication_model = None
    self._compliance_holder = None
    self._compliance_model = None
    self._replication_units = '%'
    #: the ModalFitSession while a modal fit is open
    self.fit: Any = None
    self.fit_name: str | None = None
    self.fit_object_name: str | None = None
    self._fit_cursor = None
    self._fit_parabola = None
    self._fit_damping_label = None
    #: the half-power reading of the peak under the cursor, drawn
    #: (`plot.half_power.HalfPowerOverlay`) and its last reading
    self._fit_half_power = None
    self._fit_reading = None
    self._fit_coherence_name = None
    #: what the plot's top and bottom edges mean as damping, kept
    #: across fits — an article's plausible range rarely changes
    #: between two of its own FRF sets
    self._fit_damping_range: list[float] = [self.FIT_DAMPING_TOP,
                                            self.FIT_DAMPING_BOTTOM]
    self._fit_range_edits: list | None = None
    #: the dashed synthesis curves currently on the CMIF,
    #: for updating in place while the cursor is dragged
    self._fit_synthesis = []
    #: fires when the cursor has been still a moment
    self._fit_settle = QTimer(self)
    self._fit_settle.setSingleShot(True)
    self._fit_settle.timeout.connect(self._fit_settled)
    #: set while a drag tick is running, so the next is
    #: dropped rather than queued
    self._fit_dragging = False
    #: what the dear tier — the fit and the dashed synthesis —
    #: cost the last time it ran, in seconds. The gate on a drag
    #: tick reads this *before* paying: the old scheme ran the
    #: dear tier first and checked the budget after, which on the
    #: hard drone survey meant every drag opened with the full
    #: bill — seconds of freeze — before the guard could notice,
    #: and paid it again after every pause, because the settle
    #: re-armed it. Remembered cost, primed by the settle that
    #: `start_modal_fit` schedules, means a set too big to follow
    #: live never stalls a single tick.
    self._fit_dear_cost = 0.0
    #: whether the dashed synthesis has fallen behind the cursor —
    #: a tick that skipped the dear tier sets it, and the settle
    #: catches up
    self._fit_synthesis_stale = True
    self._fit_model = None
    self._fit_plot = None
    self._compare = None           # {'pair', 'cell'} while comparing
    #: (spec name, measured name) the Scaling field is editing
    self._scaling_pair = None
    #: an import is running; a second one queued behind it waits
    self._importing = False
    #: what MAC the mac_view currently shows, so redrawing the same
    #: comparison keeps the zoom (picking cells re-renders it)
    self._mac_shown = None
    #: re-entrancy guard: showing a pair rebuilds the table that
    #: asked for it, and its selection would ask again
    self._matched_row_moving = False
    self._compare_active = False
    #: how an FRF and a shape set together read: 'fit' | 'overlay'
    self.pair_mode: str = 'fit'
    self._pair_selected = False
    self._pair_with_picks = False
    self._units_restate = QTimer(self)
    self._units_restate.setSingleShot(True)
    self._units_restate.timeout.connect(self._restate_after_units)
    self.main_split: QSplitter = QSplitter(Qt.Orientation.Horizontal)
    self.main_split.addWidget(self.views)
    self.main_split.addWidget(self._build_units_panel())
    self.main_split.setStretchFactor(0, 3)
    self.main_split.setStretchFactor(1, 1)
    self.units_panel.hide()
    # the console rides the bottom edge as a tab (Brandon,
    # 2026-08-30): collapsed by default, one click to expand the
    # session's journal, one to put it away — no menu to find
    from .console import ConsolePanel

    center = QWidget()
    column = QVBoxLayout(center)
    column.setContentsMargins(0, 0, 0, 0)
    column.setSpacing(0)
    column.addWidget(self.main_split, 1)
    self.console: ConsolePanel = ConsolePanel()
    column.addWidget(self.console)
    # the tab is not a layout row: floated over the views' bottom
    # edge, so collapsed the console claims no strip at all
    self.console.float_over(center)
    # what the tab covers, the 2-D plots leave empty: legend row
    # and bottom axis end above it, whichever pane it lands on
    self.data_pane.reserve_bottom(
        self.console.tab.sizeHint().height() + 4)
    self.setCentralWidget(center)

    # Dragging the divider between the tree and the views has to
    # track the cursor. AnimatedDocks — on by default — eases every
    # dock geometry change, so mid-drag the divider trails the mouse
    # rather than following it. And the separator is 4 px by default,
    # a target you aim at rather than a thing you grab.
    self.setDockOptions(QMainWindow.DockOption.AllowNestedDocks
                        | QMainWindow.DockOption.AllowTabbedDocks)
    self.setStyleSheet(self.styleSheet()
                       + '\nQMainWindow::separator { width: 7px; '
                         'height: 7px; }\n')

    self.tree: ProjectTree = ProjectTree()
    # a drop on the tree is the same import as a drop anywhere else,
    # queued the same way — see _import_after_drop for why it is
    # queued at all
    self.tree.files_dropped.connect(self._import_after_drop)
    self.tree.objects_moved.connect(self._move_objects)
    self.tree.solids_reordered.connect(self._reorder_solids)
    self.tree.landing_for = self._landing_of
    self.tree.write_for_drag = self._write_for_drag
    # column 1 carries the refresh badge of a stale object and the
    # edit pencil of a sub-row; the acts are on the bar
    self.tree.setColumnCount(2)
    self.tree.setHeaderLabels(['Project', ''])
    self.tree.header().setStretchLastSection(False)
    self.tree.header().setSectionResizeMode(
        0, QHeaderView.ResizeMode.Stretch)
    self.tree.setColumnWidth(1, 26)
    self.tree.itemClicked.connect(self._tree_item_clicked)
    self.tree.setIconSize(QSize(20, 20))
    self.tree.currentItemChanged.connect(self._selection_changed)
    self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
    self.tree.customContextMenuRequested.connect(self._show_tree_menu)
    self.tree.setSelectionMode(
        QAbstractItemView.SelectionMode.ExtendedSelection)
    self.tree.itemSelectionChanged.connect(self._selection_changed)
    self.tree.setEditTriggers(
        QTreeWidget.EditTrigger.DoubleClicked
        | QTreeWidget.EditTrigger.EditKeyPressed)
    self.test_item: QTreeWidgetItem = QTreeWidgetItem(['Project'])
    self.test_item.setIcon(0, type_icon('Test'))
    self.test_item.setData(0, ROLE_ORIGINAL_NAME, 'Project')
    self.test_item.setData(0, ROLE_REFERENCE, ('test', None, None))
    # draggable *out* of the window, where it saves the whole
    # project; not ROLE_DRAGGABLE, which means movable between
    # object groups and the project belongs to none
    self.test_item.setData(0, ROLE_WHOLE_PROJECT, True)
    self.test_item.setFlags(self.test_item.flags() | Qt.ItemFlag.ItemIsEditable)
    self.tree.addTopLevelItem(self.test_item)
    self.test_item.setExpanded(True)
    self.tree.setCurrentItem(self.test_item)

    # linking lives on a small bar above the tree, beside the same
    # actions on the context menu
    tree_bar = QToolBar('Links')
    tree_bar.setMovable(False)
    tree_bar.setIconSize(QSize(16, 16))
    self.link_action: QAction = QAction(control_icon('link'), '&Link', self)
    self.link_action.setToolTip(
        'Link the selected objects so they are known to belong '
        'together — shapes to their geometry')
    self.link_action.triggered.connect(self.link_selected)
    tree_bar.addAction(self.link_action)
    self.link_action.setVisible(False)
    self.unlink_action: QAction = QAction(control_icon('unlink'), '&Unlink',
                                 self)
    self.unlink_action.setToolTip(
        'Take the selected objects out of their link')
    self.unlink_action.triggered.connect(self.unlink_selected)
    tree_bar.addAction(self.unlink_action)
    self.unlink_action.setVisible(False)
    # the rest of the workflow, from what is loaded: every gray
    # slot the type expects that the data can fill, and the report
    # (Brandon, 2026-09-30). Shown only on a typed project — an
    # untyped one has no skeleton to fill.
    self.automatic_action: QAction = QAction(
        control_icon('automatic'), '&Automatic', self)
    self.automatic_action.setToolTip(
        'Compute everything the project type still expects from '
        'what is loaded, and generate the report')
    self.automatic_action.triggered.connect(self.work_up)
    tree_bar.addAction(self.automatic_action)
    self.automatic_action.setVisible(False)
    # Everything in the dock takes a file drag the way the tree
    # does. The dock's chrome — the title bar, the toolbar, the
    # holder around the tree — accepted nothing, and a drag refused
    # there is left to Qt walking up to the window, which through a
    # dock's widget stack is exactly the walk that misses (the
    # tree's module docstring records it). On a fresh window the
    # tree is a strip about 90 px wide, so most of the left side
    # *was* chrome: importing by drop worked on the right side of
    # the window and only sometimes on the left, which reads as a
    # broken feature aimed at the one place that names the project.
    tree_holder = _TakesFileDrops()
    tree_layout = QVBoxLayout(tree_holder)
    tree_layout.setContentsMargins(0, 0, 0, 0)
    tree_layout.setSpacing(0)
    tree_layout.addWidget(tree_bar)
    tree_layout.addWidget(self.tree)
    tree_holder.files_dropped.connect(self._import_after_drop)
    # The tree is not a view of the project, it is how you say what
    # the views are of: every pane answers to a selection made here,
    # and there is only ever the one window, so floating it out would
    # leave an empty one behind. It stays — movable between edges,
    # never floating, never closed.
    self.project_dock: QDockWidget = _FileDropDock('Project')
    self.project_dock.setFeatures(
        QDockWidget.DockWidgetFeature.DockWidgetMovable)
    self.project_dock.files_dropped.connect(self._import_after_drop)
    self.project_dock.setWidget(tree_holder)
    self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea,
                       self.project_dock)
    # the dock follows its content: expanding a 20-average grid widens
    # it instead of showing one column of the grid through a slot

    self.active_geometry_action: QAction = QAction('Set as &Active Geometry', self)
    # never `connect(self.set_active_geometry)`: `triggered` passes
    # its checked bool, which landed in `name` as False — not None —
    # so the verb skipped the current-item lookup, looked up the
    # object called False, and refused. The menu entry did nothing,
    # ever, and said "Select a geometry" to a selected geometry.
    self.active_geometry_action.triggered.connect(
        lambda _checked=False: self.set_active_geometry())
    self.edit_action: QAction = QAction('&Edit', self)
    self.edit_action.triggered.connect(self.edit_entities)
    # Save As lives on the object's context menu and acts on that
    # one object; the File menu saves only the whole project. It
    # asks for the format in the dialog's own file-type list, which
    # is where a save dialog has always asked, so there is no second
    # verb for the foreign formats.
    self.save_action: QAction = QAction('&Save As...', self)
    self.save_action.setToolTip(
        'Write this object — as a .vdyn, or in a format another '
        'tool can read')
    self.save_action.triggered.connect(self.save_selected)
    self.export_report_action: QAction = QAction('Export &Report...', self)
    self.export_report_action.setToolTip(
        'Write this report as one self-contained HTML file — opens '
        'in any browser, nothing to install')
    self.export_report_action.triggered.connect(self.export_report)
    # identity and issue (report feature `report_identity`): offered
    # on a report's menu while the feature is on
    self.export_report_pdf_action: QAction = QAction(
        'Export Report as P&DF...', self)
    self.export_report_pdf_action.setToolTip(
        'Write this report as a paginated PDF, page n of N on every '
        'page; a draft says so on each page')
    self.export_report_pdf_action.triggered.connect(self.export_report_pdf)
    self.issue_report_action: QAction = QAction('&Issue Report...', self)
    self.issue_report_action.setToolTip(
        'Issue this report as a PDF under its number and revision; a '
        'change after issue is an amendment')
    self.issue_report_action.triggered.connect(self.issue_report)
    self.save_test_action: QAction = QAction('Save &Project As...', self)
    self.save_test_action.setToolTip(
        'Save the whole project — every object, under its name — as '
        'one .vdyn file')
    self.save_test_action.triggered.connect(self.save_test)
    self.delete_action: QAction = QAction('&Delete Selected', self)
    # fires while focus is in the tree, the table or the 3D view — picking
    # in the 3D view selects table rows, so delete has to reach it too.
    # WithChildren: the panes hold focus in a child (a viewport, or VTK's
    # render widget), never themselves.
    self.delete_action.setShortcuts([QKeySequence.StandardKey.Delete,
                                     QKeySequence(Qt.Key.Key_Backspace)])
    self.delete_action.setShortcutContext(
        Qt.ShortcutContext.WidgetWithChildrenShortcut)
    self.delete_action.triggered.connect(self.delete_selected)
    self.tree.addAction(self.delete_action)
    self.scene.addAction(self.delete_action)
    # deliberately not on the table: there, Delete clears the selected
    # cells like it does in every table. It is added back only while
    # editing geometry, where the rows *are* the entities and removing
    # them is what Delete has always meant. See _set_table_model.
    # Escape and Return belong to a geometry editing session: one
    # abandons it, the other makes an element from
    # the nodes picked so far. They were **window** shortcuts, and a
    # window shortcut is answered before the focused widget sees the
    # key — so Return typed into any editor anywhere in the window
    # was swallowed by a verb that was usually a no-op. Renaming a
    # row could not be finished with the Return key; it had to be
    # clicked away from, which reads as an editor that will not
    # close.
    #
    # Scoped to the two places a picking session actually lives, so
    # an editor keeps its own keys. The tree is not one of them.
    for action, keys, verb in (
            ('escape_action', [QKeySequence(Qt.Key.Key_Escape)],
             self.stop_editing),
            ('commit_action', [QKeySequence(Qt.Key.Key_Return),
                               QKeySequence(Qt.Key.Key_Enter)],
             self._commit_picked_nodes)):
        made = QAction('Stop editing' if verb is self.stop_editing
                       else 'Create from picked nodes', self)
        made.setShortcuts(keys)
        made.setShortcutContext(
            Qt.ShortcutContext.WidgetWithChildrenShortcut)
        made.triggered.connect(verb)
        # the render window holds focus in a child of its own, which
        # is why it is WithChildren rather than WidgetShortcut
        self.scene.addAction(made)
        self.table.addAction(made)
        setattr(self, action, made)
    # Copy and Paste on the tree alone: the tables keep their own
    # spreadsheet clipboard, and a copy from the tree is the
    # selection as objects (paste back: duplicates) and as .vdyn
    # files (paste into a folder: an export) — Brandon, 2026-09-03
    self.copy_action: QAction = QAction('&Copy', self)
    self.copy_action.setShortcut(QKeySequence.StandardKey.Copy)
    self.copy_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.copy_action.triggered.connect(self.copy_selected)
    self.tree.addAction(self.copy_action)
    self.paste_action: QAction = QAction('&Paste', self)
    self.paste_action.setShortcut(QKeySequence.StandardKey.Paste)
    self.paste_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.paste_action.triggered.connect(self.paste_objects)
    self.tree.addAction(self.paste_action)
    self.tree.objects_pasted.connect(self._paste_named_objects)
    self.rename_action: QAction = QAction('&Rename', self)
    self.rename_action.setShortcut(QKeySequence(Qt.Key.Key_F2))
    self.rename_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.rename_action.triggered.connect(self.rename_selected)
    self.tree.addAction(self.rename_action)
    self.tree.itemChanged.connect(self._item_renamed)
    self.tree.itemExpanded.connect(self._populate_entities)
    self.tree.setMouseTracking(True)
    self.tree.itemEntered.connect(self._hover_item)

    self.unit_combo: QComboBox = QComboBox()
    self.unit_combo.addItems(list(SYSTEMS))
    self.unit_combo.setCurrentText(DEFAULT_SYSTEM.name)
    self.unit_combo.currentTextChanged.connect(self._units_changed)
    self.unit_combo.setToolTip('The units every view and table shows '
                               'values in; data is kept in SI')
    # right-justified in the status bar, keeping the chrome minimal
    self.statusBar().addPermanentWidget(QLabel('Display units:'))
    self.statusBar().addPermanentWidget(self.unit_combo)

    # deliberately spare: import in, project out. Per-object save,
    # export and delete live on the object's own context menu.
    file_menu = self.menuBar().addMenu('&File')
    file_menu.addAction('&Import...', self.import_files)
    file_menu.addAction(self.save_test_action)
    report_menu = file_menu.addMenu('Generate &Report')
    report_menu.addAction('&Modal Test',
                          lambda: self.generate_report('modal'))
    report_menu.addAction('&Random Vibration',
                          lambda: self.generate_report('random'))
    report_menu.addAction('&Transient',
                          lambda: self.generate_report('transient'))
    report_menu.addAction('&Shock',
                          lambda: self.generate_report('shock'))
    # every template the bar's menu offers (Brandon, 2026-10-02: the
    # sine and the random-and-sine reports were on the bar and not
    # here)
    report_menu.addAction('S&ine Sweep',
                          lambda: self.generate_report('sine'))
    report_menu.addAction('S&ystem ID',
                          lambda: self.generate_report('sysid'))
    report_menu.addAction('MIM&O Random Vibration',
                          lambda: self.generate_report('mimo'))
    report_menu.addAction('&Empty',
                          lambda: self.generate_report('empty'))
    # the one-call reports over a batch of controller runs, the
    # same `run_report` a script calls (Brandon, 2026-10-04)
    file_menu.addAction('Reports from R&uns…', self.reports_from_runs)
    # a run cut to a stretch and written back, nothing else changed
    # (Brandon, 2026-10-13)
    file_menu.addAction('&Truncate Rattlesnake File…', self.truncate_rattlesnake_file)
    # the run log kept on the floor while a test runs, in a dock that
    # floats to a second monitor (gui/run_log_dock.py)
    file_menu.addAction('Run &Log', self.show_run_log)
    file_menu.addSeparator()
    # light, dark, or the platform's choice, remembered between
    # launches: a Linux desktop Qt could not read left a friend of
    # Brandon's with a light window and no way to change it
    # (2026-09-14). One menu, still — File stays the only one.
    # the name an exported ESCDF file records as its creator
    # (Brandon, 2026-09-30: a user setting, since the format asks
    # for one and a library must never prompt for it)
    file_menu.addAction('Creator &Name…', self.set_creator_name)
    appearance = file_menu.addMenu('&Appearance')
    group = QActionGroup(self)
    group.setExclusive(True)
    self.appearance_actions: dict[str, QAction] = {}
    remembered = remembered_appearance()
    for choice, label in (('system', '&System'), ('light', '&Light'),
                          ('dark', '&Dark')):
        action = appearance.addAction(label)
        action.setCheckable(True)
        action.setChecked(choice == remembered)
        action.triggered.connect(
            lambda _checked=False, choice=choice:
            self.choose_appearance(choice))
        group.addAction(action)
        self.appearance_actions[choice] = action
    file_menu.addAction('Check for &Updates...', self.check_for_updates)
    # the About role: macOS moves it into the application menu,
    # where a Mac user looks for a version; Windows and Linux keep
    # it here (Brandon, 2026-09-12: no easy way to see the version)
    about = file_menu.addAction('&About Visual Dynamics...', self.about)
    about.setMenuRole(QAction.MenuRole.AboutRole)
    file_menu.addSeparator()
    file_menu.addAction('&Quit', self.close)

    self._show_status('Import a file to get started')
    # How far along an import is, beside the 'Importing …' text —
    # *beside* it, on the left, which takes more than a layout
    # choice: a temporary status message obscures the status bar's
    # normal (left) widgets, so a permanent widget on the right was
    # the only place a bar survived showMessage. Instead the import
    # brings its own strip — the text and the bar in one left-hand
    # widget — and while it runs, status lines go onto the strip's
    # label rather than through showMessage (see _show_status).
    # The bar itself is hidden except while there is something
    # honest to show: a project file reports per object and a
    # multi-file drop per file, but a single foreign file is one
    # unreported read, and a busy-bar over a blocked event loop
    # cannot animate — a frozen busy-bar reads as a hang, which is
    # worse than no bar.
    self._import_progress: QProgressBar = QProgressBar()
    self._import_progress.setMaximumWidth(200)
    self._import_progress.setTextVisible(False)
    self._import_label: QLabel = QLabel()
    strip = QWidget()
    strip_row = QHBoxLayout(strip)
    strip_row.setContentsMargins(6, 0, 0, 0)
    strip_row.setSpacing(8)
    # bar first, words after: the leftmost thing on the screen is
    # the one moving, and the label reads as its caption
    strip_row.addWidget(self._import_progress)
    strip_row.addWidget(self._import_label)
    # Cancel, beside the bar while a long verb runs (2026-10-02):
    # the one control that takes input while the window is held
    self._cancel_button: QPushButton = QPushButton('Cancel')
    self._cancel_button.setFlat(True)
    self._cancel_button.setToolTip('Stop the running computation (Esc)')
    self._cancel_asked: bool = False
    self._long_worker: Any = None
    #: the worker's progress and end, back on this thread (`_run_long`)
    self._long_relay: _LongVerbRelay = _LongVerbRelay(self)
    self._long_relay.told.connect(self._strip_tick)
    self._cancel_button.clicked.connect(self._ask_cancel)
    strip_row.addWidget(self._cancel_button)
    self._cancel_button.hide()
    self.statusBar().insertWidget(0, strip)
    self._import_strip: QWidget = strip
    strip.hide()
    self._import_progress.hide()

    hints = QApplication.instance().styleHints()
    if hasattr(hints, 'colorSchemeChanged'):
        # a bound method, never a lambda: the style hints live as
        # long as the application, and a lambda capturing self
        # would keep every closed window alive with its project —
        # the 15 MB-a-window leak that grew CI's workers to 2.7 GB
        # (2026-09-13). PySide drops a bound-method connection when
        # its receiver is destroyed.
        hints.colorSchemeChanged.connect(self._scheme_changed)
    # and once now: the panes colored themselves at construction,
    # but the tree's palette only exists in apply_theme — without
    # this it wore the platform's gray until the OS switched theme
    self.apply_theme(self.theme_name)
Attributes
objects property
objects: Project

The project's objects: a mapping of name to object.

speed property
speed: float

How fast the animation runs, as a multiple of the normal rate.

editing_family property
editing_family: str | None

The element family being added to, read from what the table is being edited for: a family row's own family, or for element group rows the family of the first element they hold. None outside the elements and element groups tables, or for groups that hold no elements.

element_type property
element_type: tuple[int, int]

(type code, node count) of the element the clicks build. The family being edited decides it, and an element group with no elements yet gets a triangle, the smallest face.

Methods:
set_playing
set_playing(playing: bool) -> None

Start or stop the animation.

Source code in src/visualdynamics/gui/main_window.py
def set_playing(self, playing: bool) -> None:
    """Start or stop the animation."""
    playing = bool(playing) and self.animator is not None
    self._playing = playing
    if playing:
        self._timer.start()
    else:
        self._timer.stop()
    self.play_action.setEnabled(self.animator is not None and not playing)
    self.pause_action.setEnabled(playing)
choose_appearance
choose_appearance(choice: str) -> None

File → Appearance: remember choice ('light', 'dark' or 'system') and apply it now. 'system' follows the platform's appearance again.

Source code in src/visualdynamics/gui/main_window.py
def choose_appearance(self, choice: str) -> None:
    """File → Appearance: remember `choice` ('light', 'dark' or
    'system') and apply it now. 'system' follows the platform's
    appearance again."""
    remember_appearance(choice)
    for name, action in self.appearance_actions.items():
        action.setChecked(name == choice)
    # the application first — chrome, menus, native widgets — then
    # the parts drawn here; Qt announces the switch through
    # colorSchemeChanged too, which lands on apply_theme as well
    wear_appearance(choice=choice)
    self.apply_theme()
    self._show_status(
        'Following the system appearance' if choice == 'system'
        else f'{choice.capitalize()} appearance, remembered')
apply_theme
apply_theme(name: str | None = None) -> None

Apply the light or dark theme name to every part of the window. By default it is the chosen one: this launch's flag, the remembered appearance, or the platform's.

Source code in src/visualdynamics/gui/main_window.py
def apply_theme(self, name: str | None = None) -> None:
    """Apply the light or dark theme `name` to every part of the
    window. By default it is the chosen one: this launch's flag, the
    remembered appearance, or the platform's."""
    import pyqtgraph as pg

    self.theme_name = name or chosen_scheme()
    colors = resolve_theme(self.theme_name)
    _keep_selection_vivid(self.table)
    pg.setConfigOption('foreground', colors['plot_foreground'])
    self.data_pane.apply_theme(self.theme_name, colors)
    # the scene's own theme name, not just its background: told
    # only to repaint, it repainted in the theme it was built with,
    # and a window opened light stayed white after Dark (Brandon's
    # screenshot, 2026-09-14)
    self.scene.apply_theme(self.theme_name)
    # the tree wears the scene's own ground — one theme for every
    # surface, black or white, never the platform's gray (Brandon,
    # 2026-08-23)
    palette = self.tree.palette()
    palette.setColor(QPalette.ColorRole.Base,
                     QColor(colors['scene_background']))
    palette.setColor(QPalette.ColorRole.Text,
                     QColor(colors['scene_text']))
    self.tree.setPalette(palette)
    _keep_selection_vivid(self.tree)
    # and its viewport, which paints the background and does not
    # take the tree's palette: on Linux the tree went light text on
    # white under Dark (Brandon, 2026-10-29). Elsewhere the platform's
    # own palette had turned dark too, which hid it.
    self.tree.viewport().setPalette(self.tree.palette())
    if self.report is not None:
        self._paint_compatibility()
    self.render_current()
closeEvent
closeEvent(event: QCloseEvent) -> None

Close every VTK view (the scene, the 3-D data view and the MAC bars) before Qt destroys the widgets.

Without this, closing the window after a 3-D view has rendered crashes: the render window is torn down with its GL context still live.

Source code in src/visualdynamics/gui/main_window.py
def closeEvent(self, event: QCloseEvent) -> None:
    """Close every VTK view (the scene, the 3-D data view and the MAC
    bars) before Qt destroys the widgets.

    Without this, closing the window after a 3-D view has rendered
    crashes: the render window is torn down with its GL context still
    live.
    """
    # All three of them, not just the scene: the stage and the MAC
    # bars have the same interactor, the same GL context and the same
    # timers, and the scene-only version left them to take their
    # chances (Brandon's crash report, 2026-08-30).
    self.set_playing(False)
    # a synthesis waiting for its settings to rest would otherwise
    # iterate and draw into a window being torn down
    self._synthesis_settle.stop()
    # and the report page: a navigation in flight when the view is
    # destroyed is the gate's stall (ReportEditor.stand_down)
    if self.report_editor is not None:
        self.report_editor.stand_down()
    # the run log: its place remembered, every entry already given
    # written, and its lock released, before the dock goes
    if self.run_log_dock is not None:
        self._remember_run_log_place()
        self.run_log_dock.panel.shut_down()
    plotters = [self.scene.built_plotter, self.data_pane.waterfall_plotter,
                self._mac_bars_plotter_obj]
    self.scene.plotter = None
    self.data_pane.waterfall_plotter = None
    self._mac_bars_plotter_obj = None
    for plotter in plotters:
        if plotter is not None:
            with contextlib.suppress(Exception):  # never block closing
                plotter.close()
    super().closeEvent(event)
show_run_log
show_run_log(path: str | None = None, *, log_only: bool | None = None) -> Any

Show the Run Log dock, built the first time and put back where it was last left, and return it.

path names a log: one that exists is opened (read-only, saying who holds it, when another program is writing it); one that does not is offered to the New Log form, written only when the keeper confirms it. log_only sets the layout (set_log_only).

Source code in src/visualdynamics/gui/main_window.py
def show_run_log(self, path: str | None = None, *,
                 log_only: bool | None = None) -> Any:
    """Show the Run Log dock, built the first time and put back where
    it was last left, and return it.

    `path` names a log: one that exists is opened (read-only, saying
    who holds it, when another program is writing it); one that does
    not is offered to the New Log form, written only when the keeper
    confirms it. `log_only` sets the layout (`set_log_only`).
    """
    import os

    from .run_log_dock import RunLogDock, restore_place

    dock = self.run_log_dock
    if dock is None:
        dock = self.run_log_dock = RunLogDock(self)
        restore_place(dock, self)
        dock.panel.layout_wanted.connect(self.set_log_only)
        dock.topLevelChanged.connect(self._run_log_moved)
        dock.dockLocationChanged.connect(self._run_log_moved)
        dock.panel.show_layout(self.run_log_only)
        for kind, (prediction, source) in self.run_log_predictions.items():
            dock.panel.offer_prediction(kind, prediction, source)
    dock.show()
    dock.raise_()
    if log_only is not None:
        self.set_log_only(log_only)
    if path is not None:
        if os.path.exists(path):
            dock.panel.open_log(path)
        else:
            form = dock.panel.open_form('new log')
            if form is not None:
                form.set_box('path', path)
    return dock
set_log_only
set_log_only(on: bool) -> None

Show the Run Log alone, filling the window, with the project tree and the 3-D and plot panes hidden (on), or the whole window with the dock back where it was left.

Nothing is drawn while the log is alone, so no 3-D view is built and no OpenGL check is run. Going back to the whole window draws what is selected, and the first 3-D view it builds asks the check first (opengl.ready), as every first 3-D view does.

Source code in src/visualdynamics/gui/main_window.py
def set_log_only(self, on: bool) -> None:
    """Show the Run Log alone, filling the window, with the project
    tree and the 3-D and plot panes hidden (`on`), or the whole window
    with the dock back where it was left.

    Nothing is drawn while the log is alone, so no 3-D view is built
    and no OpenGL check is run. Going back to the whole window draws
    what is selected, and the first 3-D view it builds asks the check
    first (`opengl.ready`), as every first 3-D view does.
    """
    from .run_log_dock import restore_place

    dock = self.show_run_log() if on else self.run_log_dock
    if dock is None:
        return
    if on != self.run_log_only:
        if on:
            # its place as the keeper left it, kept for the way back
            self._remember_run_log_place()
            self.run_log_only = True
            dock.setFloating(False)
            self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea,
                               dock)
            # closing the dock would leave an empty window
            dock.setFeatures(
                dock.features()
                & ~QDockWidget.DockWidgetFeature.DockWidgetClosable)
            self.centralWidget().hide()
            self.project_dock.hide()
            dock.show()
            self._show_status('The run log alone: Whole Window on its '
                              'bar shows the project and its views')
        else:
            self.run_log_only = False
            dock.setFeatures(
                dock.features()
                | QDockWidget.DockWidgetFeature.DockWidgetClosable)
            self.centralWidget().show()
            self.project_dock.show()
            restore_place(dock, self)
            dock.show()
            self.render_current()
    dock.panel.show_layout(on)
add_object
add_object(name: str, obj: Any, alternate: str | None = None, source: str | None = None, key: Any = None) -> str

Add obj to the project under a unique name, give it a row in the tree, and return the name used.

When name is taken, alternate is tried first, and a name still taken is numbered. source is the file the object was read from and key what that file's reader called it; both go in the row's tooltip, because the name says neither.

Source code in src/visualdynamics/gui/main_window.py
def add_object(self, name: str, obj: Any, alternate: str | None = None,
               source: str | None = None,
               key: Any = None) -> str:
    """Add `obj` to the project under a unique name, give it a row in
    the tree, and return the name used.

    When `name` is taken, `alternate` is tried first, and a name
    still taken is numbered. `source` is the file the object was read
    from and `key` what that file's reader called it; both go in the
    row's tooltip, because the name says neither.
    """
    if name in self.objects and alternate:
        name = alternate
    return self.show_object(self.project.add(name, obj),
                            source=source, key=key)
show_object
show_object(name: str, source: str | None = None, key: Any = None, select: bool = True) -> str

Give an object already in the project its row in the tree, and return its name.

A project verb (a computation, a fit, a projection) puts its result in the project, and this adds the row for it. source and key go in the tooltip, as in add_object. With select, the new row is selected. An object that has its row already keeps it, brought up to date, rather than gaining a second.

Source code in src/visualdynamics/gui/main_window.py
def show_object(self, name: str, source: str | None = None,
                key: Any = None, select: bool = True) -> str:
    """Give an object already in the project its row in the tree, and
    return its name.

    A project verb (a computation, a fit, a projection) puts its
    result in the project, and this adds the row for it. `source`
    and `key` go in the tooltip, as in `add_object`. With `select`,
    the new row is selected. An object that has its row already
    keeps it, brought up to date, rather than gaining a second.
    """
    obj = self.objects[name]
    # One row per object. The specification sheet shows the object it
    # makes a tick later, and every render before that tick asked
    # again: five rows for one object, and deleting it left four
    # rows naming nothing (Brandon, 2026-10-22).
    existing = self._item_for_object(name)
    if existing is not None:
        self._refresh_item(existing, obj)
        if select:
            self.tree.setCurrentItem(existing)
            self.tree.clearSelection()
            existing.setSelected(True)
        return name
    item = QTreeWidgetItem([name])
    item.setIcon(0, object_icon(obj, units_defined=_units_defined(obj)))
    self.object_sources[name] = source
    self.object_keys[name] = key
    item.setToolTip(0, self._object_tooltip(name, obj))
    self._mark_computable(item, obj)
    item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
    # what the tree reads as "this row is an object", and so is
    # draggable between object groups: sub-items are parts of one
    item.setData(0, ROLE_DRAGGABLE, True)
    item.setData(0, ROLE_ORIGINAL_NAME, name)  # name before an edit
    item.setData(0, ROLE_REFERENCE, ('object', name, None))
    self._build_children(item, obj, name)
    self.test_item.addChild(item)
    self.test_item.setExpanded(True)
    self.refresh_compatibility()
    self._reorder_tree()    # canonical type order, then placeholders
    self._apply_type_links([name])
    self._paint_links()
    # a new object may be what a report figure was waiting for
    self._report_content_changed(settling=True)
    if select:
        self.tree.setCurrentItem(item)
        self.tree.clearSelection()
        item.setSelected(True)
    return name
add_solid_block_act
add_solid_block_act() -> None

Open the add pane for a solid block in the selected geometry: a center, three widths, three angles and a name. Each Add runs the project's add_solid_block.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_block_act(self) -> None:
    """Open the add pane for a solid block in the selected geometry:
    a center, three widths, three angles and a name. Each Add runs
    the project's `add_solid_block`."""
    self._open_mesh_pane('solid_block')
add_solid_cylinder_act
add_solid_cylinder_act() -> None

Open the add pane for a solid cylinder in the selected geometry: a center, a radius, a length along its own Z, three angles and a name. Each Add runs the project's add_solid_cylinder.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_cylinder_act(self) -> None:
    """Open the add pane for a solid cylinder in the selected
    geometry: a center, a radius, a length along its own Z, three
    angles and a name. Each Add runs the project's
    `add_solid_cylinder`."""
    self._open_mesh_pane('solid_cylinder')
add_solid_section_act
add_solid_section_act() -> None

Open the add pane for a solid section in the selected geometry: an I-beam, channel, tee, angle or tube, its dimensions, a length along its own Z, a center, three angles and a name. Each Add runs the project's add_solid_section.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_section_act(self) -> None:
    """Open the add pane for a solid section in the selected
    geometry: an I-beam, channel, tee, angle or tube, its dimensions,
    a length along its own Z, a center, three angles and a name. Each
    Add runs the project's `add_solid_section`."""
    self._open_mesh_pane('solid_section')
add_solid_cone_act
add_solid_cone_act() -> None

Open the add pane for a cone in the selected geometry: a center, a base radius, a top radius, a height along its own Z, three angles and a name. Each Add runs the project's add_solid_cone.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_cone_act(self) -> None:
    """Open the add pane for a cone in the selected geometry: a
    center, a base radius, a top radius, a height along its own Z,
    three angles and a name. Each Add runs the project's
    `add_solid_cone`."""
    self._open_mesh_pane('solid_cone')
add_solid_sphere_act
add_solid_sphere_act() -> None

Open the add pane for a sphere in the selected geometry: a center, a radius and a name. Each Add runs the project's add_solid_sphere.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_sphere_act(self) -> None:
    """Open the add pane for a sphere in the selected geometry: a
    center, a radius and a name. Each Add runs the project's
    `add_solid_sphere`."""
    self._open_mesh_pane('solid_sphere')
add_solid_plate_act
add_solid_plate_act() -> None

Open the add pane for a plate in the selected geometry: a center, two widths in its own X-Y plane, three angles and a name. Each Add runs the project's add_solid_plate.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_plate_act(self) -> None:
    """Open the add pane for a plate in the selected geometry: a
    center, two widths in its own X-Y plane, three angles and a
    name. Each Add runs the project's `add_solid_plate`."""
    self._open_mesh_pane('solid_plate')
add_solid_disk_act
add_solid_disk_act() -> None

Open the add pane for a disk in the selected geometry: a center, a radius in its own X-Y plane, three angles and a name. Each Add runs the project's add_solid_disk.

Source code in src/visualdynamics/gui/main_window.py
def add_solid_disk_act(self) -> None:
    """Open the add pane for a disk in the selected geometry: a
    center, a radius in its own X-Y plane, three angles and a name.
    Each Add runs the project's `add_solid_disk`."""
    self._open_mesh_pane('solid_disk')
combine_solids_act
combine_solids_act(operation: str) -> None

Combine the picked solids into one by operation ('union', 'subtract' or 'intersect'), with the project's combine_solids, and select the new solid.

The solids are passed in tree order, so the first in the tree is the body a subtraction cuts. A refusal is shown in the status bar.

Source code in src/visualdynamics/gui/main_window.py
def combine_solids_act(self, operation: str) -> None:
    """Combine the picked solids into one by `operation` ('union',
    'subtract' or 'intersect'), with the project's `combine_solids`,
    and select the new solid.

    The solids are passed in tree order, so the first in the tree is
    the body a subtraction cuts. A refusal is shown in the status
    bar."""
    chosen = self._selected_solids()
    if chosen is None:
        return
    name, ids = chosen
    try:
        new = self.project.combine_solids(name, operation, ids)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    self._solids_changed(name, [new])
    self._show_status(f'{name}: {len(ids)} solids made one by '
                      f'{SOLID_LABELS[operation].lower()}')
separate_solids_act
separate_solids_act() -> None

Take the picked booleans and patterns apart, with the project's separate_solids, and select the solids they were made of.

Source code in src/visualdynamics/gui/main_window.py
def separate_solids_act(self) -> None:
    """Take the picked booleans and patterns apart, with the
    project's `separate_solids`, and select the solids they were made
    of."""
    from ..core import solids

    chosen = self._selected_solids()
    if chosen is None:
        return
    name, ids = chosen
    freed = [o for i in ids for o in solids.solid(self.objects[name], i).operands]
    try:
        self.project.separate_solids(name, ids)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    self._solids_changed(name, freed)
    self._show_status(f'{name}: taken apart into {len(freed)} solids')
mesh_solids_act
mesh_solids_act() -> None

Open the Mesh Solids pane on the picked solids.

The pane beside the view has the element size, starting at half the thinnest feature, and the bricks it would make are drawn as it is typed. Mesh runs the project's mesh_solids.

Source code in src/visualdynamics/gui/main_window.py
def mesh_solids_act(self) -> None:
    """Open the Mesh Solids pane on the picked solids.

    The pane beside the view has the element size, starting at half
    the thinnest feature, and the bricks it would make are drawn as
    it is typed. Mesh runs the project's `mesh_solids`."""
    # Brandon, 2026-10-11.
    from ..core import solids

    chosen = self._selected_solids()
    if chosen is None:
        return
    name, ids = chosen
    geometry = self.objects[name]
    if self._mesh is not None:
        self.close_mesh_pane()
    if self._merging is not None:
        self.close_merge_pane(redraw=False)
    if self._mating is not None:
        self.close_mate_pane(redraw=False)
    defined = geometry.units_defined
    unit = self.unit_system.label_text('length') if defined else 'units'
    # the size a mesh is likely to hold at, the one Detect Element
    # Size gives (Brandon, 2026-10-19: the pane opened at 0.95 in on
    # a block with 1 in holes, a wall misread)
    # a plate or a disk among them takes shells only (2026-10-26)
    flat = any(solids.is_flat(geometry, i) for i in ids)
    size = self._mesh_size_likely(geometry, ids, 'quads' if flat else 'hexes')
    shown = (float(self.unit_system.from_si(size, 'length')) if defined
             else size)
    names = [(s.name.strip() or f'{SOLID_LABELS[s.kind]} {s.id}')
             for s in (solids.solid(geometry, i) for i in ids)]
    self._meshing = {'name': name, 'ids': ids, 'defined': defined,
                     'unit': unit}
    panel = self.scene.mesh_solids_panel
    panel.open_for(unit, shown, ', '.join(names), len(ids), flat=flat)
    self._mesh_pieces_layout()
    panel.show()
    self._mesh_solids_refresh()
close_mesh_solids_pane
close_mesh_solids_pane(redraw: bool = True) -> None

Close the Mesh Solids pane and remove its preview. With redraw, the view is drawn again.

Source code in src/visualdynamics/gui/main_window.py
def close_mesh_solids_pane(self, redraw: bool = True) -> None:
    """Close the Mesh Solids pane and remove its preview. With
    `redraw`, the view is drawn again."""
    self.scene.mesh_solids_panel.hide()
    if self.__dict__.get('_meshing') is None:
        return
    self._meshing = None
    self._draw_plane_preview(None)
    if redraw:
        self.render_current()
pattern_solids_act
pattern_solids_act() -> None

Open the Pattern pane on the picked solids.

The pane has how many copies along X, Y and Z and how far apart, and the copies are drawn as they are typed. Pattern runs the project's pattern_solids, which makes one solid of them.

Source code in src/visualdynamics/gui/main_window.py
def pattern_solids_act(self) -> None:
    """Open the Pattern pane on the picked solids.

    The pane has how many copies along X, Y and Z and how far apart,
    and the copies are drawn as they are typed. Pattern runs the
    project's `pattern_solids`, which makes one solid of them."""
    # Brandon, 2026-10-11.
    chosen = self._selected_solids()
    if chosen is None:
        return
    self._open_pattern_pane(*chosen)
close_pattern_pane
close_pattern_pane(redraw: bool = True) -> None

Close the Pattern pane and remove its preview. With redraw, the view is drawn again.

Source code in src/visualdynamics/gui/main_window.py
def close_pattern_pane(self, redraw: bool = True) -> None:
    """Close the Pattern pane and remove its preview. With `redraw`,
    the view is drawn again."""
    self.scene.pattern_panel.hide()
    patterning = self.__dict__.get('_patterning')
    if patterning is None:
        return
    self._patterning = None
    self._draw_plane_preview(None)
    if patterning.get('editing') is not None:
        self._mark_pencils([self._item_for_object(patterning['name'])])
    if redraw:
        self.render_current()
current_reference
current_reference() -> tuple[str | None, Any, Any]

(kind, object, detail) for the current tree item, where detail is an index or a component; all None when there is none.

Source code in src/visualdynamics/gui/main_window.py
def current_reference(self) -> tuple[str | None, Any, Any]:
    """`(kind, object, detail)` for the current tree item, where
    `detail` is an index or a component; all None when there is
    none."""
    item = self.tree.currentItem()
    if item is None:
        return None, None, None
    reference = item.data(0, ROLE_REFERENCE)
    if reference is None:
        return None, None, None
    kind, name, detail = reference
    return kind, self.objects.get(name), detail
current_object
current_object() -> Any

The object the current tree item belongs to (a sub-item gives its object), or None.

Source code in src/visualdynamics/gui/main_window.py
def current_object(self) -> Any:
    """The object the current tree item belongs to (a sub-item gives
    its object), or None."""
    return self.current_reference()[1]
selected_references
selected_references() -> list[tuple[str, str, Any, Any]]

(kind, name, object, detail) for every selected tree item.

The project row stands for every object in the project, records picked in an object's grid stand for themselves, and gray slots are left out.

Source code in src/visualdynamics/gui/main_window.py
def selected_references(self) -> list[tuple[str, str, Any, Any]]:
    """`(kind, name, object, detail)` for every selected tree item.

    The project row stands for every object in the project, records
    picked in an object's grid stand for themselves, and gray slots
    are left out."""
    out = []
    for item in self.tree.selectedItems():
        reference = item.data(0, ROLE_REFERENCE)
        if reference is None:
            continue
        kind, name, detail = reference
        if kind == 'placeholder':   # a gray slot holds nothing yet
            continue
        if kind == 'test':  # the whole test: everything it holds
            out.extend(('object', key, value, None)
                       for key, value in self.objects.items())
            continue
        obj = self.objects.get(name)
        if obj is None:
            continue
        grid = self.record_grids.get(name) if kind == 'object' else None
        records = grid.selected_records() if grid is not None else []
        if records:
            # the grid's own selection is the sub-item selection — there
            # is nowhere else for the two to disagree — and its kind
            # keeps the tree's vocabulary: record, channel or mode
            out.extend((grid.kind, name, obj, i) for i in records)
        else:
            out.append((kind, name, obj, detail))
    return out
object_item
object_item(item: QTreeWidgetItem | None = None) -> QTreeWidgetItem | None

The object's row for item (the current item by default), found by walking up the tree; None for the project row.

Source code in src/visualdynamics/gui/main_window.py
def object_item(self,
                item: QTreeWidgetItem | None = None
                ) -> QTreeWidgetItem | None:
    """The object's row for `item` (the current item by default),
    found by walking up the tree; None for the project row."""
    item = item or self.tree.currentItem()
    if item is self.test_item:
        return None
    while item is not None and item.parent() not in (None, self.test_item):
        item = item.parent()
    return item
delete_selected
delete_selected() -> None

Delete what is selected.

While a geometry's table is being edited, its selected rows are deleted. Otherwise, picked solids, entities (nodes, elements and the like) or components are deleted from their geometry, and picked records, modes, channels or photos from their object, and picked sets of mated surfaces from their geometry. Only when no sub-item is picked are whole objects deleted, so an object selected beside a sub-item is not deleted with it.

Source code in src/visualdynamics/gui/main_window.py
def delete_selected(self) -> None:
    """Delete what is selected.

    While a geometry's table is being edited, its selected rows are
    deleted. Otherwise, picked solids, entities (nodes, elements and
    the like) or components are deleted from their geometry, and
    picked records, modes, channels or photos from their object, and
    picked sets of mated surfaces from their geometry. Only
    when no sub-item is picked are whole objects deleted, so an object
    selected beside a sub-item is not deleted with it."""
    if self.editing is not None:
        return self.delete_entity_rows()
    chosen = self._selected_solids()
    if chosen is not None:
        return self._delete_solids(*chosen)
    chosen = self._selected_mates()
    if chosen is not None:
        return self._delete_mates(*chosen)
    entities = self._selected_entities()
    if entities:
        return self._delete_entities(*entities)
    emptied = self._selected_components()
    if emptied:
        return self._empty_components(*emptied)
    if any(item is self.test_item for item in self.tree.selectedItems()):
        self._show_status('Select an object inside the test to delete it')
        return
    # Sub-items go before objects: a record, mode or channel picked in
    # the tree or in a grid deletes that record, not — as it used to —
    # the whole object it lives in. An object-level pick still deletes
    # the object, and outranks sub-item picks of the same object.
    doomed, subitems = {}, {}
    for kind, name, obj, detail in self.selected_references():
        if kind == 'object':
            doomed[name] = obj
        elif kind in ('record', 'mode', 'channel', 'photo'):
            subitems.setdefault(name, (obj, kind, set()))[2].add(detail)
    if subitems:
        # Sub-item picks make the whole keystroke about sub-items. A
        # co-selected object is context, not a target — a geometry is
        # selected so records can animate on it, and deleting one grid
        # record must not take the geometry with it. That happened: one
        # record and the whole geometry went in a single keystroke.
        doomed = {}
    if not doomed and not subitems:
        return
    removed = []
    for name, (obj, kind, picks) in subitems.items():
        deleter = {'record': 'delete_records', 'mode': 'delete_modes',
                   'channel': 'delete_channels',
                   'photo': 'delete_photos'}[kind]
        line = self._deletion_line(name, obj, deleter, picks)
        try:
            getattr(obj, deleter)(picks)
        except ValueError as refusal:
            self._show_status(f'{name}: {refusal}')
            return
        self.project.journal.append(line)
        self._refresh_item(self._item_for_object(name), obj)
        removed.append(f'{len(picks)} {kind}'
                       + ('s' if len(picks) != 1 else '')
                       + f' from {name}')
    if doomed:
        # One selection change for the whole batch, not one per
        # removal: taking rows out one at a time promotes a
        # neighbor to current each time, and the handler
        # re-rendered each survivor's view only for the next
        # removal to kill it — the specification plot blinked once
        # per object on a delete-all (Brandon, 2026-08-31). The
        # links and placeholders settle once too, after the loop.
        with QSignalBlocker(self.tree):
            self.tree.clearSelection()
            self.tree.setCurrentItem(None)
            for name in doomed:
                self._remove_object(name, settle=False)
        self._paint_links()
        self._refresh_placeholders()
        removed.extend(doomed)
    self.refresh_compatibility()
    self.render_current()
    self._show_status('Removed ' + ', '.join(removed))
acts_for
acts_for(names=None)

The acts the selection can take, as a list of (verb, label, icon, handler, tooltip) in the bar's order.

names is the selection. When it is left out, it is read from the tree, a record pick counting as its object. With the project row selected, the one act offered is the project's report. A stale object offers Recompute first, ahead of anything else it can do.

Source code in src/visualdynamics/gui/main_window.py
def acts_for(self, names=None):
    """The acts the selection can take, as a list of `(verb, label,
    icon, handler, tooltip)` in the bar's order.

    `names` is the selection. When it is left out, it is read from the
    tree, a record pick counting as its object. With the project row
    selected, the one act offered is the project's report. A stale
    object offers Recompute first, ahead of anything else it can do.
    """
    if names is None:
        # two element groups or more of one geometry, picked in the tree, that
        # may be one (Brandon, 2026-09-27): only when they may —
        # a refused merge is not offered (principle 3)
        picked = self._selected_entities()
        if picked is not None and picked[1] == 'groups':
            geometry = self.objects.get(picked[0])
            acts = []
            if (geometry is not None and len(picked[2]) > 1
                    and geometry.merge_refusal(picked[2]) is None):
                acts.append(
                    ('merge_groups', 'Merge Element Groups', 'merge_groups',
                     lambda: self.merge_groups_act(picked[0], picked[2]),
                     ('Make the selected element groups one element '
                      'group: the first keeps its name and properties')))
            # only where there is something to tie: groups holding
            # elements, as the geometry's own test asked of it
            if geometry is not None and geometry.nodes_in(picked[2]):
                label = next(label for verb, label, _icon, _handler
                             in self.ACTS
                             if verb == 'merge_coincident_nodes')
                acts.append(
                    ('merge_coincident_nodes', label, 'merge_nodes',
                     lambda: self.merge_nodes_act(picked[0], picked[2]),
                     ('Make the coincident nodes of the selected '
                      'element groups one node — what ties parts '
                      'meshed apart where they meet')))
                # the step before the merge, where the parts' meshes
                # do not match (Brandon, 2026-10-11)
                acts.append(
                    ('align_meshes', 'Align Meshes', 'align_meshes',
                     lambda: self.align_meshes_act(picked[0], picked[2]),
                     ("Move the coarser mesh's nodes onto the finer's "
                      'where the selected element groups\' parts meet '
                      'or overlap, so they can be merged')))
            # two groups or more with surfaces, plates or solids, to
            # mate (Brandon, 2026-10-20)
            from ..core.mates import _solid_or_plate_groups

            if (geometry is not None and len(picked[2]) > 1
                    and len(_solid_or_plate_groups(geometry, picked[2])) > 1):
                acts.append(
                    ('mate_surfaces', 'Mate Surfaces', 'mate_surfaces',
                     lambda: self.mate_surfaces_act(picked[0], picked[2]),
                     ('Find where the selected element groups\' surfaces '
                      'mate, and join them there rigidly or by springs')))
            if acts:
                return acts
        if self.test_item.isSelected():
            return [('generate_report', 'Generate Report', 'report',
                     self.generate_report_act,
                     ('Generate the report this project\'s type '
                      'calls for')),
                    # somewhere to build a model: without it a
                    # geometry could only be imported (Brandon,
                    # 2026-09-26, a "+" on the project)
                    ('new_geometry', 'New Geometry', 'new_geometry',
                     self.new_geometry_act,
                     ('An empty geometry, in the display units, to '
                      'build a part in from solids')),
                    # and a channel table to fill in by hand
                    # (Brandon, 2026-10-22)
                    ('new_channel_table', 'New Channel Table',
                     'new_channel_table', self.new_channel_table_act,
                     'An empty channel table, to add channels to')]
        solid_acts = self._solid_acts()
        if solid_acts is not None:
            return solid_acts
        references = self.selected_references()
        names = list(dict.fromkeys(
            name for kind, name, _obj, _detail in references
            if kind in ('object', 'record')))
        # modes picked in one shape set's grid: the modes a test is
        # planned for (Brandon, 2026-10-21), and nothing else is
        # offered on a mode pick
        modes_of = {name for kind, name, _obj, _detail in references if kind == 'mode'}
        if not names and len(modes_of) == 1:
            name = next(iter(modes_of))
            applicable = dict(self.project.selection_verbs(name))
            return [(verb, label, icon, getattr(self, handler), applicable[verb])
                    for verb, label, icon, handler in self.ACTS
                    if verb == 'plan_instrumentation' and verb in applicable]
        # rows picked in one channel table's grid beside one whole
        # shape set: the set projected at those channels alone
        # (Brandon, 2026-10-31); the set's own acts would ignore the
        # picks, so this is the one offered
        if self._projection_picks() is not None:
            return [(verb, label, icon, getattr(self, handler),
                     ('Project the shape set onto the picked channels\' '
                      'DOFs, on the table\'s geometry'))
                    for verb, label, icon, handler in self.ACTS
                    if verb == 'project_onto_basis']
        # rows picked in one channel table's grid: a signal at the
        # picked reference channels (Brandon, 2026-10-30)
        if not names and self._signal_source() is not None:
            return [(verb, label, icon, getattr(self, handler),
                     'Generate a signal at the picked reference channels')
                    for verb, label, icon, handler in self.ACTS
                    if verb == 'generate_signal']
    names = [name for name in names if name in self.objects]
    if not names:
        return []
    applicable = dict(self.project.selection_verbs(*names))
    elsewhere = self.GROUP_ACTS | set(self.SOLID_ACTS)
    acts = [(verb, label, icon, getattr(self, handler),
             applicable[verb])
            for verb, label, icon, handler in self.ACTS
            if verb in applicable and verb not in elsewhere]
    if (not any(act[0] == 'compute_modal_responses' for act in acts)
            and self._modal_candidates() is not None):
        # modes picked in a set's grid are not objects, so the pair
        # table never sees them; the act reads the picks itself
        acts += [(verb, label, icon, getattr(self, handler),
                  ('Compute how each picked mode answers the forces, '
                   'as modal responses of their own'))
                 for verb, label, icon, handler in self.ACTS
                 if verb == 'compute_modal_responses']
    cross = (len(names) == 1 and isinstance(self.objects[names[0]], Psd)
             and not self.objects[names[0]].is_autospectrum())
    if cross:
        # the label follows the object (Brandon, 2026-10-29)
        acts = [(verb, 'Convert to CPSD', *rest) if verb == 'convert_to_psd'
                else (verb, label, *rest) for verb, label, *rest in acts]
    if len(names) == 1 and names[0] in self._stale:
        acts.insert(0, ('refresh', 'Recompute', 'refresh',
                        self.refresh_selected,
                        (f'Settings changed: {self._stale[names[0]]}. '
                         'Recompute this object from its source')))
    return acts
save_animation
save_animation(path: str | None = None) -> str | None

Save the 3-D view's animation as an MP4, the way it plays: this camera, scale and speed. Returns the path, or None when there is no animation or the save was canceled or failed.

A mode shape's phase is saved as whole cycles lasting at least six seconds, and a record or a sweep of lines is saved once through (viz.animate.cycle_parameters and sweep_parameters, the same rule a script's movie= uses). Each frame is drawn the way the window draws it, so the caption an envelope writes for each line is in every frame, and playback is put back where it was afterwards. A record or a sweep of lines is saved with its plot under the scene, the cursor where the frame is (movie.over_plot), so the movie says where in the record each frame is. With path, no dialog is shown.

Source code in src/visualdynamics/gui/main_window.py
def save_animation(self, path: str | None = None) -> str | None:
    """Save the 3-D view's animation as an MP4, the way it plays: this
    camera, scale and speed. Returns the path, or None when there is
    no animation or the save was canceled or failed.

    A mode shape's phase is saved as whole cycles lasting at least six
    seconds, and a record or a sweep of lines is saved once through
    (`viz.animate.cycle_parameters` and `sweep_parameters`, the same
    rule a script's `movie=` uses). Each frame is drawn the way the
    window draws it, so the caption an envelope writes for each line
    is in every frame, and playback is put back where it was
    afterwards. A record or a sweep of lines is saved with its plot
    under the scene, the cursor where the frame is (`movie.over_plot`),
    so the movie says where in the record each frame is. With `path`,
    no dialog is shown.
    """
    from ..viz.animate import (
        cycle_parameters,
        movie_frames,
        sweep_parameters,
    )
    from .movie import over_plot, write_movie

    if self.animator is None or self.scene.plotter is None:
        return None
    if path is None:
        item = self.object_item()
        name = item.text(0) if item is not None else 'Animation'
        path, _ = QFileDialog.getSaveFileName(
            self, 'Save Animation', f'{name}.mp4', 'MP4 video (*.mp4)')
        if not path:
            return None
        if not path.lower().endswith('.mp4'):
            path += '.mp4'
    playing = self._playing
    self.set_playing(False)
    phase = self.phase_slider.value()
    held = self._cursor.value() if self._cursor is not None else None
    if self._shape_mode:
        parameters = cycle_parameters(SECONDS_PER_CYCLE / self.speed)
        step = self.animator.set_parameter
    else:
        parameters = sweep_parameters(len(self._cursor_abscissa),
                                      SECONDS_PER_RECORD / self.speed)

        def step(index):
            # the cursor, not the animator: it is where an envelope
            # rewrites its caption, so every frame carries its line
            self._cursor.setValue(float(self._cursor_abscissa[index]))

    # seconds on a large model or a Retina screen, with input held
    # off: say so, rather than look hung
    self._show_status(f'Saving {os.path.basename(path)}…')
    QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
    frames = movie_frames(self.scene.plotter, step, parameters)
    if not self._shape_mode and self.data_pane.graphics.isVisible():
        # the plot and its cursor under the scene (Brandon,
        # 2026-10-25: so the user can see the slider and know where
        # in time the animation is)
        frames = over_plot(frames, self.data_pane.graphics)
    try:
        count = write_movie(path, frames)
    except RuntimeError as error:      # MovieUnavailable among them
        self._show_status(str(error))
        return None
    finally:
        QApplication.restoreOverrideCursor()
        if held is not None:
            self._cursor.setValue(held)
        if self._shape_mode:
            self._draw_frame(phase / PHASE_STEPS * 2 * np.pi)
        self.set_playing(playing)
    self._show_status(
        f'Saved {os.path.basename(path)} — {count / FRAMES_PER_SECOND:.3g} s, '
        f'{os.path.getsize(path) / 1e6:.2g} MB')
    return path
refresh_selected
refresh_selected() -> None

Recompute the selected stale object. This is the bar's Recompute, the same act as a click on the object's badge.

Source code in src/visualdynamics/gui/main_window.py
def refresh_selected(self) -> None:
    """Recompute the selected stale object. This is the bar's
    Recompute, the same act as a click on the object's badge."""
    item = self.object_item()
    if item is not None:
        self.refresh_object(item.text(0))
generate_report_act
generate_report_act() -> None

The bar's Generate Report.

A project with a type gets its report at once. A project without one gets a menu of the built-in templates. Whenever there are saved templates, the menu is shown in both cases, with the saved templates listed after the built-in ones.

Source code in src/visualdynamics/gui/main_window.py
def generate_report_act(self) -> None:
    """The bar's Generate Report.

    A project with a type gets its report at once. A project without
    one gets a menu of the built-in templates. Whenever there are
    saved templates, the menu is shown in both cases, with the saved
    templates listed after the built-in ones."""
    # Saved templates in the menu: 2026-09-08.
    from ..io.report_template import saved_templates

    saved = saved_templates()
    if self.project_type and not saved:
        # the type already says which report this project is for,
        # so there is nothing to choose — one click, one report
        self.generate_typed_report()
        return
    menu = QMenu(self)
    if self.project_type:
        plural = 's' * (len(PROJECT_TEMPLATES[self.project_type]) > 1)
        menu.addAction(f'Generate {self.project_type} Report{plural}',
                       self.generate_typed_report)
    else:
        for label, template in (('&Modal Test', 'modal'),
                                ('&Random Vibration', 'random'),
                                ('&Transient', 'transient'),
                                ('&Shock', 'shock'),
                                ('S&ine Sweep', 'sine'),
                                ('S&ystem ID', 'sysid'),
                                ('MIM&O Random Vibration', 'mimo'),
                                ('&Empty', 'empty')):
            menu.addAction(f'Generate {label} Report',
                           lambda _checked=False, t=template:
                           self.generate_report(t))
    if saved:
        menu.addSeparator()
        for label, path in saved:
            menu.addAction(f'Generate {label} Report (saved template)',
                           lambda _checked=False, t=str(path):
                           self.generate_report(t))
    self._pop_menu(menu)
refresh_object
refresh_object(name: str) -> None

Recompute the stale object name in place, as a click on its badge does. A refusal is shown in the status bar.

Source code in src/visualdynamics/gui/main_window.py
def refresh_object(self, name: str) -> None:
    """Recompute the stale object `name` in place, as a click on its
    badge does. A refusal is shown in the status bar."""
    reason = self._stale.get(name, '')
    try:
        self.project.refresh(name)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    obj = self.objects[name]
    item = self._item_for_object(name)
    if item is not None:
        item.setToolTip(0, self._object_tooltip(name, obj))
        self._build_children(item, obj, name)
    self._refresh_stale_badges()
    self._report_content_changed()
    self.render_current()
    followed = [other for other in self._stale
                if self.provenance_source(other) == name]
    told = (f' — {", ".join(followed)} now stale in turn'
            if followed else '')
    self._show_status(
        f'{name} recomputed'
        + (f' ({reason})' if reason else '') + told)
linked_group
linked_group(name: str) -> list[str] | None

The names in name's object group, name included, or None when it is not linked.

Source code in src/visualdynamics/gui/main_window.py
def linked_group(self, name: str) -> list[str] | None:
    """The names in `name`'s object group, `name` included, or None
    when it is not linked."""
    group = self._group_of(name)
    return None if group is None else group['members']
link_role(name: str) -> str | None

The role of name's object group: 'Basis', or None for an object without a role or not linked.

The Basis group sets the DOFs comparisons are made in: other sets are projected onto its DOFs, and its modes are the MAC's rows and the baseline of the frequency error.

Source code in src/visualdynamics/gui/main_window.py
def link_role(self, name: str) -> str | None:
    """The role of `name`'s object group: 'Basis', or None for an
    object without a role or not linked.

    The Basis group sets the DOFs comparisons are made in: other
    sets are projected onto its DOFs, and its modes are the MAC's
    rows and the baseline of the frequency error."""
    group = self._group_of(name)
    return None if group is None else group['role']
set_link_role(name: str, role: str | None) -> None

Give name's object group the role role ('Basis'), or clear it with None.

Only one group has a role at a time, so giving it to this group takes it from any other. It is set explicitly so that nothing later has to guess which group is which.

Source code in src/visualdynamics/gui/main_window.py
def set_link_role(self, name: str, role: str | None) -> None:
    """Give `name`'s object group the role `role` ('Basis'), or clear
    it with None.

    Only one group has a role at a time, so giving it to this group
    takes it from any other. It is set explicitly so that nothing
    later has to guess which group is which."""
    group = self._group_of(name)
    if group is None:
        return
    # the role means one thing: taking it takes it from any other
    if role is not None:
        for other in self.object_groups:
            if other is not group and other['role'] == role:
                other['role'] = None
    group['role'] = role
    self._reorder_tree()      # the Basis group reads first
    self._paint_links()
    self._show_status(
        f'{", ".join(group["members"])}: '
        + ('marked as the Basis — comparisons happen in this '
           'group\'s DOFs' if role else 'Basis cleared'))
linked_geometry
linked_geometry(name: str) -> tuple[str, Geometry] | None

(geometry name, geometry) for the geometry in name's object group, or None. Only a link counts; no geometry is guessed.

Source code in src/visualdynamics/gui/main_window.py
def linked_geometry(self,
                    name: str) -> tuple[str, Geometry] | None:
    """`(geometry name, geometry)` for the geometry in `name`'s object
    group, or None. Only a link counts; no geometry is guessed."""
    members = self.linked_group(name)
    if members is None:
        return None
    return next(((member, self.objects[member])
                 for member in members
                 if isinstance(self.objects.get(member), Geometry)),
                None)
unlink_selected() -> None

Take the selected objects out of their object groups. A group left with one member is removed.

Source code in src/visualdynamics/gui/main_window.py
def unlink_selected(self) -> None:
    """Take the selected objects out of their object groups. A group
    left with one member is removed."""
    names = self._selected_object_names()
    if not names:
        return
    self.project.unlink(*names)
    self._links_changed()
    self._show_status('Unlinked ' + ', '.join(names))
set_project_type
set_project_type(project_type: str | None) -> None

Set the project type, or clear it with None.

The tree then shows a gray slot for everything that type's report expects, and the status bar says how many are missing. The type does not choose a reading: a time history opens plain whatever the project type is.

Source code in src/visualdynamics/gui/main_window.py
def set_project_type(self, project_type: str | None) -> None:
    """Set the project type, or clear it with None.

    The tree then shows a gray slot for everything that type's report
    expects, and the status bar says how many are missing. The type
    does not choose a reading: a time history opens plain whatever
    the project type is.
    """
    # A plain time history whatever the type: Brandon, 2026-08-30.
    self.project_type = project_type or None
    # an act like any other, journaled as the assignment a script
    # makes — settling in place, since imports may restate it
    journal = self.project.journal
    line = f'project.project_type = {self.project_type!r}'
    if journal and journal[-1].startswith('project.project_type = '):
        journal[-1] = line
    elif self.project_type is not None:
        journal.append(line)
    self._refresh_placeholders()
    self._apply_type_links()
    if self.project_type:
        missing = missing_expectations(self.project_type, self.objects,
                                       self._sides())
        self._show_status(
            f'{self.project_type} project — '
            + (f'{len(missing)} expected '
               f'object{"s" * (len(missing) != 1)} still missing'
               if missing else 'everything a report needs is here'))
    else:
        self._show_status('Project type cleared')
work_up
work_up() -> None

The tree bar's Automatic: run the project's work_up, give every object it made a row, and select the last one made.

It needs a project type, and says so when there is none. The status bar lists what was added and what is still missing.

Source code in src/visualdynamics/gui/main_window.py
def work_up(self) -> None:
    """The tree bar's Automatic: run the project's `work_up`, give
    every object it made a row, and select the last one made.

    It needs a project type, and says so when there is none. The
    status bar lists what was added and what is still missing."""
    if not self.project_type:
        self._show_status('Set a project type first — Automatic fills '
                          'the slots the type expects')
        return
    QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
    try:
        added = self.project.work_up()
    finally:
        QApplication.restoreOverrideCursor()
    for k, name in enumerate(added):
        self.show_object(name, select=k == len(added) - 1)
    still = [label for label, _cls, _icon, _n, optional, _side
             in self.project.missing() if not optional]
    if added:
        self._show_status(
            f'Automatic: added {", ".join(added)}'
            + (f' — still missing {", ".join(still)}' if still else
               ' — everything a report needs is here'))
    else:
        self._show_status(
            'Automatic: nothing to compute'
            + (f' — {", ".join(still)} must be imported' if still
               else ' — everything a report needs is here'))
delete_entity_rows
delete_entity_rows() -> None

Delete the rows selected in the geometry table being edited.

Source code in src/visualdynamics/gui/main_window.py
def delete_entity_rows(self) -> None:
    """Delete the rows selected in the geometry table being edited."""
    if self.editing is None:
        return
    name, component = self.editing
    geometry = self.objects.get(name)
    rows = self.table.selected_items()
    if not rows or geometry is None:
        return
    keys = [_entity_key(geometry, component, row) for row in rows]
    self._delete_entities(name, component, keys)
define_units
define_units() -> None

Open the units pane for what is selected.

With individual records selected, only those are given units; selecting an object (or its category) covers all of its records. Channel tables are skipped. The pane shows one object; with several selected, it opens on the first and the status bar says to select the others one at a time.

Source code in src/visualdynamics/gui/main_window.py
def define_units(self) -> None:
    """Open the units pane for what is selected.

    With individual records selected, only those are given units;
    selecting an object (or its category) covers all of its records.
    Channel tables are skipped. The pane shows one object; with
    several selected, it opens on the first and the status bar says
    to select the others one at a time.
    """
    targets = {}
    for kind, name, obj, detail in self.selected_references():
        if isinstance(obj, ChannelTable):
            continue
        if isinstance(obj, ShapeSet):
            targets.setdefault(name, {'object': obj, 'records': set(),
                                      'whole': True})['whole'] = True
            continue
        entry = targets.setdefault(name, {'object': obj, 'records': set(),
                                          'whole': False})
        if kind == 'record':
            entry['records'].add(detail)
        else:
            entry['whole'] = True
    if not targets:
        self._show_status(
            'Select an object or channels inside the test to define units')
        return

    # every kind of object declares its units the same way, in the pane
    name = next(iter(targets))
    entry = targets[name]
    records = (None if entry['whole'] or not entry['records']
               else sorted(entry['records']))
    self.show_units_panel(name, entry['object'], records)
    if len(targets) > 1:
        # the pane shows one object; it stays open, so the rest are a
        # matter of selecting them next rather than a queue of dialogs
        self._show_status(
            f'Defining imported units for {name}; select the other '
            f'{len(targets) - 1} separately')
    self.render_current()
set_active_geometry
set_active_geometry(name: str | None = None) -> None

Make name (the selected object by default) the active geometry, the one every other object is checked against.

Source code in src/visualdynamics/gui/main_window.py
def set_active_geometry(self, name: str | None = None) -> None:
    """Make `name` (the selected object by default) the active
    geometry, the one every other object is checked against."""
    if name is None:
        item = self.object_item()
        name = item.text(0) if item is not None else None
    if not isinstance(self.objects.get(name), Geometry):
        self._show_status('Select a geometry to make it active')
        return
    self.active_geometry = name
    journal = self.project.journal
    line = f'project.active_geometry = {name!r}'
    if journal and journal[-1].startswith('project.active_geometry = '):
        journal[-1] = line
    else:
        journal.append(line)
    self.refresh_compatibility()
    self._show_status(f'{name} is now the active geometry')
refresh_compatibility
refresh_compatibility() -> None

Check the project's objects against the active geometry again, and repaint the tree.

It runs when the project changes (an import, a delete, a rename or a new active geometry), not on a selection or a render. With no active geometry, the first geometry in the project is used.

Source code in src/visualdynamics/gui/main_window.py
def refresh_compatibility(self) -> None:
    """Check the project's objects against the active geometry again,
    and repaint the tree.

    It runs when the project changes (an import, a delete, a rename
    or a new active geometry), not on a selection or a render. With
    no active geometry, the first geometry in the project is used.
    """
    if self.active_geometry not in self.objects:
        self.active_geometry = next(
            (name for name, obj in self.objects.items()
             if isinstance(obj, Geometry)), None)
    self.report = check_compatibility(self.objects,
                                      self.active_geometry,
                                      object_groups=self.object_groups)
    self._paint_compatibility()
show_units_panel
show_units_panel(name: str, obj: Any, records: Sequence[int] | None = None) -> None

Open the units pane on the object name, beside the plot.

records limits the table to those records (all of them when None). An FRF gets two tables, one for its response channels and one for its reference channels, because a matrix of N×M records has only N+M channel units to declare. Everything else uses the first table alone.

Source code in src/visualdynamics/gui/main_window.py
def show_units_panel(self, name: str, obj: Any,
                     records: Sequence[int] | None = None) -> None:
    """Open the units pane on the object `name`, beside the plot.

    `records` limits the table to those records (all of them when
    None). An FRF gets two tables, one for its response channels and
    one for its reference channels, because a matrix of N×M records
    has only N+M channel units to declare. Everything else uses the
    first table alone.
    """
    two_sided = isinstance(obj, DataArray) and obj.needs_reference
    if two_sided:
        models = frf_units_models(obj, records, self)
    else:
        models = (units_table_model(obj, records, self),)
        self.units_reference_table.setModel(None)
    wanted = 40
    for table, model in zip(
            (self.units_table, self.units_reference_table), models):
        table.setModel(model)
        model.edit_rejected.connect(self._show_status)
        model.dataChanged.connect(self._units_declared)
        wanted += self._size_units_columns(table, model)
    for widget in (self.units_response_caption,
                   self.units_reference_caption,
                   self.units_reference_table):
        widget.setVisible(two_sided)
    total = sum(self.main_split.sizes()) or self.width()
    self.main_split.setSizes([max(total - wanted, 320), wanted])
    # "Units" alone would read as the units things are shown and written
    # in, which is the selector in the status bar. These are the units the
    # values arrived in, and naming them converts once, to SI.
    self.units_title.setText(f'Imported Units — {name}')
    self.units_note.setText(
        'What the values were recorded in — naming a unit converts them '
        'once. Copy, paste, Delete to clear, and right-click to set '
        'several at a time. Drag the corner handle to fill down — or '
        'double-click it to fill to the end.')
    self.units_target = (name, obj)
    self.units_panel.show()
rename_selected
rename_selected() -> None

Start editing the selected object's name in the tree.

The project's own row can be renamed too, and a photo picked in its grid is renamed itself rather than the object holding it.

Source code in src/visualdynamics/gui/main_window.py
def rename_selected(self) -> None:
    """Start editing the selected object's name in the tree.

    The project's own row can be renamed too, and a photo picked in
    its grid is renamed itself rather than the object holding it.
    """
    # `top_level_item` walks *up* to the project and answers None when
    # it is already there, which is right for finding an object's row
    # and wrong here, and left Rename silently doing nothing on the
    # one row that carries the project's name.
    current = self.tree.currentItem()
    item = (self.test_item if current is self.test_item
            else self.top_level_item())
    if item is None:
        return
    # A photo picked in the grid is the thing being renamed, not the
    # object holding it — the grid's cell selection *is* the sub-item
    # selection. Renaming the object instead is not a refusal, it is
    # the wrong rename carried out silently, which is how this read
    # as "renaming a photo does not work".
    name = item.text(0)
    grid = self.record_grids.get(name)
    if isinstance(self.objects.get(name), Photos) and grid is not None:
        picked = grid.selected_records()
        if len(picked) == 1:
            self._rename_photo(name, picked[0])
            return
    self.tree.setCurrentItem(item)
    self.tree.editItem(item, 0)
rename_object
rename_object(old: str, new: str) -> str

Rename the object old to new from code, and return the name used. A name already taken is numbered the way Project.add numbers it.

Source code in src/visualdynamics/gui/main_window.py
def rename_object(self, old: str, new: str) -> str:
    """Rename the object `old` to `new` from code, and return the name
    used. A name already taken is numbered the way `Project.add`
    numbers it."""
    unique, n = new, 1
    while unique in self.objects:
        n += 1
        unique = f'{new} ({n})'
    item = self._item_for_object(old)
    self.tree.blockSignals(True)
    try:
        item.setText(0, unique)
        self._carry_rename(item, old, unique)
    finally:
        self.tree.blockSignals(False)
    return unique
edit_entities
edit_entities() -> None

Edit the selected part of a geometry in a table beside the 3-D view: its nodes, its coordinate systems, a family of its elements, or its element groups.

Source code in src/visualdynamics/gui/main_window.py
def edit_entities(self) -> None:
    """Edit the selected part of a geometry in a table beside the 3-D
    view: its nodes, its coordinate systems, a family of its elements,
    or its element groups."""
    kind, obj, detail = self.current_reference()
    item = self.tree.currentItem()
    reference = (kind, item.data(0, ROLE_REFERENCE)[1] if kind else None,
                 detail)
    component = self._component_of(reference) if kind else None
    if not isinstance(obj, Geometry) or component not in ENTITY_TABLES:
        self._show_status(
            'Select nodes, coordinate systems, an element family or an '
            'element group to edit them')
        return
    item = self.object_item()
    self.editing = (item.text(0), component)
    self.editing_scope = self._scope_of(reference)
    if kind == 'element':
        self.editing_scope = ('family', element_family(
            int(obj.elem_type[int(detail)])))
    self.views.setOrientation(Qt.Orientation.Horizontal)
    model = self._set_table_model(
        ENTITY_TABLES[component](obj, self.unit_system, self))
    model.dataChanged.connect(self._edited_geometry)
    self.table.selectionModel().selectionChanged.connect(
        self._edit_selection_changed)
    self._show_views(three_d=True, table=True)
    self._select_scope_rows(obj)
    self._draw_edit_selection()
    self.add_action.setVisible(True)
    # an element group is not placed in space, so its + adds a row outright
    # rather than arming a mode that waits for a click in the view
    self.add_action.setToolTip('Add an empty element group' if component == 'groups'
                               else 'Add items by clicking in the view')
    self._begin_picking(obj, self._picking_component())
    self._update_toolbar_actions()
    self._show_status(
        f'Editing {self._editing_words()} of {self.editing[0]} — '
        'select rows to highlight them; Escape or reselecting the tree '
        'leaves editing')
set_add_mode
set_add_mode(enabled: bool) -> None

Turn on or off adding entities by clicking in the 3-D view, while a geometry's table is being edited.

In the element groups table there is nothing to click, so the button adds one element group and comes back up.

Source code in src/visualdynamics/gui/main_window.py
def set_add_mode(self, enabled: bool) -> None:
    """Turn on or off adding entities by clicking in the 3-D view,
    while a geometry's table is being edited.

    In the element groups table there is nothing to click, so the
    button adds one element group and comes back up."""
    enabled = bool(enabled) and self.editing is not None
    if enabled and self.editing[1] == 'groups':
        # Nothing to click: an element group is a name over elements, not a
        # place. The button adds one and comes straight back up —
        # arming a mode that could never be satisfied would be a
        # control that looks live and does nothing. The button comes
        # back up first, because that re-enters here and would
        # otherwise overwrite what was just said in the status bar.
        self.add_action.setChecked(False)
        self._add_group()
        return
    if enabled and self.rotate_action.isChecked():
        # one mode at a time (Brandon, 2026-10-21): a click in the
        # view either adds or grabs a ring, never both, so the bar
        # never shows two modes armed
        self.rotate_action.setChecked(False)
    if not enabled and self._picked_nodes:
        self._commit_picked_nodes()
    self.add_mode = enabled
    self._picked_nodes = []
    self._draw_picked()
    if self.add_action.isChecked() != enabled:
        self.add_action.setChecked(enabled)
    self._update_element_type_actions()
    if self.editing is None:
        return
    geometry_name, _component = self.editing
    geometry = self.objects.get(geometry_name)
    if geometry is not None:
        # elements are built from nodes, so pick nodes
        self._begin_picking(geometry, self._picking_component())
    self._show_status(self._add_mode_hint() if enabled
                      else f'Editing {self._editing_words()}')
merge_groups_act
merge_groups_act(name, groups)

Merge element groups groups of geometry name with the project's merge_groups, and say in the status bar what was merged, or why it was refused.

Source code in src/visualdynamics/gui/main_window.py
def merge_groups_act(self, name, groups):
    """Merge element groups `groups` of geometry `name` with the
    project's `merge_groups`, and say in the status bar what was
    merged, or why it was refused."""
    geometry = self.objects.get(name)
    try:
        found = self.project.merge_groups(name, groups)
    except (ValueError, TypeError) as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    kept = geometry.group_name[list(geometry.group_id).index(
        found['into'])] or f'Element group {found["into"]}'
    message = (f'{name}: merged {found["groups"]} element group'
               f'{"s" * (found["groups"] != 1)} into {kept} — '
               f'{found["elements"]} elements moved')
    if self.editing is not None:
        self._after_geometry_change(name, geometry, message)
    else:
        self._refresh_item(self._item_for_object(name), geometry)
        self.refresh_compatibility()
        self.render_current()
        self._show_status(message)
    self._update_tie_action()
tie_selected
tie_selected() -> None

Tie the selected elements, either to the nearest nodes of an element group chosen from a menu, or to a second selection of elements, picked next and tied on the second press.

Source code in src/visualdynamics/gui/main_window.py
def tie_selected(self) -> None:
    """Tie the selected elements, either to the nearest nodes of an
    element group chosen from a menu, or to a second selection of
    elements, picked next and tied on the second press."""
    self.tie_action.setChecked(self._tie_patch is not None)
    if self.editing is None or self.editing[1] != 'elements':
        return
    name = self.editing[0]
    geometry = self.objects.get(name)
    rows = sorted({index.row() for index
                   in self.table.selectionModel().selectedRows()})
    picked = [_entity_key(geometry, 'elements', row) for row in rows]
    if self._tie_patch is not None:
        if not picked:
            self._show_status('Select the elements to tie the first '
                              'patch to, then press Tie again')
            return
        patch, self._tie_patch = self._tie_patch, None
        self._tie(name, patch, list(picked), 'the second selection')
        return
    if not picked:
        self._show_status('Select the elements to tie')
        return
    menu = QMenu(self)
    heading = menu.addAction('Tie to the nearest nodes of')
    heading.setEnabled(False)
    names = list(geometry.group_name)
    for k, group in enumerate(geometry.group_id):
        properties = geometry.group_properties.get(int(group))
        # nothing to tie to in a link or a lumped mass
        if (properties is not None
                and properties.kind in ('rigid', 'mass', 'spring', 'ground')) \
                or not geometry.elements_in(int(group)):
            continue
        label = names[k] or f'Element group {int(group)}'
        target = names[k] if names[k] and names.count(names[k]) == 1 \
            else int(group)
        menu.addAction(label).triggered.connect(
            lambda _checked=False, to=target, text=label:
            self._tie(name, list(picked), to, text))
    menu.addSeparator()
    menu.addAction('A second selection…').triggered.connect(
        lambda _checked=False: self._await_second_patch(list(picked)))
    self._pop_menu(menu)
set_rotate_mode
set_rotate_mode(enabled: bool) -> None

Turn on or off the rings for turning the coordinate system being edited. While a ring is dragged, it has the mouse.

Source code in src/visualdynamics/gui/main_window.py
def set_rotate_mode(self, enabled: bool) -> None:
    """Turn on or off the rings for turning the coordinate system
    being edited. While a ring is dragged, it has the mouse."""
    enabled = bool(enabled) and self._turnable_row() is not None
    if enabled and self.add_mode:
        # turning takes the click, so adding stops (`set_add_mode`)
        self.set_add_mode(False)
    self._rotating = None
    self._sliding = None
    if enabled:
        self._begin_rotating()
    else:
        self._end_rotating()
    self._update_rotate_actions()
reset_rotation
reset_rotation() -> None

Set the coordinate system being turned back to no rotation, keeping its origin.

Source code in src/visualdynamics/gui/main_window.py
def reset_rotation(self) -> None:
    """Set the coordinate system being turned back to no rotation,
    keeping its origin."""
    row, matrix = self._rotating_frame()
    if matrix is None:
        return
    geometry = self.objects[self.editing[0]]
    geometry.cs_matrix[row] = identity_frame(matrix)
    self._place_frame(row, angles=True)
    self.angle_box.blockSignals(True)
    self.angle_box.setValue(0.0)
    self.angle_box.blockSignals(False)
    self.table.model().refresh_row(row)
    self._draw_rings()
    self._draw_live_triad(row, geometry.cs_matrix[row])
    self._show_status('Rotation reset')
hover_at
hover_at(x: float, y: float) -> int | None

Light up the entity under the pixel (x, y), and return it (None when there is none).

Source code in src/visualdynamics/gui/main_window.py
def hover_at(self, x: float, y: float) -> int | None:
    """Light up the entity under the pixel `(x, y)`, and return it
    (None when there is none)."""
    entity = self._picker.pick(x, y, pixel_ratio=self.devicePixelRatioF())
    if entity == self._hovered:
        return entity        # nothing changed, so nothing to redraw
    self._hovered = entity
    self._draw_hover()
    return entity
select_entity
select_entity(entity: int | None, extend: bool = False) -> int | None

Select the table row for an entity picked in the 3-D view, and return the row (None when the entity has none, or when no table is being edited).

The same rule applies to every kind of entity. A plain click selects only what was clicked; with extend (the modifier key), a click adds to the selection, and clicking something already selected takes it back out.

Source code in src/visualdynamics/gui/main_window.py
def select_entity(self, entity: int | None,
                  extend: bool = False) -> int | None:
    """Select the table row for an entity picked in the 3-D view, and
    return the row (None when the entity has none, or when no table
    is being edited).

    The same rule applies to every kind of entity. A plain click
    selects only what was clicked; with `extend` (the modifier key),
    a click adds to the selection, and clicking something already
    selected takes it back out.
    """
    if self.editing is None or entity is None:
        return None
    name, component = self.editing
    geometry = self.objects.get(name)
    row = _row_for_entity(geometry, component, entity)
    if row is None:
        return None
    if not extend:
        mode = QItemSelectionModel.SelectionFlag.ClearAndSelect
    elif row in self.table.selected_items():
        mode = QItemSelectionModel.SelectionFlag.Deselect
    else:
        mode = QItemSelectionModel.SelectionFlag.Select
    model = self.table.model()
    # the item is a row, or a column of a transposed table (the
    # element groups, 2026-10-20)
    whole = (QItemSelectionModel.SelectionFlag.Columns if model.transposed
             else QItemSelectionModel.SelectionFlag.Rows)
    self.table.selectionModel().select(model.cell(row, 0), mode | whole)
    self.table.scrollTo(model.cell(row, 0))
    return row
about
about() -> None

Show the About box: the version, the copyright, and the alpha notice with the contact address.

It is the one place to read the version without the network or a relaunch.

Source code in src/visualdynamics/gui/main_window.py
def about(self) -> None:
    """Show the About box: the version, the copyright, and the alpha
    notice with the contact address.

    It is the one place to read the version without the network or a
    relaunch."""
    from .. import __version__
    from .disclaimer import TEXT

    box = QMessageBox(self)
    box.setWindowTitle('About Visual Dynamics')
    box.setIconPixmap(self.windowIcon().pixmap(64, 64))
    box.setText(f'<b>Visual Dynamics {__version__}</b><br>'
                'A units-aware toolset for structural dynamics test '
                'work.<br>© 2026 Brandon Zwink — see LICENSE for the '
                'terms.')
    box.setInformativeText(TEXT)
    box.setTextFormat(Qt.TextFormat.RichText)
    box.setStandardButtons(QMessageBox.StandardButton.Ok)
    box.setModal(False)
    box.show()
    self.about_box: QMessageBox | None = box
check_for_updates
check_for_updates() -> None

File → Check for Updates.

In the packaged macOS app, Sparkle does the whole job in its own window (gui/updater.py): it checks, downloads, verifies the update's signature and replaces the app.

Everywhere else, the window reads the release manifest from visualdynamics.org (update.attempt) and says what it found. A newer version is shown in a dialog with the page to open in the browser, or for a pip install the command to run; nothing is downloaded or run. The request runs off the main thread, waits update.ASKED_TIMEOUT, and when it fails the status bar says why (a proxy, a filtered domain, a certificate that cannot be verified, a slow network). This menu item is the only place the application checks; there is no check at startup.

Source code in src/visualdynamics/gui/main_window.py
def check_for_updates(self) -> None:
    """File → Check for Updates.

    In the packaged macOS app, Sparkle does the whole job in its own
    window (`gui/updater.py`): it checks, downloads, verifies the
    update's signature and replaces the app.

    Everywhere else, the window reads the release manifest from
    visualdynamics.org (`update.attempt`) and says what it found. A
    newer version is shown in a dialog with the page to open in the
    browser, or for a pip install the command to run; nothing is
    downloaded or run. The request runs off the main thread, waits
    `update.ASKED_TIMEOUT`, and when it fails the status bar says
    why (a proxy, a filtered domain, a certificate that cannot be
    verified, a slow network). This menu item is the only place the
    application checks; there is no check at startup.
    """
    # Outside Sparkle this stays a check, never an update (`update.py`
    # explains the line): an updater that runs what it fetched without
    # verifying a signature is remote code execution with a friendly
    # name, and only the Sparkle build verifies one (since 2026-09-28).
    # The reason a failure is reported: someone who chose this menu
    # item is waiting on news, and "could not reach visualdynamics.org"
    # is not news (Brandon, 2026-09-23: a work machine that could not
    # check, and an afternoon of commands to find out why).
    import threading

    from .. import update
    from . import updater

    if updater.check():
        self._show_status('Checking for updates…')
        return
    self._show_status('Checking for updates…')
    if not getattr(self, '_update_wired', False):
        self._update_answer.connect(self._report_update)
        self._update_wired = True
    threading.Thread(target=lambda: self._update_answer.emit(
        update.attempt(timeout=update.ASKED_TIMEOUT)),
        daemon=True).start()
dragEnterEvent
dragEnterEvent(event: QDragEnterEvent) -> None

Accept a drag of files anywhere on the window.

On a fresh window the tree is a strip about 90 px wide, and a drop that missed it would do nothing at all, which reads as the drop being ignored. So the whole window takes files.

Source code in src/visualdynamics/gui/main_window.py
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
    """Accept a drag of files anywhere on the window.

    On a fresh window the tree is a strip about 90 px wide, and a
    drop that missed it would do nothing at all, which reads as the
    drop being ignored. So the whole window takes files.
    """
    from .project_tree import trace_drag

    trace_drag('window', 'enter', event.mimeData())
    if event.mimeData().hasUrls() and self._dropped_files(event.mimeData()):
        event.setDropAction(Qt.DropAction.CopyAction)
        event.acceptProposedAction()
    else:
        super().dragEnterEvent(event)
event
event(found: QEvent) -> bool

Move keyboard focus to a hidden widget while the window is inactive, and give it back when the window is active again.

macOS 26 with Qt 6.11 discards a Finder drop released over the widget that had focus when the application was deactivated. The focus is moved to a zero-size widget that no drop can land on, and an inactive window has no keyboard, so nothing is lost in between. Focus is not moved while one of the application's own popups is open.

Source code in src/visualdynamics/gui/main_window.py
def event(self, found: QEvent) -> bool:
    """Move keyboard focus to a hidden widget while the window is
    inactive, and give it back when the window is active again.

    macOS 26 with Qt 6.11 discards a Finder drop released over the
    widget that had focus when the application was deactivated. The
    focus is moved to a zero-size widget that no drop can land on,
    and an inactive window has no keyboard, so nothing is lost in
    between. Focus is not moved while one of the application's own
    popups is open.
    """
    # The drop is discarded without an error: draggingEntered and
    # draggingUpdated are answered Copy, and the release still arrives
    # as draggingExited. The tree is the first focusable widget, so on
    # a fresh window it held focus and the one dead spot in the window
    # was the very widget whose status line says "drag files onto the
    # tree". Proven by swizzling the NSView's dragging methods and
    # toggling nothing but focus; the full hunt is in the log for the
    # commit that introduced this.
    #
    # Three shapes of this fix failed first, each for a reason worth
    # keeping:
    #
    # - Parking when the drag *enters* is too late. The drag's source
    #   deactivated the app before the first enter, and the platform
    #   state that kills the drop is set by then.
    # - `clearFocus()` at deactivation is in time but does nothing:
    #   focus-to-nobody leaves the platform's input state armed. Only
    #   a genuine handoff to another widget runs the full teardown.
    # - Disabling input methods on the tree changes nothing: the
    #   poison is the focus itself, not the input context.
    if found.type() == QEvent.Type.WindowDeactivate:
        if QApplication.activePopupWidget() is not None:
            # deactivated by one of our own popups — a combo's
            # drop-down list is its own window on macOS 26 — and
            # parking here hands focus away from the editor, which
            # closes the popup the instant it opens (Brandon,
            # 2026-08-30, the units drop-down). The park exists
            # for drags arriving from *another app*; while our
            # popup is up, the app never lost the stage.
            return super().event(found)
        focused = self.focusWidget()
        if focused is not None and focused is not self._focus_park:
            self._parked_focus = focused
            self._focus_park.setFocus(
                Qt.FocusReason.OtherFocusReason)
    elif found.type() == QEvent.Type.WindowActivate:
        parked, self._parked_focus = self._parked_focus, None
        if parked is not None and self.focusWidget() is self._focus_park:
            try:
                if parked.isVisible():
                    parked.setFocus(Qt.FocusReason.OtherFocusReason)
            except RuntimeError:
                pass            # it died while the window was away
    return super().event(found)
truncate_rattlesnake_file
truncate_rattlesnake_file() -> None

File → Truncate Rattlesnake File.

A run (.nc4) is picked in a file dialog and shown in the stream window dialog without its Import button. Save Truncated Copy writes the run cut to the stretch chosen. It works on any run, not only one large enough to be asked about on import, and refuses a file with no streamed time data.

Source code in src/visualdynamics/gui/main_window.py
def truncate_rattlesnake_file(self) -> None:
    """File → Truncate Rattlesnake File.

    A run (`.nc4`) is picked in a file dialog and shown in the
    stream window dialog without its Import button. Save Truncated
    Copy writes the run cut to the stretch chosen. It works on any
    run, not only one large enough to be asked about on import, and
    refuses a file with no streamed time data."""
    from ..io import rattlesnake
    from .stream_window import StreamWindowDialog

    path, _filter = QFileDialog.getOpenFileName(
        self, 'Truncate Rattlesnake File', '', 'Rattlesnake run (*.nc4)')
    if not path:
        return
    summary = rattlesnake.stream_summary(path) if rattlesnake.sniff(path) else None
    if not summary or not summary['streams']:
        self._show_status(f'{os.path.basename(path)} holds no streamed time '
                          'data to cut')
        return
    dialog = StreamWindowDialog(path, summary, resolve_theme(self.theme_name),
                                self, save=self._save_rattlesnake_window,
                                importing=False)
    dialog.exec()
import_paths
import_paths(paths: Sequence[str]) -> list[str]

Import several files, report any failures once at the end, and return the names of the objects added.

Images are not objects of their own: they are added together to the project's Photos object, which is created on first use. Paths that arrive while an import is running wait for it to finish.

Source code in src/visualdynamics/gui/main_window.py
def import_paths(self, paths: Sequence[str]) -> list[str]:
    """Import several files, report any failures once at the end, and
    return the names of the objects added.

    Images are not objects of their own: they are added together to
    the project's Photos object, which is created on first use. Paths
    that arrive while an import is running wait for it to finish.
    """
    if self._importing:
        # the progress ticks flush paints by pumping the queue, and
        # a drop landing mid-import queues a second import that the
        # pump would start *inside* the first — the very
        # re-entrancy all the deferral exists to prevent. It waits
        # its turn instead.
        QTimer.singleShot(100, lambda: self.import_paths(list(paths)))
        return []
    self._importing = True
    # the strip takes over from the temporary message: same words,
    # left side, immune to nothing painting while the loop blocks
    self.statusBar().clearMessage()
    self._import_label.setText(self._status_text)
    self._import_strip.show()
    try:
        return self._import_paths(paths)
    finally:
        self._importing = False
        self._import_strip.hide()
        # the one render the suppressed selection changes add up to.
        # It narrates what it drew, which must not shout down what
        # the import said — a type announcement outranks "8
        # channels on the stage" — so the import's last word is
        # restated after it.
        told = self._status_text
        self.render_current()
        # whatever was said last mid-import — a type announcement,
        # a refusal — went to the strip's label; restate it the
        # normal way so hiding the strip does not eat it
        self._show_status(told)
save_test
save_test() -> None

Save the whole project, every object under its name, as one file: a .vdyn, a MATLAB .mat, or an ESCDF .h5, chosen in the save dialog.

Source code in src/visualdynamics/gui/main_window.py
def save_test(self) -> None:
    """Save the whole project, every object under its name, as one
    file: a `.vdyn`, a MATLAB `.mat`, or an ESCDF `.h5`, chosen in the
    save dialog."""
    name = self.test_item.text(0)
    path, chosen = QFileDialog.getSaveFileName(
        self, 'Save Project', f'{name}.vdyn',
        'Visual Dynamics files (*.vdyn);;MATLAB file (*.mat);;'
        'Engineering Sciences Common Data Format (*.h5)')
    if not path:
        return
    # the same project in MATLAB's container, or as the standard's
    # types — the verb picks the writer by the suffix, so only the
    # suffix has to be right
    if chosen.startswith('MATLAB') and not path.endswith('.mat'):
        path += '.mat'
    if chosen.startswith('Engineering') and not path.endswith(ESCDF_SUFFIXES):
        path += ESCDF_SUFFIX
    # through the verb, not io directly: the verb carries the
    # provenance records (the direct call dropped them, and a
    # GUI-saved project reopened with no staleness bookkeeping)
    # and journals the save like any other act (Brandon,
    # 2026-08-30)
    self.project.name = name
    # an ESCDF file records who made it: the preference, or the
    # login name when none is set (the verb's own default)
    self.project.save(path, created_by=remembered_creator() or None)
    count = len(self.objects)
    self._show_status(
        f'Saved {name} ({count} object{"s" * (count != 1)}) to '
        f'{os.path.basename(path)}')
copy_selected
copy_selected() -> None

Cmd/Ctrl+C in the tree: copy the selected objects to the clipboard, as objects and as files, and say so in the status bar. Solid rows go on the clipboard as solids, to paste into a geometry.

Source code in src/visualdynamics/gui/main_window.py
def copy_selected(self) -> None:
    """Cmd/Ctrl+C in the tree: copy the selected objects to the
    clipboard, as objects and as files, and say so in the status
    bar. Solid rows go on the clipboard as solids, to paste into a
    geometry."""
    picked = self._selected_solids()
    if picked is not None:
        # Copy and Paste on solid rows (Brandon, 2026-10-18): the
        # clipboard names the geometry and the ids, and a paste with
        # a geometry picked copies the recipes into it
        import json

        from PySide6.QtCore import QMimeData

        name, ids = picked
        mime = QMimeData()
        mime.setData(SOLIDS_MIME, json.dumps({'geometry': name,
                                              'ids': ids}).encode())
        QApplication.clipboard().setMimeData(mime)
        self._show_status(f'Copied {len(ids)} solid{"s" * (len(ids) != 1)} '
                          '— select a geometry and paste')
        return
    names = self.tree.copy_selected()
    if names == [PROJECT_ROW]:
        self._show_status('Copied the project — paste into a folder '
                          'to save it as a .vdyn')
    elif names:
        self._show_status(
            f'Copied {len(names)} object{"s" * (len(names) != 1)} — '
            'paste here to duplicate, or into a folder to export')
    else:
        self._show_status('Select objects in the tree to copy them')
paste_objects
paste_objects() -> None

Cmd/Ctrl+V in the tree: paste whatever the clipboard holds that the tree can take (objects copied from a tree, solids copied from a geometry's rows, or files).

Source code in src/visualdynamics/gui/main_window.py
def paste_objects(self) -> None:
    """Cmd/Ctrl+V in the tree: paste whatever the clipboard holds
    that the tree can take (objects copied from a tree, solids
    copied from a geometry's rows, or files)."""
    if self._paste_solids():
        return
    if not self.tree.paste():
        self._show_status('Nothing on the clipboard to paste here')
save_selected
save_selected() -> None

Save the selected object, in the format chosen in the save dialog. With the project row selected, the whole project is saved (save_test).

The format is picked from the dialog's file-type list. .vdyn is first because it is the only format that reads back complete; the rest are the formats this object can be written in, so a mode shape is never offered a format that cannot hold one.

Source code in src/visualdynamics/gui/main_window.py
def save_selected(self) -> None:
    """Save the selected object, in the format chosen in the save
    dialog. With the project row selected, the whole project is saved
    (`save_test`).

    The format is picked from the dialog's file-type list. `.vdyn` is
    first because it is the only format that reads back complete; the
    rest are the formats this object can be written in, so a mode
    shape is never offered a format that cannot hold one.
    """
    # One verb, because *Save As* and *Export* were the same act with
    # two names (pick an object, pick a file, write it), and a user who
    # wanted a UNV had to know that the second menu entry existed and
    # that the first would not offer it.
    if self.tree.currentItem() is self.test_item:
        self.save_test()
        return
    obj = self.current_object()
    if obj is None:
        QMessageBox.information(self, 'Save', 'Select an object to save.')
        return
    native = 'Visual Dynamics object (*.vdyn)'
    available = io.exporters(obj)
    filters = ';;'.join([native, *(f'{e.description} (*{e.suffix})'
                                   for e in available)])
    path, chosen = QFileDialog.getSaveFileName(self, 'Save As', '', filters)
    if not path:
        return
    exporter = next((e for e in available
                     if f'{e.description} (*{e.suffix})' == chosen), None)
    if exporter is None and not path.lower().endswith('.vdyn'):
        # The filter text is the dialog's to report, and a name
        # typed with a format's suffix says the format as plainly.
        # Matched on the text alone, a mismatch wrote the object as
        # `name.npz.vdyn` where an sdynpy file was asked for
        # (Brandon, 2026-10-16: "no file gets written").
        exporter = next((e for e in available
                         if path.lower().endswith(e.suffix)), None)
    if exporter is None:
        if not path.endswith('.vdyn'):
            path += '.vdyn'
        io.save(obj, path)
        self._show_status(f'Saved {path}')
        return
    self._export_object(obj, path, exporter)
render_current
render_current() -> None

Draw everything selected: geometries are drawn together, and curves share axes.

Source code in src/visualdynamics/gui/main_window.py
10297
10298
10299
10300
10301
10302
10303
10304
10305
10306
10307
10308
10309
10310
10311
10312
10313
10314
10315
10316
10317
10318
10319
10320
10321
10322
10323
10324
10325
10326
10327
10328
10329
10330
10331
10332
10333
10334
10335
10336
10337
10338
10339
10340
10341
10342
10343
10344
10345
10346
10347
10348
10349
10350
10351
10352
10353
10354
10355
10356
10357
10358
10359
10360
10361
10362
10363
10364
10365
10366
10367
10368
10369
10370
10371
10372
10373
10374
10375
10376
10377
10378
10379
10380
10381
10382
10383
10384
10385
10386
10387
10388
10389
10390
10391
10392
10393
10394
10395
10396
10397
10398
10399
10400
10401
10402
10403
10404
10405
10406
10407
10408
10409
10410
10411
10412
10413
10414
10415
10416
10417
10418
10419
10420
10421
10422
10423
10424
10425
10426
10427
10428
10429
10430
10431
10432
10433
10434
10435
10436
10437
10438
10439
10440
10441
10442
10443
10444
10445
10446
10447
10448
10449
10450
10451
10452
10453
10454
10455
10456
10457
10458
10459
10460
10461
10462
10463
10464
10465
10466
10467
10468
10469
10470
10471
10472
10473
10474
10475
10476
10477
10478
10479
10480
10481
10482
10483
10484
10485
10486
10487
10488
10489
10490
10491
10492
10493
10494
10495
10496
10497
10498
10499
10500
10501
10502
10503
10504
10505
10506
10507
10508
10509
10510
10511
10512
10513
10514
10515
10516
10517
10518
10519
10520
10521
10522
10523
10524
10525
10526
10527
10528
10529
10530
10531
10532
10533
10534
10535
10536
10537
10538
10539
10540
10541
10542
10543
10544
10545
10546
10547
10548
10549
10550
10551
10552
10553
10554
10555
10556
10557
10558
10559
10560
10561
10562
10563
10564
10565
10566
10567
10568
10569
10570
10571
10572
10573
10574
10575
10576
10577
10578
10579
10580
10581
10582
10583
10584
10585
10586
10587
10588
10589
10590
10591
10592
10593
10594
10595
10596
10597
10598
10599
10600
10601
10602
10603
10604
10605
10606
10607
10608
10609
10610
10611
10612
10613
10614
10615
10616
10617
10618
10619
10620
10621
10622
10623
10624
10625
10626
10627
10628
10629
10630
10631
10632
10633
10634
10635
10636
10637
10638
10639
10640
10641
10642
10643
10644
10645
def render_current(self) -> None:
    """Draw everything selected: geometries are drawn together, and
    curves share axes."""
    self._update_toolbar_actions()
    # the frames and the shock windows belong to the plot that was
    # up; whatever is drawn next puts its own back if it wants them
    self._clear_averaging()
    self._clear_shocks()
    self._clear_filtering()
    self._clear_truncation()
    self._refused_comparison = None
    self._clear_octave()
    self._clear_tone()
    self._clear_transmissibility()
    self._clear_confidence()
    self._clear_rms_check()
    self._clear_matrix_check()
    # a grid still being drawn belongs to the drawing it began in
    self._matrix_drawing = None
    self._field_limit = None
    # and so does the 3-D surface: the waterfall path raises it
    # again itself, so a photo, a report, a fit or an empty
    # selection never inherits the last selection's 3-D view. Here
    # rather than in _show_views because the empty-selection path
    # exits before _show_views runs
    self.data_pane.show_waterfall(False)
    references = self.selected_references()
    # the fit owns the panes while its FRF stays selected; looking at
    # anything else ends the fit (the fitted modes are already in the
    # project — nothing is lost by leaving)
    if self.fit is not None:
        selected = {name for _kind, name, _obj, _detail in references}
        if selected and selected <= {self.fit_name, self.fit_object_name}:
            return
        self.stop_fitting()
    # one FRF and one shape set, both whole, read two ways — the
    # Edit Fit / Resynthesis buttons choose, and the choice sticks.
    # Sub-item picks keep their own meanings (a mode pick beside an
    # FRF is always the synthesis overlay), so they do not enter here.
    chosen = {name: obj for _kind, name, obj, _detail in references}
    is_pair = (len(chosen) == 2
               and sum(isinstance(obj, Frf)
                       for obj in chosen.values()) == 1
               and sum(isinstance(obj, ShapeSet)
                       for obj in chosen.values()) == 1)
    # mode or record picks keep the overlay showing whatever the
    # toggle last said — browsing a truncated synthesis must not keep
    # snapping into the fit — but the Edit button stays offered
    self._pair_selected = is_pair and all(
        kind == 'object' for kind, *_rest in references)
    self._pair_with_picks = is_pair and all(
        kind in ('object', 'mode', 'record')
        for kind, *_rest in references)
    if (self._pair_selected and self.pair_mode == 'fit'
            and self.fit is None):
        self.start_modal_fit()
        return
    # the pane edits what is selected; looking at something else puts it
    # away, and so does deleting the object out from under it
    if self.units_target is not None and (
            self.objects.get(self.units_target[0])
            is not self.units_target[1]
            or self.units_target[0] not in {
                name for _kind, name, _obj, _detail in references}):
        self.close_units_panel()
    # The project row alone shows nothing on the right. Drawn as
    # "everything it holds" it was every geometry overlaid, fifty
    # curves on one axis and the channel table, and the act a person
    # came to it for — Generate Report — sat a third of the way down
    # the window on the time data's bar (Brandon, 2026-09-09: "I'd
    # almost rather see nothing in the right screen when the top
    # level project is selected but still see the generate report
    # option in the toolbar"). So the panes clear, the 3-D view
    # holds the space with the project's own acts on its bar, and
    # the status line says what the project holds. The row still
    # *means* everything for copy and rename — that is the tree's
    # vocabulary, not the render's.
    if (references and self.test_item.isSelected()
            and all(item is self.test_item
                    for item in self.tree.selectedItems())):
        self._clear_views()
        self._show_views()
        self._offer_acts()
        n = len(self.objects)
        self._show_status(
            f'{self.test_item.text(0)}: {n} object{"s" * (n != 1)} — '
            'Generate Report is on the bar; select an object to see it')
        return
    if not references:
        self._clear_views()
        self._offer_acts()
        if self.tree.currentItem() is self.test_item:
            self._show_status(
                f'{self.test_item.text(0)} is empty — import a file, '
                'or drag files onto the tree')
        else:
            self._show_status(
                'Import a file to get started — or drag files onto the '
                'project tree')
        return

    geometries, series, channels, shapes = {}, [], [], []
    reports, picture_sets, matches, sine_specs = [], [], [], []
    sine_levels = []
    for kind, name, obj, detail in references:
        if isinstance(obj, Geometry):
            entry = geometries.setdefault(
                name, {'object': obj, 'components': set(), 'entities': {}})
            if kind == 'component' and detail:
                entry['components'].add(detail)
            elif kind == 'family':
                # a family is its elements, as an element group is its
                # own — kept as the family until it is drawn
                # (`_drawable`), so it can be captioned by its element
                # groups' names rather than its elements' ids
                entry['entities'].setdefault('families', []).append(detail)
            elif kind in ENTITY_COMPONENT:
                entry['entities'].setdefault(
                    ENTITY_COMPONENT[kind], []).append(detail)
            elif kind == 'solid':
                entry.setdefault('solids', []).append(detail)
            elif kind == 'solids':
                entry['show_solids'] = True
            elif kind == 'mate':
                entry.setdefault('mates', []).append(int(detail))
            elif kind == 'mates':
                entry.setdefault('mates', []).extend(m.id for m in obj.mates)
        elif isinstance(obj, DataArray):
            # one entry per object, not per record: ten cells picked in
            # a grid are one time history restricted to ten records,
            # and the animator refuses a selection of ten objects
            if kind == 'record':
                for entry_name, _obj, records in series:
                    if entry_name == name and records is not None:
                        records.append(detail)
                        break
                else:
                    series.append((name, obj, [detail]))
            else:
                series.append((name, obj, None))
        elif isinstance(obj, ShapeSet):
            shapes.append((name, obj, detail if kind == 'mode' else None))
        elif isinstance(obj, ChannelTable):
            channels.append((name, obj, detail if kind == 'channel' else None))
        elif isinstance(obj, Report):
            reports.append((name, obj))
        elif isinstance(obj, MatchedModes):
            matches.append((name, obj))
        elif isinstance(obj, (SineSweepSpecification, SineLevelSet)):
            # picked DOF rows restrict either object, exactly as
            # picked records restrict a data object; the tone is
            # the bar's pick
            into = (sine_specs
                    if isinstance(obj, SineSweepSpecification)
                    else sine_levels)
            if kind == 'dof':
                for entry_name, _obj, picks in into:
                    if entry_name == name and picks is not None:
                        picks.append(detail)
                        break
                else:
                    into.append((name, obj, [detail]))
            else:
                into.append((name, obj, None))
        elif isinstance(obj, Photos):
            # picked photos restrict the object, exactly as records do
            if kind == 'photo':
                for entry_name, _obj, picks in picture_sets:
                    if entry_name == name and picks is not None:
                        picks.append(detail)
                        break
                else:
                    picture_sets.append((name, obj, [detail]))
            else:
                picture_sets.append((name, obj, None))

    self._update_dof_controls(geometries, series, channels)
    self._update_sensor_controls(geometries, series, channels, shapes)
    self._update_link_actions()
    shape_table = bool(shapes) and not geometries and not series
    shape_sets = list(dict.fromkeys(
        (name, id(obj)) for name, obj, _detail in shapes))
    comparing = (len(shape_sets) == 2 and len(geometries) <= 1
                 and not series and not any(
                     entry['components'] or entry['entities']
                     for entry in geometries.values()))
    self._set_compare_layout(comparing)
    # a comparison if there is one to make, otherwise what the
    # specification says on its own — either way a table per channel
    # that the plot follows
    # what a specification says on its own, a row per channel. A
    # specification *with* a measurement has no table: the bar
    # charts carry those numbers, and carry them better.
    specifications = (self._specification_rows(series)
                      if series and not channels else None)
    # the levels are a reading asked for, not the default: the
    # spectra alone by default, and the RMS toggle brings the bars
    # up with the table beneath (Brandon, 2026-09-06)
    levels = (specifications
              if specifications is not None and self.data_pane.rms_wanted
              else None)
    # a transient record beside its target splits the space: the
    # waveform on top, and under it every control channel with how
    # far it is from what was asked. The plot draws one channel or a
    # few, so the table is where the rest are still accounted for
    replicating = bool(series) and self._replication_found(series)
    signaling = self._signal_door()
    modaling = self._modal_door()
    lining = self._lines_door()
    staging = self._state_space_door()
    authoring = (None if signaling is not None or modaling is not None
                 or lining is not None or staging is not None
                 else self._authoring(shapes, geometries, series, channels))
    # no 3-D view in edit mode (Brandon, 2026-09-06): every editing
    # gesture is on the flat plot, so the stage stands down while
    # the sheet is open and comes back when it closes
    self.data_pane.flat_only = (authoring is not None
                                or signaling is not None
                                or modaling is not None
                                or lining is not None
                                or staging is not None)
    # a lone set's transformation is drawn on its geometry, which
    # comes up above the tables (Brandon, 2026-10-23)
    self._values_on = (self._values_geometry(shapes[0][0], shapes[0][1])
                       if shape_table and len(shape_sets) == 1 else None)
    self._show_views(three_d=bool(geometries) or self._values_on is not None,
                     plots=bool(series) or bool(picture_sets)
                     or bool(sine_specs) or bool(sine_levels)
                     or authoring is not None or signaling is not None
                     or modaling is not None or staging is not None,
                     table=bool(channels) or shape_table or comparing
                     or bool(reports) or bool(matches)
                     or levels is not None
                     or bool(replicating)
                     or (bool(sine_specs)
                         and self.data_pane.sweep_rate_wanted)
                     or bool(series and self._srs_found(series))
                     or (bool(series)
                         and self._compliance_rows(series) is not None))
    if not (geometries or series or channels or picture_sets
            or matches or sine_specs or sine_levels):
        self._clear_views()


    # the rigid-body reading is a reading of one whole geometry with
    # nothing riding it: no shapes or data to animate, no part picked
    lone = (len(geometries) == 1 and not shapes and not series
            and not channels and self.editing is None
            and not any(entry['components'] or entry['entities']
                        for entry in geometries.values()))
    self.scene.offer_modes(lone)
    # the first selection that draws in the scene builds its VTK
    # view here (`ScenePane.plotter`); on a machine that cannot draw
    # one (`opengl.ready`, which says why) the scene keeps its page
    # saying so, and the rest of the selection is drawn without it
    drawable = not geometries or self.scene.plotter is not None
    deflection = (self._deflection_for(geometries, series, shapes)
                  if drawable else None)
    parts = []
    synthesis_note = ''
    if deflection is not None:
        parts.append(deflection)
        if series:
            animated = self._animated_series(series)
            parts.append(self._render_series(
                animated, cursor=True,
                chosen=('selected' if animated is series
                        else self._cursor_dimension)))
    else:
        if geometries and drawable:
            parts.append(self._render_geometries(list(geometries.items())))
        if sine_specs or sine_levels:
            parts.append(self._render_sine(
                sine_specs[0] if sine_specs else None,
                sine_levels, series))
        elif series:
            overlay, synthesis_note = self._synthesis_series(shapes,
                                                             series)
            parts.append(self._render_series(series + overlay))
            if synthesis_note:
                parts.append(synthesis_note)
            parts.append(self._transformed_rows_note(series))
    if picture_sets and not series:
        parts.append(self._render_photos(picture_sets))
    if reports:
        parts.append(self._render_report_builder(*reports[0]))
    if channels:
        name, table, _detail = channels[0]
        # picked cells in the tree's grid are the rows shown here;
        # selecting the object (no picks) shows the whole table
        rows = sorted({detail for n, _table, detail in channels
                       if n == name and detail is not None})
        summary = self._render_table(table, rows=rows or None, name=name)
        tables = {n for n, _table, _detail in channels}
        if len(tables) > 1:
            summary += f' (1 of {len(tables)} tables selected)'
        parts.append(summary)
    if levels is not None:
        parts.append(self._render_specifications(levels))
    if matches and not comparing and not channels:
        parts.append(self._render_matches(*matches[0]))
    if shapes and deflection is None and not synthesis_note:
        if shape_table:
            sets = list(dict.fromkeys(
                (name, id(obj)) for name, obj, _detail in shapes))
            if len(sets) == 2:
                # the same interactive pairing as the animated
                # comparison — no geometry required to match modes
                summary = self._compare_flat(shapes)
            else:
                summary = self._render_shape_table(
                    shapes[0][1], mode=shapes[0][2],
                    name=shapes[0][0])
            parts.append(summary)
        else:
            parts.append(self._shape_summary(shapes, geometries))
    if signaling is not None:
        parts.append(self._render_signal())
    if modaling is not None:
        parts.append(self._render_modal())
    if lining is not None:
        parts.append(self._render_lines())
    if staging is not None:
        parts.append(self._render_state_space())
    if authoring is not None:
        parts.append(self._render_author(authoring))
        if authoring[0] == 'table':
            # the table's bar is put away by its own renderer; the
            # sheet's toggle lives there, so it comes back alone
            self.mode_table_action.setVisible(False)
            self.mac_bars_action.setVisible(False)
            self.mac_action.setVisible(False)
            self._hide_shape_values()
            self.table_bar.show()
    elif channels and not shapes and (
            self.author_action.isVisible()
            or self.environment_action.isVisible()
            or self.table_view_action.isVisible()):
        self.mode_table_action.setVisible(False)
        self.mac_bars_action.setVisible(False)
        self.mac_action.setVisible(False)
        self._hide_shape_values()
        self.table_bar.show()
    self._offer_acts()
    refused = getattr(self, '_refused_comparison', None)
    if refused:
        parts.append(f'Not compared: {refused}')
    refusal, self._author_refusal = self._author_refusal, None
    self._show_status('  |  '.join(part for part in [*parts, refusal] if part))
name_object_group
name_object_group(member: str, name: str | None = None) -> None

Name the object group member is in, and show the name on its bracket. When name is not given, it is asked for in a dialog, and a blank name removes it. The name is what the group's ESCDF activity is called on export.

Source code in src/visualdynamics/gui/main_window.py
def name_object_group(self, member: str, name: str | None = None) -> None:
    """Name the object group `member` is in, and show the name on its
    bracket. When `name` is not given, it is asked for in a dialog,
    and a blank name removes it. The name is what the group's ESCDF
    activity is called on export."""
    from PySide6.QtWidgets import QInputDialog

    current = self.project.object_group_of(member)
    if current is None:
        return
    if name is None:
        held = next((g.get('name', '') for g in self.object_groups
                     if member in g['members']), '')
        name, ok = QInputDialog.getText(
            self, 'Name Object Group', 'The object group\'s name:', text=held)
        if not ok:
            return
    self.project.name_object_group(member, name.strip() or None)
    self._paint_links()
    self.tree.viewport().update()
    self._show_status(f'Object group named {name.strip()!r}' if name.strip()
                      else 'Object group name removed')
add_matches
add_matches() -> None

Add the selected MAC squares to the matched modes of this pair of mode sets.

The project creates the MatchedModes object or adds to it, links it to the Basis group, and keeps the MAC values as they were displayed, because computing them again by name would not give the same values for a projected comparison.

Source code in src/visualdynamics/gui/main_window.py
def add_matches(self) -> None:
    """Add the selected MAC squares to the matched modes of this pair
    of mode sets.

    The project creates the MatchedModes object or adds to it, links
    it to the Basis group, and keeps the MAC values as they were
    displayed, because computing them again by name would not give
    the same values for a projected comparison."""
    if self._compare is None or not self._compare.get('pairs'):
        return
    a_name, b_name = self._compare['pair']
    matrix = self._compare['matrix']
    pairs = [list(pair) for pair in self._compare['pairs']]
    macs = [float(matrix[r, c]) for r, c in self._compare['pairs']]
    found = self._matched_for(a_name, b_name)
    if found is None:
        selected = self.tree.selectedItems()
        current = self.tree.currentItem()
        name = self.project.match_modes(a_name, b_name, pairs=pairs,
                                        macs=macs)
        self.show_object(name, select=False)
        # the comparison is the user's context: put it back
        if current is not None:
            self.tree.setCurrentItem(current)
        self.tree.clearSelection()
        for item in selected:
            item.setSelected(True)
        matched = self.objects[name]
    else:
        name, matched = found
        matched.add(pairs, macs)
        # growing an existing set is an act like creating one: the
        # first commit journals through the match_modes verb, and
        # this is the same statement made again (Brandon,
        # 2026-08-30, the audit's second pass)
        self.project.record_call(
            matched, 'add',
            [(int(a), int(b)) for a, b in pairs],
            [float(m) for m in macs])
        a_home = self.project.geometry_for(a_name)
        b_home = self.project.geometry_for(b_name)
        was = (matched.first_geometry, matched.second_geometry)
        matched.first_geometry = (matched.first_geometry
                                  or (a_home[0] if a_home else None))
        matched.second_geometry = (matched.second_geometry
                                   or (b_home[0] if b_home else None))
        if (matched.first_geometry, matched.second_geometry) != was:
            self.project.record_setting(
                matched, 'first_geometry', matched.first_geometry)
            self.project.record_setting(
                matched, 'second_geometry', matched.second_geometry)
        self._refresh_item(self._item_for_object(name), matched)
    self.render_current()
    self._show_status(
        f'{name}: {len(pairs)} '
        f'match{"es" * (len(pairs) != 1)} added '
        f'({matched.num_matches} total)')
generate_signal_act
generate_signal_act() -> None

Open Generate Signal on the selected object, or put it away when it is open on it already. The pane previews the signal as its settings change, and its button makes it (Project.generate_signal).

Source code in src/visualdynamics/gui/main_window.py
def generate_signal_act(self) -> None:
    """Open Generate Signal on the selected object, or put it away
    when it is open on it already. The pane previews the signal as
    its settings change, and its button makes it
    (`Project.generate_signal`)."""
    from ..core.signals import suggested

    source = self._signal_source()
    if source is None:
        self._show_status('Select a spectrum, a sine specification, a '
                          'shock specification or a channel table to '
                          'generate a signal from')
        return
    name, dofs = source
    obj = self.objects[name]
    if (self._signaling is not None and self._signaling['source'] == name
            and self._signaling['dofs'] == dofs):
        self.close_signal_pane()
        return
    self._author_wanted = False
    settings = self._signal_settings.get(name) or suggested(obj)
    if isinstance(obj, ChannelTable):
        # the picks, or every reference when the whole table is
        # selected: settings remembered from a pick never narrow a
        # later opening on the whole table
        from dataclasses import replace

        settings = replace(settings, dofs=dofs)
    if settings.kind == 'srs' and self.data_pane.stroke_settings:
        # the shaker the Stroke reading was last read against, so a
        # synthesis opens on the ratings already typed for a pulse
        from dataclasses import replace

        held = self.data_pane.stroke_settings
        settings = replace(settings, **{
            key: value for key, value in held.items()
            if getattr(settings, key, None) is None})
    self._signaling = {'source': name, 'dofs': dofs, 'settings': settings}
    self.render_current()
close_signal_pane
close_signal_pane() -> None

Put the Generate Signal pane away, keeping what it showed.

Source code in src/visualdynamics/gui/main_window.py
def close_signal_pane(self) -> None:
    """Put the Generate Signal pane away, keeping what it showed."""
    self._signaling = None
    self._synthesis_settle.stop()
    self.data_pane.signal_panel.hide()
    self.render_current()
modal_response_act
modal_response_act() -> None

Open Compute Modal Responses on the selected time history or force CPSD and shape set (its picked modes, or all), or put it away when it is open on them. The pane previews the responses; Compute makes them an object of their own (Project.compute_modal_responses).

Source code in src/visualdynamics/gui/main_window.py
def modal_response_act(self) -> None:
    """Open Compute Modal Responses on the selected time history or
    force CPSD and shape set (its picked modes, or all), or put it
    away when it is open on them. The pane previews the responses;
    Compute makes them an object of their own
    (`Project.compute_modal_responses`)."""
    candidates = self._modal_candidates()
    if candidates is None:
        self._show_status('Select a time history with force channels, or '
                          'a CPSD of forces, and a shape set (or some of '
                          'its modes) together')
        return
    if self._modaling is not None and self._modaling['key'] == candidates:
        self.close_modal_pane()
        return
    self._author_wanted = False
    self._signaling = None
    self.data_pane.signal_panel.hide()
    history, shapes, modes, records = candidates
    self._modaling = {'key': candidates, 'history': history,
                      'shapes': shapes, 'modes': modes,
                      'records': records}
    self.render_current()
close_modal_pane
close_modal_pane() -> None

Put the Compute Modal Responses pane away.

Source code in src/visualdynamics/gui/main_window.py
def close_modal_pane(self) -> None:
    """Put the Compute Modal Responses pane away."""
    self._modaling = None
    self.data_pane.modal_response_panel.hide()
    self.render_current()
make_realizable
make_realizable() -> None

Repair the selected CSD or specification to the nearest realizable matrix and add the result beside it: the Realizability reading's apply button, the verb a script calls.

Source code in src/visualdynamics/gui/main_window.py
def make_realizable(self) -> None:
    """Repair the selected CSD or specification to the nearest
    realizable matrix and add the result beside it: the
    Realizability reading's apply button, the verb a script calls."""
    # on the worker with the bar and Cancel, as the reading was: the
    # repair of a large target is seconds of projections
    acted = self._act_on(
        lambda o: isinstance(o, Psd) and o.reference_dof is not None,
        'Select a CSD or a specification with cross terms to repair',
        lambda name: self._run_long(
            f'Repairing {name} to the nearest realizable matrix…',
            self.project.make_realizable, name))
    if acted is None:
        return
    name, _obj, added = acted
    # shown at once, with the reading still up, so the button's
    # result is read the same way: realizable at every line
    self.show_object(added)
    self._show_status(f'{added}: {name} repaired to the nearest '
                      'realizable matrix — linked to it')
make_field_specification
make_field_specification() -> None

Write the specification the Field Limit reading shows into the project, with the panel's margin (Project.derive_specification), on the worker. This is the reading's apply button; it ends the reading and shows what it made.

Source code in src/visualdynamics/gui/main_window.py
def make_field_specification(self) -> None:
    """Write the specification the Field Limit reading shows into the
    project, with the panel's margin (`Project.derive_specification`),
    on the worker. This is the reading's apply button; it ends the
    reading and shows what it made."""
    from ..core.progress import Cancelled

    state = self._field_limit
    if state is None:
        self._show_status('No field limit to write: the reading is '
                          'refused or not up')
        return
    sources, records = state['sources'], state['records']
    try:
        added = self._run_long(
            'Deriving the specification…',
            self.project.derive_specification, sources, records,
            margin_db=state['margin_db'], **state['settings'])
    except Cancelled:
        self._show_status('Derive Specification: cancelled')
        return
    except ValueError as why:
        self._show_status(f'No specification: {why}')
        return
    fit = state['fit']
    pane = self.data_pane
    if pane.field_limit_action.isChecked():
        pane.field_limit_action.trigger()
    self.show_object(added)
    first = sources if isinstance(sources, str) else sources[0][0]
    margin = state['margin_db']
    self._show_status(
        f'{added}: {fit.breakpoints} breakpoints over the '
        f'{fit.limit.describe()}'
        + (f', {margin:+g} dB margin' if margin else '')
        + f' — linked to {first}')
compute_spectra
compute_spectra() -> None

Compute averaged spectra from the selected time history, one record per channel, and add them to the project, linked to it.

Source code in src/visualdynamics/gui/main_window.py
def compute_spectra(self) -> None:
    """Compute averaged spectra from the selected time history, one
    record per channel, and add them to the project, linked to it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute spectra',
        self.project.compute_spectra)
    if acted is None:
        return
    name, obj, added = acted
    spectra = self.objects[added]
    frames = obj.num_records // max(spectra.num_records, 1)
    self.show_object(added)
    self._show_status(
        f'{added}: {spectra.num_records} channel '
        f'spectr{"a" if spectra.num_records != 1 else "um"} averaged '
        f'over {frames} frame{"s" * (frames != 1)} — linked to '
        f'{name}')
project_onto_basis
project_onto_basis() -> None

Project the other selected mode set onto the Basis set's DOFs, and add the result as a new object.

A node match tolerance, in percent of the basis model's size, is asked for first. The project does the projection and the linking. Rows picked in a channel table's grid beside the set narrow the target to those channels.

Source code in src/visualdynamics/gui/main_window.py
def project_onto_basis(self) -> None:
    """Project the other selected mode set onto the Basis set's DOFs,
    and add the result as a new object.

    A node match tolerance, in percent of the basis model's size, is
    asked for first. The project does the projection and the
    linking. Rows picked in a channel table's grid beside the set
    narrow the target to those channels."""
    names = self._selected_object_names()
    tables = [n for n in names if isinstance(self.objects.get(n), ChannelTable)]
    sets = [n for n in names if isinstance(self.objects.get(n), ShapeSet)]
    picks = self._projection_picks()
    rows = None
    if picks is not None:
        other_name, basis_name, rows = picks
    elif len(tables) == 1 and len(sets) == 1:
        # the model's modes at a test's channels (Brandon, 2026-10-29)
        basis_name, other_name = tables[0], sets[0]
    else:
        candidates = self._correlation_candidates()
        if candidates is None:
            return
        basis_name = candidates['basis'][0]
        other_name = candidates['other'][0]
    percent, ok = QInputDialog.getDouble(
        self, 'Project onto Basis DOFs',
        'Node match tolerance (% of the basis model size):',
        2.0, 0.01, 100.0, 2)
    if not ok:
        return
    try:
        added = self.project.project_onto_basis(
            other_name, onto=basis_name, tolerance=percent / 100.0,
            **({} if rows is None else {'channels': rows}))
    except ValueError as refusal:
        self._show_status(f'{other_name}: {refusal}')
        return
    self.show_object(added)
    report = self.objects[added].projection_report
    home = self.project.geometry_for(basis_name)
    if home is not None and home[1].units_defined:
        worst = self.unit_system.from_si(report['worst'], 'length')
        unit = self.unit_system.label_text('length')
    else:
        worst, unit = report['worst'], 'model units'
    note = (f'; {len(report["dropped"])} basis DOFs dropped'
            if report['dropped'] else '')
    self._show_status(
        f'{added}: {report["matched"]} of {report["total"]} basis '
        f'nodes matched (worst {worst:.4g} {unit}){note}'
        + ('' if isinstance(self.objects.get(basis_name), ChannelTable)
           else f' — select it beside {basis_name} to compare'))
export_state_space_act
export_state_space_act() -> None

Open Export State-Space Model on the selected shape set and channel table, or put it away when it is open on them. The pane previews the model's drive-point FRFs; Export writes the file (Project.export_state_space).

Source code in src/visualdynamics/gui/main_window.py
def export_state_space_act(self) -> None:
    """Open Export State-Space Model on the selected shape set and
    channel table, or put it away when it is open on them. The pane
    previews the model's drive-point FRFs; Export writes the file
    (`Project.export_state_space`)."""
    key = self._state_space_candidates()
    if key is None:
        self._show_status('Select a real shape set and a channel table '
                          'together')
        return
    if self._staging is not None and self._staging['key'] == key:
        self.close_state_space_pane()
        return
    # one pane beside the plot at a time
    self._author_wanted = False
    self._signaling = self._modaling = self._lining = None
    for panel in (self.data_pane.signal_panel,
                  self.data_pane.modal_response_panel,
                  self.data_pane.lines_panel):
        panel.hide()
    self._staging = {'key': key, 'maximum': None, 'rigid': 1.0}
    self.render_current()
close_state_space_pane
close_state_space_pane() -> None

Put the Export State-Space Model pane away.

Source code in src/visualdynamics/gui/main_window.py
def close_state_space_pane(self) -> None:
    """Put the Export State-Space Model pane away."""
    self._staging = None
    self.data_pane.state_space_panel.hide()
    self.render_current()
transform_act
transform_act() -> None

The bar's Transform to Modal Coordinates.

Source code in src/visualdynamics/gui/main_window.py
def transform_act(self) -> None:
    """The bar's Transform to Modal Coordinates."""
    self.transform_selection('physical')
expand_act
expand_act() -> None

The bar's Expand to Physical Responses.

Source code in src/visualdynamics/gui/main_window.py
def expand_act(self) -> None:
    """The bar's Expand to Physical Responses."""
    self.transform_selection('modal')
transform_selection
transform_selection(direction: str | None = None) -> None

Transform the selected data through the selected shape set, modal responses from physical or physical from modal, and add the result to the project with the verb a script calls.

direction is the way the act asked for: 'physical' transforms, 'modal' expands; left out, the way the selection reads. A history of forces with modal responses appended offers both.

A transform has no settings, so there is no pane and no preview, as with Integrate. What was done is said in the status bar and kept on the new object's transform_report.

Source code in src/visualdynamics/gui/main_window.py
def transform_selection(self, direction: str | None = None) -> None:
    """Transform the selected data through the selected shape set,
    modal responses from physical or physical from modal, and add the
    result to the project with the verb a script calls.

    `direction` is the way the act asked for: 'physical' transforms,
    'modal' expands; left out, the way the selection reads. A
    history of forces with modal responses appended offers both.

    A transform has no settings, so there is no pane and no preview,
    as with Integrate. What was done is said in the status bar and
    kept on the new object's `transform_report`."""
    # No settings pane: Brandon, 2026-09-04.
    candidates = self._transform_candidates()
    if candidates is None:
        self._show_status('Select a data object and a shape set '
                          'together to transform one through the '
                          'other')
        return
    history, shapes, read, records = candidates
    direction = direction or read
    # the pick only when there is one, so the journal's line is the
    # one a script would write for the whole object
    picked = {} if records is None else {'records': records}
    try:
        if direction == 'physical':
            added = self.project.transform(history, shapes, **picked)
        else:
            added = self.project.expand(history, shapes, **picked)
    except ValueError as refusal:
        self._show_status(f'{history}: {refusal}')
        return
    report = self.objects[added].transform_report
    self.show_object(added)
    self._show_status(f'{added}: {report.describe()}, through {shapes}')
generate_typed_report
generate_typed_report() -> None

Generate the reports the project's type calls for: one, or for a random-and-sine project a random report and a sine report.

Source code in src/visualdynamics/gui/main_window.py
def generate_typed_report(self) -> None:
    """Generate the reports the project's type calls for: one, or for
    a random-and-sine project a random report and a sine report."""
    # Two reports since the combined report was retired, 2026-10-05.
    for template in PROJECT_TEMPLATES[self.project_type]:
        self.generate_report(template)
extract_sine_levels
extract_sine_levels(time_name=None) -> None

Extract each specification tone's level from the time history time_name (the selected one by default), and add the levels to the project beside it.

The result is selected, so the comparison is one click away.

Source code in src/visualdynamics/gui/main_window.py
def extract_sine_levels(self, time_name=None) -> None:
    """Extract each specification tone's level from the time history
    `time_name` (the selected one by default), and add the levels to
    the project beside it.

    The result is selected, so the comparison is one click away."""
    if time_name is None:
        obj = self.current_object()
        if not isinstance(obj, TimeHistory):
            self._show_status(
                'Select a time history to extract sine levels')
            return
        time_name = self.object_item().text(0)
    from ..core.progress import Cancelled

    try:
        added = self._run_long(
            f'Extracting sine levels from {time_name}…',
            self.project.extract_sine, time_name)
    except Cancelled:
        self._show_status(f'{time_name}: extraction cancelled')
        return
    except (ValueError, AttributeError) as refusal:
        self._show_status(f'{time_name}: {refusal}')
        return
    for k, name in enumerate(added):
        self.show_object(name, source=time_name, select=k == 0)
    levels = self.objects[added[0]]
    setting = self.objects[time_name].sine_extraction
    self._show_status(
        f'{len(levels)} tone level{"s" * (len(levels) != 1)} '
        f'extracted from {time_name} — '
        + (setting.describe() if setting is not None
           else f'{levels.cycles:g} cycles of smoothing'))
compute_psds
compute_psds() -> None

Compute averaged auto-power spectral densities from the selected time history, one record per channel, and add them to the project, linked to it.

Source code in src/visualdynamics/gui/main_window.py
def compute_psds(self) -> None:
    """Compute averaged auto-power spectral densities from the
    selected time history, one record per channel, and add them to
    the project, linked to it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute PSDs',
        self.project.compute_psds)
    if acted is None:
        return
    name, obj, added = acted
    psds = self.objects[added]
    frames, where = self._averaged_frames(obj, psds.num_records)
    self.show_object(added)
    self._show_status(
        f'{added}: {psds.num_records} channel '
        f'PSD{"s" * (psds.num_records != 1)} averaged over '
        f'{frames} frame{"s" * (frames != 1)}{where} — linked to {name}')
compute_octave
compute_octave() -> None

Compute the selected PSD, CPSD or specification on proportional bands, and add the result beside it.

This is the Octave Bands panel's apply button, and it uses the bands per octave set in the panel, so it makes exactly the bands its preview draws.

Source code in src/visualdynamics/gui/main_window.py
def compute_octave(self) -> None:
    """Compute the selected PSD, CPSD or specification on proportional
    bands, and add the result beside it.

    This is the Octave Bands panel's apply button, and it uses the
    bands per octave set in the panel, so it makes exactly the bands
    its preview draws."""
    # the spacing is the panel's — the very steps being previewed
    # are what the button makes, which is the whole contract
    per_octave = self.data_pane.octave_panel.per_octave()
    acted = self._act_on(
        lambda o: isinstance(o, Psd),
        'Select a PSD, CPSD or specification to band',
        self.project.compute_octave, per_octave)
    if acted is None:
        return
    name, obj, added = acted
    banded = self.objects[added]
    # Apply ends the reading. Left on, the toggle carried over to
    # the object it had just made: the banded ribbons drew, and
    # their re-banding stepped over them in the preview color —
    # every one pink (Brandon, 2026-09-18)
    pane = self.data_pane
    if pane.octave_action.isChecked():
        pane.octave_action.trigger()
    self.show_object(added)
    self._show_status(
        f'{added}: {len(banded.abscissa)} bands from '
        f'{len(obj.abscissa)} lines, {banded.num_records} '
        f'record{"s" * (banded.num_records != 1)} — linked to {name}')
compute_frfs
compute_frfs() -> None

Compute FRFs from the selected time history, and add them beside it.

It asks for the estimator first. The three assume the noise is in different places (on the response, on the reference, or on both), and the data cannot say which; that depends on the instrumentation, which the person who ran the test knows.

The references are the history's own excitation channels and the frames are the ones a PSD would use, so the FRFs, the PSDs and the multiple coherence beside them all describe one measurement.

Source code in src/visualdynamics/gui/main_window.py
def compute_frfs(self) -> None:
    """Compute FRFs from the selected time history, and add them
    beside it.

    It asks for the estimator first. The three assume the noise is in
    different places (on the response, on the reference, or on both),
    and the data cannot say which; that depends on the
    instrumentation, which the person who ran the test knows.

    The references are the history's own excitation channels and the
    frames are the ones a PSD would use, so the FRFs, the PSDs and
    the multiple coherence beside them all describe one measurement.
    """
    obj = self.current_object()
    if not isinstance(obj, TimeHistory):
        self._show_status('Select a time history to compute FRFs')
        return
    name = self.object_item().text(0)
    methods = list(TimeHistory.FRF_METHODS)
    labels = [f'{method} — {note}' for method, note in zip(methods, (
        'noise on both (total least squares)',
        'noise on the response',
        'noise on the reference (single reference only)'))]
    chosen, ok = QInputDialog.getItem(
        self, 'Compute FRFs', 'Estimator:', labels, 0, False)
    if not ok:
        return
    method = methods[labels.index(chosen)]
    seeded = obj.averaging is None
    try:
        added = self.project.compute_frfs(name, method)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    frfs = self.objects[added]
    drives = len(set(frfs.reference_dof))
    self.show_object(added)
    self._show_status(
        f'{added}: {frfs.num_records} {method} FRFs, '
        f'{frfs.num_records // max(drives, 1)} responses against '
        f'{drives} reference{"s" * (drives != 1)}, '
        f'{obj.averaging.frames * max(obj.records_per_channel.values())} '
        f'averages{" (detected)" if seeded else ""} — linked to {name}')
compute_multiple_coherence
compute_multiple_coherence() -> None

Compute the multiple coherence of the selected time history, and add it beside it.

It is averaged over the same frames a PSD would be, so the two describe one measurement; the averaging view sets both. With no averaging set, Project.compute_multiple_coherence works one out and stores it on the history, the way the shock calculator stores the events it detects, so a PSD computed later uses the same frames. The status bar says whether the averaging was set or detected.

Source code in src/visualdynamics/gui/main_window.py
def compute_multiple_coherence(self) -> None:
    """Compute the multiple coherence of the selected time history,
    and add it beside it.

    It is averaged over the same frames a PSD would be, so the two
    describe one measurement; the averaging view sets both. With no
    averaging set, `Project.compute_multiple_coherence` works one out
    and stores it on the history, the way the shock calculator stores
    the events it detects, so a PSD computed later uses the same
    frames. The status bar says whether the averaging was set or
    detected.
    """
    obj = self.current_object()
    if not isinstance(obj, TimeHistory):
        self._show_status(
            'Select a time history to compute multiple coherence')
        return
    name = self.object_item().text(0)
    seeded = obj.averaging is None
    try:
        added = self.project.compute_multiple_coherence(name)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    coherence = self.objects[added]
    drives = len(obj.reference_channels())
    self.show_object(added)
    self._show_status(
        f'{added}: {coherence.num_records} '
        f'response{"s" * (coherence.num_records != 1)} against '
        f'{drives} reference{"s" * (drives != 1)}, '
        f'{obj.averaging.frames} averages'
        f'{" (detected)" if seeded else ""} — linked to {name}')
compute_srs
compute_srs() -> None

Compute shock response spectra from the selected time history, one curve per channel per shock, and add them beside it.

The shocks are the ones stored on the history, those the shock view shows at the time, and the spectrum's settings come from the shock panel. With no shocks stored, Project.compute_srs runs the detector first, so the user does not have to find the events by hand.

Source code in src/visualdynamics/gui/main_window.py
def compute_srs(self) -> None:
    """Compute shock response spectra from the selected time history,
    one curve per channel per shock, and add them beside it.

    The shocks are the ones stored on the history, those the shock
    view shows at the time, and the spectrum's settings come from the
    shock panel. With no shocks stored, `Project.compute_srs` runs
    the detector first, so the user does not have to find the events
    by hand.
    """
    settings = self.data_pane.shock_panel.srs_settings()
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute an SRS',
        self.project.compute_srs, **settings)
    if acted is None:
        return
    name, obj, added = acted
    spectra = self.objects[added]
    # what the windows turned out to be, said in the terms they came
    # from: detected events, the frames the record is read as, or the
    # single playing a target is. `shocks` is None where nothing was
    # detected, which is now the ordinary case rather than an error
    events = len(obj.shocks or ())
    if events:
        over = f'{events} shock{"s" * (events != 1)}'
    elif obj.averaging is not None:
        over = (f'{obj.averaging.frames} '
                f'frame{"s" * (obj.averaging.frames != 1)}')
    else:
        over = 'the whole record'
    self.show_object(added)
    self._show_status(
        f'{added}: {spectra.num_records} spectra over {over} '
        f'at Q={spectra.q:g} — linked to {name}')
filter_data
filter_data() -> None

Filter every channel of the selected time history and add the result beside it.

The settings are the history's own; with none set, the suggested filter is used, as in Project.filter_data.

Source code in src/visualdynamics/gui/main_window.py
def filter_data(self) -> None:
    """Filter every channel of the selected time history and add the
    result beside it.

    The settings are the history's own; with none set, the suggested
    filter is used, as in `Project.filter_data`."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to filter',
        self.project.filter_data)
    if acted is None:
        return
    name, obj, added = acted
    filtering = obj.filtering
    self.show_object(added)
    self._show_status(
        f'{added}: {filtering.describe()}, order '
        f'{filtering.order}, zero phase — linked to {name}')
truncate_data
truncate_data() -> None

Cut every channel of the selected time history to its span, and add the result beside it.

The span is the history's own. With none set, it refuses and says where to set one: there is no suggested span to use, the way Filter Data uses a suggested filter, because keeping the whole record is not a truncation.

Source code in src/visualdynamics/gui/main_window.py
def truncate_data(self) -> None:
    """Cut every channel of the selected time history to its span, and
    add the result beside it.

    The span is the history's own. With none set, it refuses and says
    where to set one: there is no suggested span to use, the way
    Filter Data uses a suggested filter, because keeping the whole
    record is not a truncation."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to truncate',
        self.project.truncate_data)
    if acted is None:
        return
    name, obj, added = acted
    truncation = obj.truncation
    cut = self.objects[added]
    self.show_object(added)
    self._show_status(
        f'{added}: {truncation.describe()} — '
        f'{cut.ordinate.shape[-1]} of {obj.ordinate.shape[-1]} '
        f'samples, linked to {name}')
resample_data
resample_data() -> None

Take the selected time history to the lower rate the resample view shows, filtered first, and add the result beside it.

The rate is the record's over the panel's factor and the filter order the panel's, as Project.resample takes them.

Source code in src/visualdynamics/gui/main_window.py
def resample_data(self) -> None:
    """Take the selected time history to the lower rate the resample
    view shows, filtered first, and add the result beside it.

    The rate is the record's over the panel's factor and the filter
    order the panel's, as `Project.resample` takes them."""
    settings = self.data_pane.resample_panel.settings()
    obj = self.current_object()
    if not isinstance(obj, TimeHistory):
        self._show_status('Select a time history to resample')
        return
    rate = obj.sample_rate / settings['factor']
    acted = self._act_on(
        TimeHistory, 'Select a time history to resample',
        self.project.resample, rate, order=settings['order'])
    if acted is None:
        return
    name, _obj, added = acted
    self.show_object(added)
    self._show_status(f'{added}: {rate:g} Hz, filtered at order '
                      f'{settings["order"]} first — linked to {name}')
generate_rigid_body_modes
generate_rigid_body_modes() -> None

Make the six rigid-body mode shapes of the selected geometry, about the point the rigid-body pane shows, and add them to its object group.

It runs the same verb a script calls, so the new object records where it came from and is marked stale when its settings change.

Source code in src/visualdynamics/gui/main_window.py
def generate_rigid_body_modes(self) -> None:
    """Make the six rigid-body mode shapes of the selected geometry,
    about the point the rigid-body pane shows, and add them to its
    object group.

    It runs the same verb a script calls, so the new object records
    where it came from and is marked stale when its settings
    change."""
    acted = self._act_on(
        Geometry,
        'Select a geometry to make rigid body ' 'modes of',
        self.project.generate_rigid_body_modes)
    if acted is None:
        return
    name, obj, added = acted
    properties = obj.mass_properties
    self.show_object(added)
    self._show_status(
        f'{added}: 6 modes {self._describe_rigid(obj, properties)}, '
        f'linked to {name}')
mate_surfaces_act
mate_surfaces_act(name: str, groups) -> None

Open Mate Surfaces on element groups groups of geometry name, or put it away when it is open on them already.

Every patch where their surfaces mate is marked in the view and listed in the pane. A click on a node adds it to the picked patch's side or takes it away, and Keep adds the patch to the geometry's Mated Surfaces, tied along the surface's normal and in its plane, each rigidly, by a stiffness or not at all (Project.add_mated_surfaces).

Source code in src/visualdynamics/gui/main_window.py
def mate_surfaces_act(self, name: str, groups) -> None:
    """Open Mate Surfaces on element groups `groups` of geometry
    `name`, or put it away when it is open on them already.

    Every patch where their surfaces mate is marked in the view and
    listed in the pane. A click on a node adds it to the picked
    patch's side or takes it away, and Keep adds the patch to the
    geometry's Mated Surfaces, tied along the surface's normal and in
    its plane, each rigidly, by a stiffness or not at all
    (`Project.add_mated_surfaces`)."""
    # Brandon, 2026-10-20: on element groups picked, a patch at a
    # time, kept as a sub-object of the geometry.
    groups = sorted({int(g) for g in groups})
    mating = self._mating
    if (mating is not None and mating['editing'] is None and mating['name'] == name
            and mating['groups'] == groups):
        self.close_mate_pane()
        return
    self._open_mating(name, groups, None)
edit_mated_surfaces_act
edit_mated_surfaces_act(name: str, mate_id: int) -> None

Open Mate Surfaces on a kept set, to change its nodes, how it is joined or its name, and Save.

Source code in src/visualdynamics/gui/main_window.py
def edit_mated_surfaces_act(self, name: str, mate_id: int) -> None:
    """Open Mate Surfaces on a kept set, to change its nodes, how it
    is joined or its name, and Save."""
    self._open_mating(name, None, int(mate_id))
mate_node_at
mate_node_at(x: float, y: float) -> int | None

The node of the picked patch's side being picked whose place is under the pixel (x, y), or None.

Source code in src/visualdynamics/gui/main_window.py
def mate_node_at(self, x: float, y: float) -> int | None:
    """The node of the picked patch's side being picked whose place is
    under the pixel `(x, y)`, or None."""
    from ..viz.pick import ScreenProjector, _nearest_candidate

    mating = self._mating
    panel = self.scene.mate_panel
    current = panel.current_patch() if mating is not None else None
    if current is None:
        return None
    candidates = self._mate_candidates(current, panel.side)
    if not candidates:
        return None
    projector = ScreenProjector(self.scene.plotter.renderer,
                                self._mate_points(candidates))
    screen, depth = projector.screen()
    ratio = self.devicePixelRatioF()
    distances = np.linalg.norm(screen - np.array([x, y]), axis=1)
    index = _nearest_candidate(distances, depth, 12.0 * ratio, 2.0 * ratio)
    return None if index is None else int(candidates[index])
toggle_mate_node
toggle_mate_node(node: int) -> None

Add node to the picked patch's side being picked, or take it away, as a click on it does.

Source code in src/visualdynamics/gui/main_window.py
def toggle_mate_node(self, node: int) -> None:
    """Add `node` to the picked patch's side being picked, or take it
    away, as a click on it does."""
    mating = self._mating
    panel = self.scene.mate_panel
    current = panel.current_patch() if mating is not None else None
    if current is None:
        return
    held = mating['patches'][current]['nodes_' + panel.side]
    if node in held:
        held.remove(node)
    else:
        held.append(node)
    mating['said'] = ''
    mating['hovered'] = None
    panel.set_patch_text(current, self._patch_text(current))
    self._mate_refresh()
close_mate_pane
close_mate_pane(redraw: bool = True) -> None

Close the Mate Surfaces pane, its marks and its clicks.

Source code in src/visualdynamics/gui/main_window.py
def close_mate_pane(self, redraw: bool = True) -> None:
    """Close the Mate Surfaces pane, its marks and its clicks."""
    self.scene.mate_panel.hide()
    if self.__dict__.get('_mating') is None:
        return
    name = self._mating['name']
    self._mating = None
    interactor = getattr(self.scene.built_plotter, 'iren', None)
    for attribute in ('_mate_observer', '_mate_hover_observer'):
        observer = self.__dict__.get(attribute)
        if interactor is not None and observer is not None:
            with contextlib.suppress(Exception):
                interactor.interactor.RemoveObserver(observer)
        setattr(self, attribute, None)
    if self.scene.built_plotter is not None:
        self.scene.plotter.remove_actor('mate-hover', render=False)
    item = self._item_for_object(name)
    if item is not None:
        self._mark_pencils([item])
    if redraw:
        self.render_current()
    else:
        self._draw_mate_marks()
plan_instrumentation_act
plan_instrumentation_act() -> None

Open Plan Instrumentation on the selected shape set, on the modes picked in its grid or, with none picked, on every mode above 0 Hz.

Source code in src/visualdynamics/gui/main_window.py
def plan_instrumentation_act(self) -> None:
    """Open Plan Instrumentation on the selected shape set, on the
    modes picked in its grid or, with none picked, on every mode
    above 0 Hz."""
    from ..project import _plannable

    references = self.selected_references()
    names = {name for kind, name, _obj, _detail in references if kind in ('object', 'mode')}
    name = next(iter(names)) if len(names) == 1 else None
    if name is None or not _plannable(self.project, self.objects.get(name)):
        self._show_status('Select finite element modes on a model with solids, plates '
                          'or beams to plan the instrumentation for')
        return
    self._open_planning(name)
plan_surface_at
plan_surface_at(x: float, y: float) -> int | None

The surface under the device pixel (x, y) that a click can leave out or put back, or None: one of a checked element group.

Source code in src/visualdynamics/gui/main_window.py
def plan_surface_at(self, x: float, y: float) -> int | None:
    """The surface under the device pixel `(x, y)` that a click can
    leave out or put back, or None: one of a checked element group."""
    planning = self.__dict__.get('_planning')
    if planning is None or planning['picker'] is None:
        return None
    picked = planning['picker'].pick(x, y)
    if picked is None:
        return None
    _angle, found = self._planning_skin()
    groups = set(self.scene.instrumentation_panel.picked_groups)
    return picked if any(s.id == picked and s.group in groups for s in found) else None
toggle_plan_surface
toggle_plan_surface(surface: int) -> None

Leave surface out of the places sensors may go, or put it back, as a click on it does, and plan again.

Source code in src/visualdynamics/gui/main_window.py
def toggle_plan_surface(self, surface: int) -> None:
    """Leave `surface` out of the places sensors may go, or put it
    back, as a click on it does, and plan again."""
    planning = self.__dict__.get('_planning')
    if planning is None:
        return
    planning['off'] ^= {int(surface)}
    self._plan_now()
close_instrumentation_pane
close_instrumentation_pane(redraw: bool = True) -> None

Put Plan Instrumentation away; nothing is added.

Source code in src/visualdynamics/gui/main_window.py
def close_instrumentation_pane(self, redraw: bool = True) -> None:
    """Put Plan Instrumentation away; nothing is added."""
    self.scene.instrumentation_panel.hide()
    self._plan_timer.stop()
    for watched, observer in self.__dict__.get('_plan_observers') or []:
        with contextlib.suppress(Exception):
            watched.RemoveObserver(observer)
    self._plan_observers = []
    if self.__dict__.get('_planning') is None:
        return
    self._planning = None
    if redraw:
        self.render_current()
merge_nodes_act
merge_nodes_act(name: str, groups) -> None

Open the Merge Coincident Nodes pane on element groups groups of geometry name.

Every place where their nodes coincide is marked in the view, and the tolerance is set in the pane. A click on a mark leaves that place out of the merge or puts it back, and Merge makes each marked place one node, so a joint can be built a place at a time.

Source code in src/visualdynamics/gui/main_window.py
def merge_nodes_act(self, name: str, groups) -> None:
    """Open the Merge Coincident Nodes pane on element groups
    `groups` of geometry `name`.

    Every place where their nodes coincide is marked in the view, and
    the tolerance is set in the pane. A click on a mark leaves that
    place out of the merge or puts it back, and Merge makes each
    marked place one node, so a joint can be built a place at a
    time."""
    # Select, see, set, apply: Brandon, 2026-10-11.
    self._open_places(name, groups, 'merge')
align_meshes_act
align_meshes_act(name: str, groups) -> None

Open Align Meshes, in the same pane as the merge, on element groups groups of geometry name.

It is the step before a merge, for parts meshed separately whose nodes do not match. Each node of the coarser mesh that would move onto a node of the finer one is marked, with a line to where it goes, and the reach is set in the pane. A click on a mark leaves that move out, and Align makes the moves.

Source code in src/visualdynamics/gui/main_window.py
def align_meshes_act(self, name: str, groups) -> None:
    """Open Align Meshes, in the same pane as the merge, on element
    groups `groups` of geometry `name`.

    It is the step before a merge, for parts meshed separately whose
    nodes do not match. Each node of the coarser mesh that would move
    onto a node of the finer one is marked, with a line to where it
    goes, and the reach is set in the pane. A click on a mark leaves
    that move out, and Align makes the moves."""
    # Brandon, 2026-10-11.
    self._open_places(name, groups, 'align')
toggle_merge_place
toggle_merge_place(index: int) -> None

Leave place index out of the merge, or put it back, as a click on its mark does.

Source code in src/visualdynamics/gui/main_window.py
def toggle_merge_place(self, index: int) -> None:
    """Leave place `index` out of the merge, or put it back, as a
    click on its mark does."""
    merging = self._merging
    if merging is None or not 0 <= index < len(merging['spots']):
        return
    kept = merging['spots'][index][0]
    merging['off'] ^= {kept}
    self._merge_refresh()
merge_place_at
merge_place_at(x: float, y: float) -> int | None

The index of the place whose mark is under the pixel (x, y), or None.

Source code in src/visualdynamics/gui/main_window.py
def merge_place_at(self, x: float, y: float) -> int | None:
    """The index of the place whose mark is under the pixel
    `(x, y)`, or None."""
    from ..viz.pick import ScreenProjector, _nearest_candidate

    if self._merging is None or not self._merging['spots']:
        return None
    projector = ScreenProjector(self.scene.plotter.renderer,
                                self._merge_points())
    screen, depth = projector.screen()
    ratio = self.devicePixelRatioF()
    distances = np.linalg.norm(screen - np.array([x, y]), axis=1)
    return _nearest_candidate(distances, depth, 12.0 * ratio, 2.0 * ratio)
close_merge_pane
close_merge_pane(redraw: bool = True) -> None

Close the merge pane, removing its marks and its handling of clicks. With redraw, the view is drawn again with the groups opaque; a caller about to draw anyway passes False.

Source code in src/visualdynamics/gui/main_window.py
def close_merge_pane(self, redraw: bool = True) -> None:
    """Close the merge pane, removing its marks and its handling of
    clicks. With `redraw`, the view is drawn again with the groups
    opaque; a caller about to draw anyway passes False."""
    self.scene.merge_panel.hide()
    if self._merging is None:
        return
    self._merging = None
    interactor = getattr(self.scene.built_plotter, 'iren', None)
    if interactor is not None and self._merge_observer is not None:
        with contextlib.suppress(Exception):
            interactor.interactor.RemoveObserver(self._merge_observer)
    self._merge_observer = None
    if redraw:
        self.render_current()
    else:
        self._draw_merge_marks()
new_geometry_act
new_geometry_act() -> None

Add an empty geometry to the project, in the display length unit, and select it. Its Solids row offers the shapes a part is built from, plates and disks among them.

Source code in src/visualdynamics/gui/main_window.py
def new_geometry_act(self) -> None:
    """Add an empty geometry to the project, in the display length
    unit, and select it. Its Solids row offers the shapes a part is
    built from, plates and disks among them."""
    unit = self.unit_system.unit('length')
    name = self.project.new_geometry('Geometry', unit=unit)
    self.show_object(name)
    self._show_status(f'{name}: an empty geometry, lengths in {unit} — '
                      'select its Solids row to add a plate, a block or another shape')
new_channel_table_act
new_channel_table_act() -> None

Add an empty channel table to the project and select it. Its bar offers Add Channel.

Source code in src/visualdynamics/gui/main_window.py
def new_channel_table_act(self) -> None:
    """Add an empty channel table to the project and select it. Its
    bar offers Add Channel."""
    name = self.project.new_channel_table()
    self.show_object(name)
    self._show_status(f'{name}: an empty channel table — Add Channel '
                      'adds a row to type into')
add_channel_act
add_channel_act() -> None

Add a blank channel to the selected channel table, numbered after its highest, and put the cursor on its row.

Source code in src/visualdynamics/gui/main_window.py
def add_channel_act(self) -> None:
    """Add a blank channel to the selected channel table, numbered
    after its highest, and put the cursor on its row."""
    acted = self._act_on(ChannelTable, 'Select a channel table to add a '
                         'channel to', self.project.add_channels)
    if acted is None:
        return
    name, table, (number,) = acted
    self._refresh_item(self._item_for_object(name), table)
    self.refresh_compatibility()
    self.render_current()
    model = self.table.model()
    if model is not None and model.rowCount():
        row = model.rowCount() - 1
        self.table.selectRow(row)
        self.table.scrollTo(model.index(row, 0))
    self._show_status(f'{name}: added channel {number}')
edit_solid_pane
edit_solid_pane(name: str, solid_id: int) -> None

Open the add pane on solid solid_id of geometry name, as its row's pencil does, to move, size or reshape it.

Its values are in the fields, the gizmo on it slides and turns it, and its preview is drawn as it is typed. Apply runs the project's edit_solid. A boolean has no size of its own, so its pane moves and turns it whole, with every solid it is made of (move_solids). A pattern opens the Pattern pane on its counts and spacing instead.

Source code in src/visualdynamics/gui/main_window.py
def edit_solid_pane(self, name: str, solid_id: int) -> None:
    """Open the add pane on solid `solid_id` of geometry `name`, as its
    row's pencil does, to move, size or reshape it.

    Its values are in the fields, the gizmo on it slides and turns
    it, and its preview is drawn as it is typed. Apply runs the
    project's `edit_solid`. A boolean has no size of its own, so its
    pane moves and turns it whole, with every solid it is made of
    (`move_solids`). A pattern opens the Pattern pane on its counts
    and spacing instead."""
    # Brandon, 2026-10-11.
    from ..core import solids

    item = solids.solid(self.objects[name], solid_id)
    if item.kind == solids.PATTERN:
        # a pattern is its counts and spacing: Pattern's own pane
        self._open_pattern_pane(name, list(item.operands), editing=solid_id)
        return
    kind = ({'block': 'solid_block', 'cylinder': 'solid_cylinder',
             'cone': 'solid_cone', 'sphere': 'solid_sphere',
             'plate': 'solid_plate', 'disk': 'solid_disk',
             solids.HOLE: 'solid_hole'}.get(item.kind)
            or ('solid_section' if item.kind in solids.SECTIONS else 'solid_part'))
    self._open_mesh_pane(kind, editing=(name, solid_id))
close_mesh_pane
close_mesh_pane() -> None

Close the add pane, removing its preview and the gizmo.

Source code in src/visualdynamics/gui/main_window.py
def close_mesh_pane(self) -> None:
    """Close the add pane, removing its preview and the gizmo."""
    if self._mesh is None:
        self.scene.mesh_panel.hide()
        return
    self._end_rotating()
    editing = self._mesh.get('editing')
    self._mesh = None
    self.scene.mesh_panel.hide()
    self._draw_plane_preview(None)
    if editing is not None:
        self._mark_pencils([self._item_for_object(editing[0])])
    if self.scene.built_plotter is not None:
        self.scene.plotter.render()
set_creator_name
set_creator_name() -> None

File → Creator Name…: set the name an exported ESCDF file records as its creator, remembered between launches. Blank means the login name.

Source code in src/visualdynamics/gui/main_window.py
def set_creator_name(self) -> None:
    """File → Creator Name…: set the name an exported ESCDF file
    records as its creator, remembered between launches. Blank means
    the login name."""
    from PySide6.QtWidgets import QInputDialog

    name, ok = QInputDialog.getText(
        self, 'Creator Name', 'The name an ESCDF file records as its '
        'creator (blank: the login name):', text=remembered_creator())
    if ok:
        remember_creator(name)
        self._show_status(f'ESCDF files will name {name.strip() or "the login name"} '
                          'as their creator')
set_view_act
set_view_act() -> None

Make the view on screen the selected geometry's default view.

The geometry and everything drawn on it then open on this view, in the window, in reports and in exported figures, and Reset View returns to it. Only the camera's direction and up vector are kept, not its distance.

Source code in src/visualdynamics/gui/main_window.py
def set_view_act(self) -> None:
    """Make the view on screen the selected geometry's default view.

    The geometry and everything drawn on it then open on this view,
    in the window, in reports and in exported figures, and Reset View
    returns to it. Only the camera's direction and up vector are
    kept, not its distance."""
    # Brandon, 2026-09-27.
    from ..core.geometry import View

    obj = self.current_object()
    plotter = self.scene.plotter
    if not isinstance(obj, Geometry) or plotter is None:
        self._show_status('Select a geometry to set its default view')
        return
    position, focus, up = plotter.camera_position
    # a unit direction, four decimals: how far away the camera stood
    # is the fit's business, and no hand turns a model finer
    eye = np.subtract(position, focus)
    view = View(np.round(eye / np.linalg.norm(eye), 4),
                np.round(np.asarray(up) / np.linalg.norm(up), 4))
    self.project.set_view(obj, view)
    self.scene.set_home_view(obj.opening_view, obj)
    self._report_content_changed(settling=True)
    self._show_status(f'{self.project.name_of(obj)} opens on this view '
                      'now — Reset View returns to it')
solve_modes_act
solve_modes_act() -> None

Solve the normal modes of the selected geometry, from its element groups' properties, and add the mode set to the geometry's object group.

How far to solve (a number of modes or a highest frequency) and the damping to give every mode are the Modes pane's, finite element kind.

Source code in src/visualdynamics/gui/main_window.py
def solve_modes_act(self) -> None:
    """Solve the normal modes of the selected geometry, from its
    element groups' properties, and add the mode set to the
    geometry's object group.

    How far to solve (a number of modes or a highest frequency) and
    the damping to give every mode are the Modes pane's, finite
    element kind."""
    # The two numbers were asked in two dialogs until the pane took
    # them (Brandon, 2026-10-22).
    obj = self.current_object()
    if not (isinstance(obj, Geometry) and obj.group_properties):
        self._show_status('Select a geometry whose element groups carry their '
                          'properties (the Element Groups table) to solve')
        return
    panel = self.scene.modes_panel
    limit, percent = panel.limit(), panel.damping() * 100.0
    solving = (f'Solving for {limit["num_modes"]} modes…'
               if 'num_modes' in limit else
               f'Solving modes to {limit["maximum_frequency"]:g} Hz…')
    acted = self._act_on(
        Geometry, 'Select a geometry to solve',
        lambda name, **options: self._run_long(
            solving, self.project.solve_modes, name, **options),
        **limit, damping=percent / 100.0)
    if acted is None:
        return
    name, _geometry, added = acted
    shapes = self.objects[added]
    rigid = int(np.sum(shapes.frequency == 0.0))
    # after the show, which writes its own count to the status bar
    self.show_object(added)
    self._show_status(
        f'{added}: {shapes.num_shapes} modes to '
        f'{float(shapes.frequency.max()):.4g} Hz, {rigid} rigid, '
        f'{percent:g}% damping — linked to {name}')
convert_to_specification_act
convert_to_specification_act() -> None

The bar's Convert to Specification: the selected PSD or CPSD is made a specification, in place and under the same name (Project.convert_to_specification).

Source code in src/visualdynamics/gui/main_window.py
def convert_to_specification_act(self) -> None:
    """The bar's Convert to Specification: the selected PSD or CPSD
    is made a specification, in place and under the same name
    (`Project.convert_to_specification`)."""
    self._read_as('convert_to_specification',
                  'Select a PSD or CPSD to convert to a specification',
                  self.project.convert_to_specification,
                  'converted to a specification — compare a measurement '
                  'against it by selecting both')
convert_to_psd_act
convert_to_psd_act() -> None

The bar's Convert to PSD (Convert to CPSD with cross terms): the selected specification is made a plain PSD, in place and under the same name, its limits left out (Project.convert_to_psd).

Source code in src/visualdynamics/gui/main_window.py
def convert_to_psd_act(self) -> None:
    """The bar's Convert to PSD (Convert to CPSD with cross terms):
    the selected specification is made a plain PSD, in place and
    under the same name, its limits left out
    (`Project.convert_to_psd`)."""
    obj = self.current_object()
    if isinstance(obj, Specification) and obj.interpolation != 'bin':
        # at breakpoints: the spacing is chosen in a pane first
        # (Brandon, 2026-10-30)
        name = self.project.name_of(obj)
        if self._lining is not None and self._lining['name'] == name:
            self.close_lines_pane()
            return
        self._author_wanted = False
        self._signaling = self._modaling = None
        self.data_pane.signal_panel.hide()
        self.data_pane.modal_response_panel.hide()
        self._lining = {'name': name,
                        'spacing': self.project.remembered_spacing(name)}
        self.render_current()
        return
    kind = ('CPSD' if isinstance(obj, Psd) and not obj.is_autospectrum()
            else 'PSD')
    said = f'converted to a {kind}'
    if isinstance(obj, Specification) and obj.has_limits:
        said += ' — its warning and abort limits were left out'
    self._read_as('convert_to_psd',
                  'Select a specification to convert to a PSD',
                  self.project.convert_to_psd, said)
close_lines_pane
close_lines_pane() -> None

Put the Convert to PSD/CPSD pane away.

Source code in src/visualdynamics/gui/main_window.py
def close_lines_pane(self) -> None:
    """Put the Convert to PSD/CPSD pane away."""
    self._lining = None
    self.data_pane.lines_panel.hide()
    self.render_current()
integrate_history
integrate_history() -> None

Integrate the selected time history once (acceleration to velocity, velocity to displacement), and add the result beside it.

The drift is high-passed out, and records of other quantities are left out, as the status bar says.

Source code in src/visualdynamics/gui/main_window.py
def integrate_history(self) -> None:
    """Integrate the selected time history once (acceleration to
    velocity, velocity to displacement), and add the result beside
    it.

    The drift is high-passed out, and records of other quantities are
    left out, as the status bar says."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to integrate',
        self.project.integrate)
    if acted is None:
        return
    name, obj, added = acted
    result = self.objects[added]
    left_out = obj.num_records - result.num_records
    aside = (f' — {left_out} record{"s" * (left_out != 1)} of other '
             'quantities left out' if left_out else '')
    self._show_status(
        f'{added}: {result.num_records} channel'
        f'{"s" * (result.num_records != 1)} integrated, drift '
        f'high-passed{aside} — linked to {name}')
    self.show_object(added)
differentiate_history
differentiate_history() -> None

Differentiate the selected time history once (displacement to velocity, velocity to acceleration), and add the result beside it.

Source code in src/visualdynamics/gui/main_window.py
def differentiate_history(self) -> None:
    """Differentiate the selected time history once (displacement to
    velocity, velocity to acceleration), and add the result beside
    it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to differentiate',
        self.project.differentiate)
    if acted is None:
        return
    name, _obj, added = acted
    result = self.objects[added]
    self._show_status(
        f'{added}: {result.num_records} channel'
        f'{"s" * (result.num_records != 1)} differentiated — '
        f'linked to {name}')
    self.show_object(added)
compute_cpsds
compute_cpsds() -> None

Compute the full cross-spectral matrix of the selected time history, every channel against every channel, and add it beside it.

Source code in src/visualdynamics/gui/main_window.py
def compute_cpsds(self) -> None:
    """Compute the full cross-spectral matrix of the selected time
    history, every channel against every channel, and add it beside
    it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute CPSDs',
        self.project.compute_cpsds)
    if acted is None:
        return
    name, obj, added = acted
    cpsds = self.objects[added]
    channels = round(cpsds.num_records ** 0.5)
    frames, where = self._averaged_frames(obj, channels)
    # shown first: selecting it renders, and the render restates the
    # status line over anything said before it
    self.show_object(added)
    self._show_status(
        f'{added}: {channels}x{channels} cross-spectral matrix '
        f'averaged over {frames} frame{"s" * (frames != 1)}{where} — '
        f'linked to {name}')
generate_report
generate_report(template: str = 'empty') -> str

Add a new Report object from a starter template, select it to edit, and return its name.

template is a built-in template's name ('modal', 'random', 'transient', 'shock', 'sine', 'sysid', 'mimo' or 'empty') or the path of a saved template. Templates refer to objects by role, such as '@basis:Frf', resolved against the object groups when the report is drawn, so the report depends on the project's structure and not on what the objects are named.

Source code in src/visualdynamics/gui/main_window.py
def generate_report(self, template: str = 'empty') -> str:
    """Add a new Report object from a starter template, select it to
    edit, and return its name.

    `template` is a built-in template's name ('modal', 'random',
    'transient', 'shock', 'sine', 'sysid', 'mimo' or 'empty') or the
    path of a saved template. Templates refer to objects by role, such
    as '@basis:Frf', resolved against the object groups when the report
    is drawn, so the report depends on the project's structure and
    not on what the objects are named.
    """
    name = self.show_object(self.project.generate_report(
        template, name=display_name('Report')))
    self._show_status(
        f'{name}: click a block in the page, edit it in the pane '
        'beside; Insert, Move, Delete and Export are on the bar')
    return name
export_report_template
export_report_template() -> None

Save the selected report on its own, as a template another project can load. The save dialog opens in the templates folder, which is where Generate Report looks for saved templates.

Source code in src/visualdynamics/gui/main_window.py
def export_report_template(self) -> None:
    """Save the selected report on its own, as a template another
    project can load. The save dialog opens in the templates folder,
    which is where Generate Report looks for saved templates."""
    import pathlib

    from ..io.report_template import SUFFIX, templates_folder

    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to save as a template')
        return
    name = self.object_item().text(0)
    folder = templates_folder()
    folder.mkdir(parents=True, exist_ok=True)
    path, _ = QFileDialog.getSaveFileName(
        self, 'Save Report Template', str(folder / f'{name}{SUFFIX}'),
        f'Report template (*{SUFFIX})')
    if not path:
        return
    if not path.endswith(SUFFIX):
        path += SUFFIX
    self.project.export(name, path)
    where = (' — Generate Report offers it'
             if pathlib.Path(path).parent == folder else '')
    self._show_status(f'Saved template {os.path.basename(path)}{where}')
reports_from_runs
reports_from_runs() -> None

File → Reports from Runs…: run visualdynamics.run_report over a batch of controller runs.

The runs and the options are asked for in dialogs, and the work runs with a progress bar and Cancel; reports already written when it is canceled are kept. The open project is not changed: each run is worked up in a project of its own, as the script does it, and its report is written to disk. The reports use the window's unit system.

Source code in src/visualdynamics/gui/main_window.py
def reports_from_runs(self) -> None:
    """File → Reports from Runs…: run `visualdynamics.run_report` over
    a batch of controller runs.

    The runs and the options are asked for in dialogs, and the work
    runs with a progress bar and Cancel; reports already written when
    it is canceled are kept. The open project is not changed: each
    run is worked up in a project of its own, as the script does it,
    and its report is written to disk. The reports use the window's
    unit system.
    """
    from ..core.progress import Cancelled
    from ..project import run_report
    from .ask import RUN_FILTER
    from .run_reports import ask_run_reports

    with self._asking():
        runs, _ = QFileDialog.getOpenFileNames(
            self, 'Select the runs to report on', '', RUN_FILTER)
        if not runs:
            return
        choices = ask_run_reports(self, [str(run) for run in runs])
    if choices is None:
        return
    count = len(choices['runs'])
    plural = 's' * (count != 1)
    try:
        written = self._run_long(
            f'Writing {count} report{plural}…', run_report,
            choices['runs'], choices['path'],
            geometry=choices['geometry'], marking=choices['marking'],
            kinds=choices.get('kinds'), last=choices.get('last'),
            unit_system=self.unit_system)
    except Cancelled:
        self._show_status('Reports from runs cancelled; the reports '
                          'already written are kept')
        return
    except (ValueError, OSError) as failure:
        QMessageBox.warning(self, 'Reports from Runs', str(failure))
        return
    where = (f"to {choices['path']}" if choices['path'] is not None
             else 'beside each run')
    self._show_status(
        f'Wrote {len(written)} report{"s" * (len(written) != 1)} {where}')
export_report
export_report() -> None

Export the selected report as one self-contained HTML file.

Source code in src/visualdynamics/gui/main_window.py
def export_report(self) -> None:
    """Export the selected report as one self-contained HTML file."""
    from ..report import render_html

    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to export')
        return
    name = self.object_item().text(0)
    if not self._export_owed(obj, name):
        return
    if not self._export_index(obj, name):
        return
    path, _ = QFileDialog.getSaveFileName(
        self, 'Export Report', f'{name}.html', 'Report (*.html)')
    if not path:
        return
    if not path.endswith('.html'):
        path += '.html'
    skipped = len(obj.unbound(self.objects))
    # the check readings on the worker first, so the page is written
    # without reading any on this thread
    if not self._prepare_report_checks(obj):
        self._show_status('The report checks wait on another task; '
                          'nothing was exported')
        return
    page = render_html(obj, self.objects, self.unit_system,
                       object_groups=self.object_groups)
    from ..report.identity import guard

    try:
        # an issued report is written again only unchanged
        guard(obj, page)
    except ValueError as refused:
        QMessageBox.warning(self, 'Export Report', str(refused))
        return
    with open(path, 'w', encoding='utf-8') as out:
        out.write(page)
    note = (f' ({skipped} unbound block'
            f'{"s" * (skipped != 1)} left out)') if skipped else ''
    self._show_status(
        f'Exported {os.path.basename(path)}{note} — opens in any '
        f'browser, nothing to install{self._index_said(obj)}')
export_report_pdf
export_report_pdf() -> None

Export the selected report as a paginated PDF (Project.export_report_pdf), rendered and printed off the window's thread. With the data index on, its files are hashed again first and a file that does not match is asked about, as for the HTML export.

Source code in src/visualdynamics/gui/main_window.py
def export_report_pdf(self) -> None:
    """Export the selected report as a paginated PDF
    (`Project.export_report_pdf`), rendered and printed off the
    window's thread. With the data index on, its files are hashed
    again first and a file that does not match is asked about, as
    for the HTML export."""
    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to export')
        return
    name = self.object_item().text(0)
    if not self._export_owed(obj, name):
        return
    if not self._export_index(obj, name):
        return
    path, _ = QFileDialog.getSaveFileName(
        self, 'Export Report as PDF', f'{name}.pdf', 'PDF (*.pdf)')
    if not path:
        return
    if not path.endswith('.pdf'):
        path += '.pdf'
    try:
        # the index was checked above; the write alone, so it is
        # not hashed twice
        self._run_long(f'Printing {os.path.basename(path)}…',
                       lambda progress=None: self.project._export_pdf(
                           name, path, self.unit_system))
    except (ValueError, OSError) as refused:
        QMessageBox.warning(self, 'Export Report as PDF', str(refused))
        return
    self._show_status(f'Exported {os.path.basename(path)}'
                      f'{self._index_said(obj)}')
issue_report
issue_report() -> None

Issue the selected report as a PDF under its number and revision (Project.issue_report). What stands in the way (a field not entered, a section owed, a report already issued) is said before the file is asked for, and with the data index on, its files are hashed again and a file that does not match is asked about before then too.

Source code in src/visualdynamics/gui/main_window.py
def issue_report(self) -> None:
    """Issue the selected report as a PDF under its number and
    revision (`Project.issue_report`). What stands in the way (a
    field not entered, a section owed, a report already issued) is
    said before the file is asked for, and with the data index on,
    its files are hashed again and a file that does not match is
    asked about before then too."""
    from ..report.identity import issue_ready
    from ..report.identity import name as identity_name

    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to issue')
        return
    name = self.object_item().text(0)
    try:
        issue_ready(obj, self.objects, self.object_groups)
    except ValueError as refused:
        QMessageBox.warning(self, 'Issue Report', str(refused))
        return
    if not self._export_index(obj, name, 'Issue'):
        return
    number = str(obj.identity.get('number', name))
    path, _ = QFileDialog.getSaveFileName(
        self, 'Issue Report', f'{number}.pdf', 'PDF (*.pdf)')
    if not path:
        return
    if not path.endswith('.pdf'):
        path += '.pdf'
    try:
        # the index was checked above; the write alone, so it is
        # not hashed twice
        self._run_long(f'Issuing {identity_name(obj)}…',
                       lambda progress=None: self.project._issue_pdf(
                           name, path, self.unit_system))
    except (ValueError, OSError) as refused:
        QMessageBox.warning(self, 'Issue Report', str(refused))
        return
    if self.report_editor is not None and self.report_editor.report is obj:
        self.report_editor.rebuild()
        self.report_editor._show_selection()
    self._show_status(f'Issued {identity_name(obj)} as '
                      f'{os.path.basename(path)}')
attach_to_report
attach_to_report() -> None

Add a file to the data index from the report editor: the file, what it is, and whether it goes with the report or is kept in the archive; journaled as index_file.

Source code in src/visualdynamics/gui/main_window.py
def attach_to_report(self) -> None:
    """Add a file to the data index from the report editor: the
    file, what it is, and whether it goes with the report or is
    kept in the archive; journaled as `index_file`."""
    from PySide6.QtWidgets import QInputDialog

    from ..core.progress import Cancelled

    with self._asking():
        path, _ = QFileDialog.getOpenFileName(
            self, 'Attach a File to the Report', '', 'Any file (*)')
    if not path:
        return
    base = os.path.basename(path)
    with self._asking():
        holds, ok = QInputDialog.getText(
            self, 'Attach a File',
            f'What {base} is or holds (a settings list, the log, the '
            'as-run procedure):')
    if not ok or not holds.strip():
        return
    roles = ['Attached: sent with the report',
             'Referenced: named in the report, kept in the archive']
    with self._asking():
        choice, ok = QInputDialog.getItem(
            self, 'Attach a File', f'{base} goes with the report as:',
            roles, 0, False)
    if not ok:
        return
    role = 'attached' if choice == roles[0] else 'referenced'
    try:
        if os.path.getsize(path) > self.INLINE_HASH_BYTES:
            entry = self._run_long(f'Checksumming {base}…',
                                   self.project.index_file, path, holds,
                                   role=role)
        else:
            entry = self.project.index_file(path, holds, role=role)
    except Cancelled:
        self._show_status(f'{base} was not added to the data index')
        return
    except (OSError, ValueError) as failure:
        QMessageBox.warning(self, 'Attach a File', str(failure))
        return
    self._show_status(f'{entry.name} added to the data index, '
                      f'{entry.role}')
    if self.report_editor is not None:
        self.report_editor.rebuild()
unindex_from_report
unindex_from_report(name: str) -> None

Take a file out of the data index from the report editor; journaled as unindex_file.

Source code in src/visualdynamics/gui/main_window.py
def unindex_from_report(self, name: str) -> None:
    """Take a file out of the data index from the report editor;
    journaled as `unindex_file`."""
    try:
        self.project.unindex_file(name)
    except ValueError as failure:
        QMessageBox.warning(self, 'Remove from Index', str(failure))
        return
    self._show_status(f'{name} taken out of the data index')
    if self.report_editor is not None:
        self.report_editor.rebuild()
merge_selected
merge_selected() -> None

Replace the selected objects with one object that combines them (Project.merge).

Source code in src/visualdynamics/gui/main_window.py
def merge_selected(self) -> None:
    """Replace the selected objects with one object that combines
    them (`Project.merge`)."""
    chosen = self._merge_candidates()
    if chosen is None:
        return
    names = [name for name, _obj in chosen]
    self.tree.blockSignals(True)
    try:
        for name in names:
            self._forget_object_row(name)
        merged_name = self.show_object(self.project.merge(
            *names, name=display_name(
                type(self.objects[names[0]]).__name__)))
    finally:
        self.tree.blockSignals(False)
    self.refresh_compatibility()
    self.render_current()
    what = ', '.join(name for name, _obj in chosen)
    self._show_status(f'Merged {what} into {merged_name}')
start_modal_fit
start_modal_fit() -> None

Start fitting real normal modes to the selected FRF object, one mode at a time.

The measured CMIF is drawn solid and the fit's synthesis dashed over it. A cursor is placed on the largest residual peak. The search is limited to the frequencies on screen, so zooming says where to look, and the cursor moves with the zoom rather than being left off screen. The table beside the plot lists the fitted modes and the pending one. Find Mode moves the cursor to the largest residual peak in view, and Confirm Mode adds the pending mode and moves to the next residual peak, since a confirmed peak is removed from the residual. A shape set selected with the FRFs starts the session with its modes and receives every change in place. Only one FRF object can be fitted at a time.

Source code in src/visualdynamics/gui/main_window.py
def start_modal_fit(self) -> None:
    """Start fitting real normal modes to the selected FRF object, one
    mode at a time.

    The measured CMIF is drawn solid and the fit's synthesis dashed
    over it. A cursor is placed on the largest residual peak. The
    search is limited to the frequencies on screen, so zooming says
    where to look, and the cursor moves with the zoom rather than
    being left off screen. The table beside the plot lists the fitted
    modes and the pending one. Find Mode moves the cursor to the
    largest residual peak in view, and Confirm Mode adds the pending
    mode and moves to the next residual peak, since a confirmed peak
    is removed from the residual. A shape set selected with the FRFs
    starts the session with its modes and receives every change in
    place. Only one FRF object can be fitted at a time.
    """
    # a fresh session, and the previous one's drag budget is no part
    # of it
    self._fit_dear_cost = 0.0
    self._fit_synthesis_stale = True
    # the fitting screen is the flat plot — the CMIF, the cursor and
    # the corner range fields all live on it. Said here as well as
    # in render_current because the toolbar's Fit button reaches
    # this directly, and an FRF showing as a waterfall would
    # otherwise host the whole fit on a hidden surface
    self.data_pane.show_waterfall(False)
    selected = self.selected_references()
    frfs = {name: obj for _kind, name, obj, _detail in selected
                if isinstance(obj, Frf)}
    shape_sets = {name: obj for _kind, name, obj, _detail
                      in selected if isinstance(obj, ShapeSet)}
    if len(frfs) != 1:
        self._show_status('Select one FRF object to fit modes to')
        return
    name, obj = next(iter(frfs.items()))
    records = sorted({d for k, n, _o, d in selected
                      if n == name and k == 'record'}) or None
    self.stop_editing()
    self.close_units_panel()
    # the measurement's own statement of which channel to believe
    # where: with a coherence in the project covering these
    # responses, Refine All weights its solve and its judgment by
    # it — a channel the coherence distrusts cannot vote noise into
    # every mode's residues. Chosen by the project's rule, and
    # journaled with the records, so the fit_modes line replays
    # this fit and not another (it did not, until 2026-10-09)
    coherence = self.project.fit_coherence(name, records)
    self.fit = ModalFitSession(
        obj, records,
        None if coherence is None else self.objects[coherence])
    self._fit_coherence_name = (coherence if self.fit.weights is not None
                                else None)
    self.fit_name = name
    self.fit_object_name = None
    if len(shape_sets) == 1:
        # a co-selected shape set is the fit to edit: its modes seed
        # the session, and every change publishes back to it in place
        seed_name, seed = next(iter(shape_sets.items()))
        self.fit.adopt(seed)
        self.fit_object_name = seed_name
    # stacked: the CMIF wants the full width up top, with the mode
    # table bottom-left and the MAC bottom-right under it — side by
    # side it was tall and skinny
    self.views.setOrientation(Qt.Orientation.Vertical)
    self._show_views(plots=True, table=True)
    self.fit_bar.show()
    # the plot bar over the CMIF offers the one overlay the fit
    # has: the residual. Show/hide toggles live there, with their
    # kin, rather than among the fit's verbs.
    self.data_pane.show_controls(
        map_wanted=None, diagonal=None, cmif=False,
        complex_data=False, pair=False, residual=True)
    self._fit_model = modal_fit_table_model(self.fit, self)
    self._set_table_model(self._fit_model)
    self._fit_model.dataChanged.connect(self._fit_edited)
    # a fitted mode can be taken back: select its row, hit Delete
    self.table.rows_deletable = True
    self._rows_wired = True
    self.table.rows_deleted.connect(self._delete_fit_modes)
    self._render_fit()
refine_all_modes
refine_all_modes() -> None

Fit the residues of every confirmed mode again, together, with the frequencies and dampings held. It needs at least two confirmed modes; ModalFitSession.refine_residues explains what it corrects.

Source code in src/visualdynamics/gui/main_window.py
def refine_all_modes(self) -> None:
    """Fit the residues of every confirmed mode again, together, with
    the frequencies and dampings held. It needs at least two
    confirmed modes; `ModalFitSession.refine_residues` explains what
    it corrects."""
    if self.fit is None or len(self.fit.modes) < 2:
        return
    before = [mode['shape'].copy() for mode in self.fit.modes]
    count = self.fit.refine_residues()
    changed = any(not np.array_equal(early, mode['shape'])
                  for early, mode in zip(before, self.fit.modes))
    self._publish_fit()
    self._render_fit()
    # the CMIF may have chosen the sequential answer — see
    # refine_residues — and saying "re-fit" about an unchanged fit
    # would send the eye hunting for a difference that is not there
    self._show_status(
        f'Re-fit {count} modes\' shapes together — frequencies and '
        'dampings kept' if changed else
        'Refine changed nothing — the fits are already as '
        'consistent as the measured CMIF supports')
find_next_mode
find_next_mode() -> None

Move the cursor to the next mode to fit: the largest CMIF peak left in the residual, within the frequencies the plot shows.

Confirm Mode moves there too once a mode is added; this does it alone, to look before confirming anything. This is the Find Mode button.

Source code in src/visualdynamics/gui/main_window.py
def find_next_mode(self) -> None:
    """Move the cursor to the next mode to fit: the largest CMIF peak
    left in the residual, within the frequencies the plot shows.

    Confirm Mode moves there too once a mode is added; this does it
    alone, to look before confirming anything. This is the Find Mode
    button.
    """
    if self.fit is None:
        return
    # A settle left armed by the gesture before this one would fire
    # a quarter-second from now and put the resting message back
    # over the one below — and the gesture before this one is
    # almost always the zoom that said where to look, since the
    # zoom carries the cursor. Canceled rather than raced: this
    # has already done the settle's work by rendering, and Find
    # Mode is a look rather than a drag catching up.
    self._fit_settle.stop()
    window = self._fit_window()
    low, high = self.fit.searched(window)
    frequency, damping = self.fit.suggest(window)
    self._fit_updating = True
    try:
        row = len(self.fit.modes)
        self._fit_model.dataChanged.emit(
            self._fit_model.index(row, 1), self._fit_model.index(row, 2))
    finally:
        self._fit_updating = False
    self._render_fit()
    self._show_status(
        f'Largest residual peak at {frequency:.2f} Hz, '
        f'{damping * 100:.2f}% — Confirm Mode to adopt it '
        f'(searched {low:.4g} to {high:.4g} Hz)')
fit_pending_mode
fit_pending_mode() -> None

Fit the pending mode at the cursor without confirming it, and draw the table and the plot again.

There is no button for this, because dragging the cursor fits as it goes. It is for a caller that changed something another way, such as a damping typed into the table or a script driving the fit with no cursor to drag.

Source code in src/visualdynamics/gui/main_window.py
def fit_pending_mode(self) -> None:
    """Fit the pending mode at the cursor without confirming it, and
    draw the table and the plot again.

    There is no button for this, because dragging the cursor fits as
    it goes. It is for a caller that changed something another way,
    such as a damping typed into the table or a script driving the
    fit with no cursor to drag.
    """
    # The Fit Mode button was removed: dragging fits as it goes and
    # the MAC follows a moment after the cursor stops, so by the time
    # anyone reached for the button the work was already done.
    if self.fit is None:
        return
    self.fit.fit_pending()
    self._fit_updating = True
    try:
        row = len(self.fit.modes)
        self._fit_model.dataChanged.emit(
            self._fit_model.index(row, 1), self._fit_model.index(row, 2))
    finally:
        self._fit_updating = False
    self._render_fit()

Functions: