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.
Files changed (66) hide show
  1. {moleditpy-2.7.2 → moleditpy-2.8.0}/PKG-INFO +4 -3
  2. {moleditpy-2.7.2 → moleditpy-2.8.0}/README.md +1 -1
  3. {moleditpy-2.7.2 → moleditpy-2.8.0}/pyproject.toml +3 -2
  4. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/PKG-INFO +4 -3
  5. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/requires.txt +6 -1
  6. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/angle_dialog.py +161 -92
  7. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/bond_length_dialog.py +96 -0
  8. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/calculation_worker.py +235 -63
  9. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/constants.py +1 -1
  10. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/dihedral_dialog.py +102 -8
  11. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_compute.py +110 -58
  12. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_dialog_manager.py +2 -0
  13. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_edit_actions.py +3 -0
  14. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_main_init.py +11 -4
  15. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_view_3d.py +13 -1
  16. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/mol_geometry.py +123 -0
  17. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/molecular_data.py +10 -4
  18. {moleditpy-2.7.2 → moleditpy-2.8.0}/LICENSE +0 -0
  19. {moleditpy-2.7.2 → moleditpy-2.8.0}/setup.cfg +0 -0
  20. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/SOURCES.txt +0 -0
  21. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  22. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  23. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
  24. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/__init__.py +0 -0
  25. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/__main__.py +0 -0
  26. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/file_icon.ico +0 -0
  27. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.icns +0 -0
  28. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.ico +0 -0
  29. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/assets/icon.png +0 -0
  30. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/main.py +0 -0
  31. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/__init__.py +0 -0
  32. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/about_dialog.py +0 -0
  33. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/align_plane_dialog.py +0 -0
  34. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/alignment_dialog.py +0 -0
  35. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/analysis_window.py +0 -0
  36. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/atom_item.py +0 -0
  37. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/bond_item.py +0 -0
  38. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/color_settings_dialog.py +0 -0
  39. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/constrained_optimization_dialog.py +0 -0
  40. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/custom_interactor_style.py +0 -0
  41. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/custom_qt_interactor.py +0 -0
  42. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/dialog3_d_picking_mixin.py +0 -0
  43. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window.py +0 -0
  44. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_app_state.py +0 -0
  45. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_edit_3d.py +0 -0
  46. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_export.py +0 -0
  47. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_molecular_parsers.py +0 -0
  48. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_project_io.py +0 -0
  49. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_string_importers.py +0 -0
  50. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_ui_manager.py +0 -0
  51. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/main_window_view_loaders.py +0 -0
  52. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/mirror_dialog.py +0 -0
  53. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/molecule_scene.py +0 -0
  54. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/move_group_dialog.py +0 -0
  55. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/periodic_table_dialog.py +0 -0
  56. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/planarize_dialog.py +0 -0
  57. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_interface.py +0 -0
  58. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_manager.py +0 -0
  59. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/plugin_manager_window.py +0 -0
  60. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/settings_dialog.py +0 -0
  61. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/sip_isdeleted_safe.py +0 -0
  62. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/template_preview_item.py +0 -0
  63. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/template_preview_view.py +0 -0
  64. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/translation_dialog.py +0 -0
  65. {moleditpy-2.7.2 → moleditpy-2.8.0}/src/moleditpy/modules/user_template_dialog.py +0 -0
  66. {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.7.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.11
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
  ![Core Logic Coverage](https://img.shields.io/badge/core_logic_coverage-73%25-green)
713
714
  ![Overall Coverage](https://img.shields.io/badge/coverage-50%25-orange)
714
715
  ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
715
- ![Pylint Score](https://img.shields.io/badge/pylint-8.67%2F10-brightgreen)
716
+ ![Pylint Score](https://img.shields.io/badge/pylint-8.63%2F10-brightgreen)
716
717
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/moleditpy)
717
718
 
718
719
  [🇯🇵 日本語 (Japanese)](#japanese)
@@ -9,7 +9,7 @@
9
9
  ![Core Logic Coverage](https://img.shields.io/badge/core_logic_coverage-73%25-green)
10
10
  ![Overall Coverage](https://img.shields.io/badge/coverage-50%25-orange)
11
11
  ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
12
- ![Pylint Score](https://img.shields.io/badge/pylint-8.67%2F10-brightgreen)
12
+ ![Pylint Score](https://img.shields.io/badge/pylint-8.63%2F10-brightgreen)
13
13
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](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.7.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.11",
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.7.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.11
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
  ![Core Logic Coverage](https://img.shields.io/badge/core_logic_coverage-73%25-green)
713
714
  ![Overall Coverage](https://img.shields.io/badge/coverage-50%25-orange)
714
715
  ![GUI Status](https://img.shields.io/badge/GUI-Manually_Verified-blue)
715
- ![Pylint Score](https://img.shields.io/badge/pylint-8.67%2F10-brightgreen)
716
+ ![Pylint Score](https://img.shields.io/badge/pylint-8.63%2F10-brightgreen)
716
717
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/moleditpy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/moleditpy)
717
718
 
718
719
  [🇯🇵 日本語 (Japanese)](#japanese)
@@ -1,6 +1,11 @@
1
1
  numpy
2
- pyqt6<6.11
3
2
  pyvista<0.48
4
3
  pyvistaqt<0.12
5
4
  rdkit<2025.10
6
5
  openbabel-wheel<3.2
6
+
7
+ [:sys_platform != "darwin"]
8
+ pyqt6<6.11
9
+
10
+ [:sys_platform == "darwin"]
11
+ pyqt6<6.10
@@ -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
- new_angle = float(self.angle_input.text())
288
- if new_angle < 0 or new_angle >= 360:
289
- QMessageBox.warning(
290
- self, "Invalid Input", "Angle must be between 0 and 360 degrees."
291
- )
292
- return
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
- # Rotation axis (perpendicular to the plane containing vec1 and vec2)
329
- rotation_axis = np.cross(vec1, vec2)
330
- rotation_axis_norm = np.linalg.norm(rotation_axis)
406
+ Uses the difference-based rotation approach via
407
+ :func:`~mol_geometry.adjust_bond_angle` to avoid 3D
408
+ rotational ambiguity.
331
409
 
332
- if rotation_axis_norm == 0:
333
- # Vectors are parallel, cannot rotate
334
- return
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
- rotation_axis = rotation_axis / rotation_axis_norm
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
- # Total rotation angle needed
339
- total_rotation_angle = target_angle_rad - current_angle_rad
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 in opposite directions)
353
- half_rotation = total_rotation_angle / 2
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
- # Get both connected groups
356
- group1_atoms = get_connected_group(
357
- self.mol, self.atom1_idx, exclude=self.atom2_idx
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
- group3_atoms = get_connected_group(
360
- self.mol, self.atom3_idx, exclude=self.atom2_idx
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 the third atom
383
- new_vec2 = rotate_vector(vec2, rotation_axis, total_rotation_angle)
384
- new_pos3 = pos2 + new_vec2
385
- conf.SetAtomPosition(self.atom3_idx, new_pos3.tolist())
386
- self.main_window.atom_positions_3d[self.atom3_idx] = new_pos3
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
- # Rotate the connected group around atom2 (vertex) - default behavior
453
+ # Default: rotate atom C and its connected sub-structure
389
454
  atoms_to_move = get_connected_group(
390
- self.mol, self.atom3_idx, exclude=self.atom2_idx
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
- for atom_idx in atoms_to_move:
394
- current_pos = np.array(conf.GetAtomPosition(atom_idx))
395
- # Transform to coordinate system centered at atom2
396
- relative_pos = current_pos - pos2
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