MoleditPy 2.7.2__tar.gz → 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {moleditpy-2.7.2 → moleditpy-2.8.0}/PKG-INFO +4 -3
- {moleditpy-2.7.2 → moleditpy-2.8.0}/README.md +1 -1
- {moleditpy-2.7.2 → moleditpy-2.8.0}/pyproject.toml +3 -2
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/PKG-INFO +4 -3
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/requires.txt +6 -1
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/angle_dialog.py +161 -92
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/bond_length_dialog.py +96 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/calculation_worker.py +235 -63
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/constants.py +1 -1
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/dihedral_dialog.py +102 -8
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_compute.py +110 -58
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_dialog_manager.py +2 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_edit_actions.py +3 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_main_init.py +11 -4
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_view_3d.py +13 -1
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/mol_geometry.py +123 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/molecular_data.py +10 -4
- {moleditpy-2.7.2 → moleditpy-2.8.0}/LICENSE +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/setup.cfg +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/__init__.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/__main__.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/file_icon.ico +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.icns +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.ico +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.png +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/main.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/__init__.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/about_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/align_plane_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/alignment_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/analysis_window.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/atom_item.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/bond_item.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/color_settings_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/constrained_optimization_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/custom_interactor_style.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/custom_qt_interactor.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/dialog3_d_picking_mixin.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_app_state.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_edit_3d.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_export.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_molecular_parsers.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_project_io.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_string_importers.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_ui_manager.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_view_loaders.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/mirror_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/molecule_scene.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/move_group_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/periodic_table_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/planarize_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_interface.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_manager.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_manager_window.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/settings_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/sip_isdeleted_safe.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/template_preview_item.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/template_preview_view.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/translation_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/user_template_dialog.py +0 -0
- {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/zoomable_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
|
|
5
5
|
Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -694,7 +694,8 @@ Requires-Python: <3.15,>=3.9
|
|
|
694
694
|
Description-Content-Type: text/markdown
|
|
695
695
|
License-File: LICENSE
|
|
696
696
|
Requires-Dist: numpy
|
|
697
|
-
Requires-Dist: pyqt6<6.
|
|
697
|
+
Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
|
|
698
|
+
Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
|
|
698
699
|
Requires-Dist: pyvista<0.48
|
|
699
700
|
Requires-Dist: pyvistaqt<0.12
|
|
700
701
|
Requires-Dist: rdkit<2025.10
|
|
@@ -712,7 +713,7 @@ Dynamic: license-file
|
|
|
712
713
|

|
|
713
714
|

|
|
714
715
|

|
|
715
|
-

|
|
716
717
|
[](https://pepy.tech/projects/moleditpy)
|
|
717
718
|
|
|
718
719
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|

|
|
10
10
|

|
|
11
11
|

|
|
12
|
-

|
|
13
13
|
[](https://pepy.tech/projects/moleditpy)
|
|
14
14
|
|
|
15
15
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "MoleditPy"
|
|
7
7
|
|
|
8
|
-
version = "2.
|
|
8
|
+
version = "2.8.0"
|
|
9
9
|
|
|
10
10
|
license = {file = "LICENSE"}
|
|
11
11
|
|
|
@@ -34,7 +34,8 @@ classifiers = [
|
|
|
34
34
|
|
|
35
35
|
dependencies = [
|
|
36
36
|
"numpy",
|
|
37
|
-
"pyqt6 < 6.
|
|
37
|
+
"pyqt6 < 6.10; sys_platform == 'darwin'",
|
|
38
|
+
"pyqt6 < 6.11; sys_platform != 'darwin'",
|
|
38
39
|
"pyvista < 0.48",
|
|
39
40
|
"pyvistaqt < 0.12",
|
|
40
41
|
"rdkit < 2025.10",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MoleditPy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
|
|
5
5
|
Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -694,7 +694,8 @@ Requires-Python: <3.15,>=3.9
|
|
|
694
694
|
Description-Content-Type: text/markdown
|
|
695
695
|
License-File: LICENSE
|
|
696
696
|
Requires-Dist: numpy
|
|
697
|
-
Requires-Dist: pyqt6<6.
|
|
697
|
+
Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
|
|
698
|
+
Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
|
|
698
699
|
Requires-Dist: pyvista<0.48
|
|
699
700
|
Requires-Dist: pyvistaqt<0.12
|
|
700
701
|
Requires-Dist: rdkit<2025.10
|
|
@@ -712,7 +713,7 @@ Dynamic: license-file
|
|
|
712
713
|

|
|
713
714
|

|
|
714
715
|

|
|
715
|
-

|
|
716
717
|
[](https://pepy.tech/projects/moleditpy)
|
|
717
718
|
|
|
718
719
|
[🇯🇵 日本語 (Japanese)](#japanese)
|
|
@@ -17,16 +17,17 @@ from PyQt6.QtWidgets import (
|
|
|
17
17
|
QLineEdit,
|
|
18
18
|
QPushButton,
|
|
19
19
|
QRadioButton,
|
|
20
|
+
QSlider,
|
|
20
21
|
QVBoxLayout,
|
|
21
22
|
QWidget,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
try:
|
|
25
26
|
from .dialog3_d_picking_mixin import Dialog3DPickingMixin
|
|
26
|
-
from .mol_geometry import get_connected_group
|
|
27
|
+
from .mol_geometry import adjust_bond_angle, get_connected_group, rodrigues_rotate
|
|
27
28
|
except Exception:
|
|
28
29
|
from modules.dialog3_d_picking_mixin import Dialog3DPickingMixin
|
|
29
|
-
from modules.mol_geometry import get_connected_group
|
|
30
|
+
from modules.mol_geometry import adjust_bond_angle, get_connected_group, rodrigues_rotate
|
|
30
31
|
|
|
31
32
|
import numpy as np
|
|
32
33
|
from PyQt6.QtCore import Qt
|
|
@@ -76,8 +77,23 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
76
77
|
angle_layout.addWidget(QLabel("New angle (degrees):"))
|
|
77
78
|
self.angle_input = QLineEdit()
|
|
78
79
|
self.angle_input.setPlaceholderText("109.5")
|
|
80
|
+
self.angle_input.textChanged.connect(self.on_angle_input_changed)
|
|
79
81
|
angle_layout.addWidget(self.angle_input)
|
|
82
|
+
|
|
83
|
+
self.angle_slider = QSlider(Qt.Orientation.Horizontal)
|
|
84
|
+
self.angle_slider.setMinimum(-180)
|
|
85
|
+
self.angle_slider.setMaximum(180)
|
|
86
|
+
self.angle_slider.setValue(109)
|
|
87
|
+
self.angle_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
88
|
+
self.angle_slider.setTickInterval(45)
|
|
89
|
+
self.angle_slider.setEnabled(False)
|
|
90
|
+
self.angle_slider.sliderPressed.connect(self.on_slider_pressed)
|
|
91
|
+
self.angle_slider.sliderMoved.connect(self.on_slider_moved)
|
|
92
|
+
self.angle_slider.sliderReleased.connect(self.on_slider_released)
|
|
93
|
+
self.angle_slider.valueChanged.connect(self.on_slider_value_changed)
|
|
94
|
+
self._slider_dragging = False
|
|
80
95
|
layout.addLayout(angle_layout)
|
|
96
|
+
layout.addWidget(self.angle_slider)
|
|
81
97
|
|
|
82
98
|
# Movement options
|
|
83
99
|
group_box = QWidget()
|
|
@@ -201,7 +217,13 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
201
217
|
self.apply_button.setEnabled(False)
|
|
202
218
|
# Clear angle input when no selection
|
|
203
219
|
try:
|
|
220
|
+
self.angle_input.blockSignals(True)
|
|
204
221
|
self.angle_input.clear()
|
|
222
|
+
self.angle_input.blockSignals(False)
|
|
223
|
+
self.angle_slider.blockSignals(True)
|
|
224
|
+
self.angle_slider.setValue(109)
|
|
225
|
+
self.angle_slider.setEnabled(False)
|
|
226
|
+
self.angle_slider.blockSignals(False)
|
|
205
227
|
except Exception: # pragma: no cover
|
|
206
228
|
import traceback
|
|
207
229
|
traceback.print_exc()
|
|
@@ -217,7 +239,13 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
217
239
|
self.add_selection_label(self.atom1_idx, "1")
|
|
218
240
|
# Clear angle input while selection is incomplete
|
|
219
241
|
try:
|
|
242
|
+
self.angle_input.blockSignals(True)
|
|
220
243
|
self.angle_input.clear()
|
|
244
|
+
self.angle_input.blockSignals(False)
|
|
245
|
+
self.angle_slider.blockSignals(True)
|
|
246
|
+
self.angle_slider.setValue(109)
|
|
247
|
+
self.angle_slider.setEnabled(False)
|
|
248
|
+
self.angle_slider.blockSignals(False)
|
|
221
249
|
except Exception: # pragma: no cover
|
|
222
250
|
import traceback
|
|
223
251
|
traceback.print_exc()
|
|
@@ -235,7 +263,13 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
235
263
|
self.add_selection_label(self.atom2_idx, "2(vertex)")
|
|
236
264
|
# Clear angle input while selection is incomplete
|
|
237
265
|
try:
|
|
266
|
+
self.angle_input.blockSignals(True)
|
|
238
267
|
self.angle_input.clear()
|
|
268
|
+
self.angle_input.blockSignals(False)
|
|
269
|
+
self.angle_slider.blockSignals(True)
|
|
270
|
+
self.angle_slider.setValue(109)
|
|
271
|
+
self.angle_slider.setEnabled(False)
|
|
272
|
+
self.angle_slider.blockSignals(False)
|
|
239
273
|
except Exception: # pragma: no cover
|
|
240
274
|
import traceback
|
|
241
275
|
traceback.print_exc()
|
|
@@ -253,7 +287,15 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
253
287
|
self.apply_button.setEnabled(True)
|
|
254
288
|
# Update angle input box with current angle
|
|
255
289
|
try:
|
|
290
|
+
self.angle_input.blockSignals(True)
|
|
256
291
|
self.angle_input.setText(f"{current_angle:.2f}")
|
|
292
|
+
self.angle_input.blockSignals(False)
|
|
293
|
+
self.angle_slider.blockSignals(True)
|
|
294
|
+
slider_val = int(round(current_angle))
|
|
295
|
+
slider_val = max(-180, min(180, slider_val))
|
|
296
|
+
self.angle_slider.setValue(slider_val)
|
|
297
|
+
self.angle_slider.setEnabled(True)
|
|
298
|
+
self.angle_slider.blockSignals(False)
|
|
257
299
|
except Exception: # pragma: no cover
|
|
258
300
|
import traceback
|
|
259
301
|
traceback.print_exc()
|
|
@@ -278,18 +320,73 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
278
320
|
angle_rad = np.arccos(cos_angle)
|
|
279
321
|
return np.degrees(angle_rad)
|
|
280
322
|
|
|
323
|
+
def on_angle_input_changed(self, text):
|
|
324
|
+
"""Line edit text changed, update slider."""
|
|
325
|
+
if not self.angle_input.isEnabled() or not self.apply_button.isEnabled():
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
val = float(text)
|
|
329
|
+
wrapped_val = (val + 180) % 360 - 180
|
|
330
|
+
self.angle_slider.blockSignals(True)
|
|
331
|
+
self.angle_slider.setValue(int(round(wrapped_val)))
|
|
332
|
+
self.angle_slider.blockSignals(False)
|
|
333
|
+
except ValueError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
def on_slider_pressed(self):
|
|
337
|
+
"""Remember the state before slider dragging starts."""
|
|
338
|
+
if self.atom1_idx is None or self.atom2_idx is None or self.atom3_idx is None:
|
|
339
|
+
return
|
|
340
|
+
self._slider_dragging = True
|
|
341
|
+
self.main_window.push_undo_state()
|
|
342
|
+
# Snapshot positions so the rotation axis stays stable during drag
|
|
343
|
+
self._snapshot_positions = self.mol.GetConformer().GetPositions().copy()
|
|
344
|
+
|
|
345
|
+
def on_slider_moved(self, value):
|
|
346
|
+
"""Update geometry in real-time while dragging."""
|
|
347
|
+
if self.atom1_idx is None or self.atom2_idx is None or self.atom3_idx is None:
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
self.angle_input.blockSignals(True)
|
|
351
|
+
self.angle_input.setText(f"{value}")
|
|
352
|
+
self.angle_input.blockSignals(False)
|
|
353
|
+
|
|
354
|
+
self.adjust_angle(float(value))
|
|
355
|
+
|
|
356
|
+
def on_slider_released(self):
|
|
357
|
+
"""Finalize slider dragging."""
|
|
358
|
+
self._slider_dragging = False
|
|
359
|
+
self._snapshot_positions = None
|
|
360
|
+
self.main_window.draw_molecule_3d(self.mol)
|
|
361
|
+
self.main_window.update_chiral_labels()
|
|
362
|
+
|
|
363
|
+
def on_slider_value_changed(self, value):
|
|
364
|
+
"""Handle click-to-position on the slider track."""
|
|
365
|
+
if self._slider_dragging:
|
|
366
|
+
return
|
|
367
|
+
if self.atom1_idx is None or self.atom2_idx is None or self.atom3_idx is None:
|
|
368
|
+
return
|
|
369
|
+
self.main_window.push_undo_state()
|
|
370
|
+
self.angle_input.blockSignals(True)
|
|
371
|
+
self.angle_input.setText(f"{value}")
|
|
372
|
+
self.angle_input.blockSignals(False)
|
|
373
|
+
self.adjust_angle(float(value))
|
|
374
|
+
self.main_window.update_chiral_labels()
|
|
375
|
+
|
|
281
376
|
def apply_changes(self):
|
|
282
377
|
"""変更を適用"""
|
|
283
378
|
if self.atom1_idx is None or self.atom2_idx is None or self.atom3_idx is None:
|
|
284
379
|
return
|
|
285
380
|
|
|
286
381
|
try:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
382
|
+
raw_angle = float(self.angle_input.text())
|
|
383
|
+
# Automatic Range Wrapping
|
|
384
|
+
new_angle = (raw_angle + 180) % 360 - 180
|
|
385
|
+
|
|
386
|
+
# Formally update the input to reflect wrapping
|
|
387
|
+
self.angle_input.blockSignals(True)
|
|
388
|
+
self.angle_input.setText(f"{new_angle:.2f}")
|
|
389
|
+
self.angle_input.blockSignals(False)
|
|
293
390
|
except ValueError:
|
|
294
391
|
QMessageBox.warning(self, "Invalid Input", "Please enter a valid number.")
|
|
295
392
|
return
|
|
@@ -304,104 +401,76 @@ class AngleDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
304
401
|
self.main_window.update_chiral_labels()
|
|
305
402
|
|
|
306
403
|
def adjust_angle(self, new_angle_deg):
|
|
307
|
-
"""角度を調整(均等回転オプション付き)
|
|
308
|
-
conf = self.mol.GetConformer()
|
|
309
|
-
pos1 = np.array(conf.GetAtomPosition(self.atom1_idx))
|
|
310
|
-
pos2 = np.array(conf.GetAtomPosition(self.atom2_idx)) # vertex
|
|
311
|
-
pos3 = np.array(conf.GetAtomPosition(self.atom3_idx))
|
|
312
|
-
|
|
313
|
-
vec1 = pos1 - pos2
|
|
314
|
-
vec2 = pos3 - pos2
|
|
315
|
-
|
|
316
|
-
# Current angle
|
|
317
|
-
current_angle_rad = np.arccos(
|
|
318
|
-
np.clip(
|
|
319
|
-
np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)),
|
|
320
|
-
-1.0,
|
|
321
|
-
1.0,
|
|
322
|
-
)
|
|
323
|
-
)
|
|
324
|
-
|
|
325
|
-
# Target angle
|
|
326
|
-
target_angle_rad = np.radians(new_angle_deg)
|
|
404
|
+
"""角度を調整(均等回転オプション付き)
|
|
327
405
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
406
|
+
Uses the difference-based rotation approach via
|
|
407
|
+
:func:`~mol_geometry.adjust_bond_angle` to avoid 3D
|
|
408
|
+
rotational ambiguity.
|
|
331
409
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
410
|
+
During slider dragging, positions are restored from a snapshot
|
|
411
|
+
taken at press-time so that the rotation axis (cross product)
|
|
412
|
+
never flips direction.
|
|
413
|
+
"""
|
|
414
|
+
conf = self.mol.GetConformer()
|
|
335
415
|
|
|
336
|
-
|
|
416
|
+
# Use snapshot if available (slider dragging) to keep the
|
|
417
|
+
# rotation axis stable; otherwise use current positions.
|
|
418
|
+
snapshot = getattr(self, '_snapshot_positions', None)
|
|
419
|
+
if snapshot is not None:
|
|
420
|
+
positions = snapshot.copy()
|
|
421
|
+
else:
|
|
422
|
+
positions = conf.GetPositions() # N×3 ndarray (copy)
|
|
337
423
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
# Rodrigues' rotation formula
|
|
342
|
-
def rotate_vector(v, axis, angle):
|
|
343
|
-
cos_a = np.cos(angle)
|
|
344
|
-
sin_a = np.sin(angle)
|
|
345
|
-
return (
|
|
346
|
-
v * cos_a
|
|
347
|
-
+ np.cross(axis, v) * sin_a
|
|
348
|
-
+ axis * np.dot(axis, v) * (1 - cos_a)
|
|
349
|
-
)
|
|
424
|
+
idx_a = self.atom1_idx
|
|
425
|
+
idx_b = self.atom2_idx # vertex
|
|
426
|
+
idx_c = self.atom3_idx
|
|
350
427
|
|
|
351
428
|
if self.both_groups_radio.isChecked():
|
|
352
|
-
# Both arms rotate equally (half angle each
|
|
353
|
-
|
|
429
|
+
# Both arms rotate equally (half angle each)
|
|
430
|
+
current_angle = self.calculate_angle()
|
|
431
|
+
half_delta_deg = (new_angle_deg - current_angle) / 2.0
|
|
432
|
+
|
|
433
|
+
group1 = get_connected_group(self.mol, idx_a, exclude=idx_b)
|
|
434
|
+
group3 = get_connected_group(self.mol, idx_c, exclude=idx_b)
|
|
354
435
|
|
|
355
|
-
#
|
|
356
|
-
|
|
357
|
-
|
|
436
|
+
# Arm 1 rotates by −half (note: reversed A/C roles)
|
|
437
|
+
adjust_bond_angle(
|
|
438
|
+
positions, idx_c, idx_b, idx_a,
|
|
439
|
+
current_angle + half_delta_deg, group1,
|
|
358
440
|
)
|
|
359
|
-
|
|
360
|
-
|
|
441
|
+
# Arm 3 rotates by +half
|
|
442
|
+
adjust_bond_angle(
|
|
443
|
+
positions, idx_a, idx_b, idx_c,
|
|
444
|
+
current_angle + half_delta_deg, group3,
|
|
361
445
|
)
|
|
362
|
-
|
|
363
|
-
# Rotate group 1 by -half_rotation
|
|
364
|
-
for atom_idx in group1_atoms:
|
|
365
|
-
current_pos = np.array(conf.GetAtomPosition(atom_idx))
|
|
366
|
-
relative_pos = current_pos - pos2
|
|
367
|
-
rotated_pos = rotate_vector(relative_pos, rotation_axis, -half_rotation)
|
|
368
|
-
new_pos = pos2 + rotated_pos
|
|
369
|
-
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
370
|
-
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
371
|
-
|
|
372
|
-
# Rotate group 3 by +half_rotation
|
|
373
|
-
for atom_idx in group3_atoms:
|
|
374
|
-
current_pos = np.array(conf.GetAtomPosition(atom_idx))
|
|
375
|
-
relative_pos = current_pos - pos2
|
|
376
|
-
rotated_pos = rotate_vector(relative_pos, rotation_axis, half_rotation)
|
|
377
|
-
new_pos = pos2 + rotated_pos
|
|
378
|
-
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
379
|
-
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
380
|
-
|
|
381
446
|
elif self.rotate_atom_radio.isChecked():
|
|
382
|
-
# Move only
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
447
|
+
# Move only atom C
|
|
448
|
+
adjust_bond_angle(
|
|
449
|
+
positions, idx_a, idx_b, idx_c,
|
|
450
|
+
new_angle_deg, {idx_c},
|
|
451
|
+
)
|
|
387
452
|
else:
|
|
388
|
-
#
|
|
453
|
+
# Default: rotate atom C and its connected sub-structure
|
|
389
454
|
atoms_to_move = get_connected_group(
|
|
390
|
-
self.mol,
|
|
455
|
+
self.mol, idx_c, exclude=idx_b,
|
|
456
|
+
)
|
|
457
|
+
adjust_bond_angle(
|
|
458
|
+
positions, idx_a, idx_b, idx_c,
|
|
459
|
+
new_angle_deg, atoms_to_move,
|
|
391
460
|
)
|
|
392
461
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
# Rotate around the rotation axis
|
|
398
|
-
rotated_pos = rotate_vector(
|
|
399
|
-
relative_pos, rotation_axis, total_rotation_angle
|
|
400
|
-
)
|
|
401
|
-
# Transform back to world coordinates
|
|
402
|
-
new_pos = pos2 + rotated_pos
|
|
403
|
-
conf.SetAtomPosition(atom_idx, new_pos.tolist())
|
|
404
|
-
self.main_window.atom_positions_3d[atom_idx] = new_pos
|
|
462
|
+
# Write updated positions back to the conformer and 3D cache
|
|
463
|
+
for i in range(conf.GetNumAtoms()):
|
|
464
|
+
conf.SetAtomPosition(i, positions[i].tolist())
|
|
465
|
+
self.main_window.atom_positions_3d[i] = positions[i]
|
|
405
466
|
|
|
406
467
|
# Update the 3D view
|
|
407
468
|
self.main_window.draw_molecule_3d(self.mol)
|
|
469
|
+
|
|
470
|
+
def reject(self):
|
|
471
|
+
super().reject()
|
|
472
|
+
try:
|
|
473
|
+
if self.main_window.current_mol:
|
|
474
|
+
self.main_window.draw_molecule_3d(self.main_window.current_mol)
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
@@ -20,6 +20,7 @@ from PyQt6.QtWidgets import (
|
|
|
20
20
|
QMessageBox,
|
|
21
21
|
QPushButton,
|
|
22
22
|
QRadioButton,
|
|
23
|
+
QSlider,
|
|
23
24
|
QVBoxLayout,
|
|
24
25
|
QWidget,
|
|
25
26
|
)
|
|
@@ -70,8 +71,23 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
70
71
|
distance_layout.addWidget(QLabel("New distance (Å):"))
|
|
71
72
|
self.distance_input = QLineEdit()
|
|
72
73
|
self.distance_input.setPlaceholderText("1.54")
|
|
74
|
+
self.distance_input.textChanged.connect(self.on_distance_input_changed)
|
|
73
75
|
distance_layout.addWidget(self.distance_input)
|
|
76
|
+
|
|
77
|
+
self.distance_slider = QSlider(Qt.Orientation.Horizontal)
|
|
78
|
+
self.distance_slider.setMinimum(10) # 0.1 A
|
|
79
|
+
self.distance_slider.setMaximum(1000) # 10.0 A
|
|
80
|
+
self.distance_slider.setValue(154) # 1.54 A
|
|
81
|
+
self.distance_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
82
|
+
self.distance_slider.setTickInterval(100)
|
|
83
|
+
self.distance_slider.setEnabled(False)
|
|
84
|
+
self.distance_slider.sliderPressed.connect(self.on_slider_pressed)
|
|
85
|
+
self.distance_slider.sliderMoved.connect(self.on_slider_moved)
|
|
86
|
+
self.distance_slider.sliderReleased.connect(self.on_slider_released)
|
|
87
|
+
self.distance_slider.valueChanged.connect(self.on_slider_value_changed)
|
|
88
|
+
self._slider_dragging = False
|
|
74
89
|
layout.addLayout(distance_layout)
|
|
90
|
+
layout.addWidget(self.distance_slider)
|
|
75
91
|
|
|
76
92
|
# Movement options
|
|
77
93
|
group_box = QWidget()
|
|
@@ -191,7 +207,13 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
191
207
|
self.apply_button.setEnabled(False)
|
|
192
208
|
# Clear distance input when no selection
|
|
193
209
|
try:
|
|
210
|
+
self.distance_input.blockSignals(True)
|
|
194
211
|
self.distance_input.clear()
|
|
212
|
+
self.distance_input.blockSignals(False)
|
|
213
|
+
self.distance_slider.blockSignals(True)
|
|
214
|
+
self.distance_slider.setValue(154)
|
|
215
|
+
self.distance_slider.setEnabled(False)
|
|
216
|
+
self.distance_slider.blockSignals(False)
|
|
195
217
|
except Exception: # pragma: no cover
|
|
196
218
|
import traceback
|
|
197
219
|
traceback.print_exc()
|
|
@@ -207,7 +229,13 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
207
229
|
self.add_selection_label(self.atom1_idx, "1")
|
|
208
230
|
# Clear distance input while selection is incomplete
|
|
209
231
|
try:
|
|
232
|
+
self.distance_input.blockSignals(True)
|
|
210
233
|
self.distance_input.clear()
|
|
234
|
+
self.distance_input.blockSignals(False)
|
|
235
|
+
self.distance_slider.blockSignals(True)
|
|
236
|
+
self.distance_slider.setValue(154)
|
|
237
|
+
self.distance_slider.setEnabled(False)
|
|
238
|
+
self.distance_slider.blockSignals(False)
|
|
211
239
|
except Exception: # pragma: no cover
|
|
212
240
|
import traceback
|
|
213
241
|
traceback.print_exc()
|
|
@@ -227,7 +255,15 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
227
255
|
self.apply_button.setEnabled(True)
|
|
228
256
|
# Update the distance input box to show current distance
|
|
229
257
|
try:
|
|
258
|
+
self.distance_input.blockSignals(True)
|
|
230
259
|
self.distance_input.setText(f"{current_distance:.3f}")
|
|
260
|
+
self.distance_input.blockSignals(False)
|
|
261
|
+
self.distance_slider.blockSignals(True)
|
|
262
|
+
slider_val = int(current_distance * 100)
|
|
263
|
+
slider_val = max(10, min(1000, slider_val))
|
|
264
|
+
self.distance_slider.setValue(slider_val)
|
|
265
|
+
self.distance_slider.setEnabled(True)
|
|
266
|
+
self.distance_slider.blockSignals(False)
|
|
231
267
|
except Exception: # pragma: no cover
|
|
232
268
|
import traceback
|
|
233
269
|
traceback.print_exc()
|
|
@@ -236,6 +272,58 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
236
272
|
self.add_selection_label(self.atom1_idx, "1")
|
|
237
273
|
self.add_selection_label(self.atom2_idx, "2")
|
|
238
274
|
|
|
275
|
+
def on_distance_input_changed(self, text):
|
|
276
|
+
"""Line edit text changed, update slider."""
|
|
277
|
+
if not self.distance_input.isEnabled() or not self.apply_button.isEnabled():
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
val = float(text)
|
|
281
|
+
if 0.1 <= val <= 10.0:
|
|
282
|
+
self.distance_slider.blockSignals(True)
|
|
283
|
+
self.distance_slider.setValue(int(val * 100))
|
|
284
|
+
self.distance_slider.blockSignals(False)
|
|
285
|
+
except ValueError:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def on_slider_pressed(self):
|
|
289
|
+
"""Remember the state before slider dragging starts."""
|
|
290
|
+
if self.atom1_idx is None or self.atom2_idx is None:
|
|
291
|
+
return
|
|
292
|
+
self._slider_dragging = True
|
|
293
|
+
self.main_window.push_undo_state()
|
|
294
|
+
|
|
295
|
+
def on_slider_moved(self, value):
|
|
296
|
+
"""Update geometry in real-time while dragging."""
|
|
297
|
+
if self.atom1_idx is None or self.atom2_idx is None:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
new_distance = value / 100.0
|
|
301
|
+
self.distance_input.blockSignals(True)
|
|
302
|
+
self.distance_input.setText(f"{new_distance:.3f}")
|
|
303
|
+
self.distance_input.blockSignals(False)
|
|
304
|
+
|
|
305
|
+
self.adjust_bond_length(new_distance)
|
|
306
|
+
|
|
307
|
+
def on_slider_released(self):
|
|
308
|
+
"""Finalize slider dragging."""
|
|
309
|
+
self._slider_dragging = False
|
|
310
|
+
self.main_window.draw_molecule_3d(self.mol)
|
|
311
|
+
self.main_window.update_chiral_labels()
|
|
312
|
+
|
|
313
|
+
def on_slider_value_changed(self, value):
|
|
314
|
+
"""Handle click-to-position on the slider track."""
|
|
315
|
+
if self._slider_dragging:
|
|
316
|
+
return # Already handled by on_slider_moved
|
|
317
|
+
if self.atom1_idx is None or self.atom2_idx is None:
|
|
318
|
+
return
|
|
319
|
+
self.main_window.push_undo_state()
|
|
320
|
+
new_distance = value / 100.0
|
|
321
|
+
self.distance_input.blockSignals(True)
|
|
322
|
+
self.distance_input.setText(f"{new_distance:.3f}")
|
|
323
|
+
self.distance_input.blockSignals(False)
|
|
324
|
+
self.adjust_bond_length(new_distance)
|
|
325
|
+
self.main_window.update_chiral_labels()
|
|
326
|
+
|
|
239
327
|
def apply_changes(self):
|
|
240
328
|
"""変更を適用"""
|
|
241
329
|
if self.atom1_idx is None or self.atom2_idx is None:
|
|
@@ -330,3 +418,11 @@ class BondLengthDialog(Dialog3DPickingMixin, QDialog): # pragma: no cover
|
|
|
330
418
|
|
|
331
419
|
# Update the 3D view
|
|
332
420
|
self.main_window.draw_molecule_3d(self.mol)
|
|
421
|
+
|
|
422
|
+
def reject(self):
|
|
423
|
+
super().reject()
|
|
424
|
+
try:
|
|
425
|
+
if self.main_window.current_mol:
|
|
426
|
+
self.main_window.draw_molecule_3d(self.main_window.current_mol)
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|