MoleditPy-linux 4.3.1__tar.gz → 4.4.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 (82) hide show
  1. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/app_state.py +33 -14
  5. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/custom_interactor_style.py +109 -8
  6. moleditpy_linux-4.4.0/src/moleditpy_linux/ui/custom_qt_interactor.py +75 -0
  7. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +2 -3
  8. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/io_logic.py +89 -15
  9. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +6 -2
  10. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +14 -0
  11. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/ui_manager.py +43 -1
  12. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/constants.py +9 -1
  13. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/default_settings.py +2 -0
  14. moleditpy_linux-4.3.1/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -105
  15. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/LICENSE +0 -0
  16. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/README.md +0 -0
  17. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/setup.cfg +0 -0
  18. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  19. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  20. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  21. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  22. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  23. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/__init__.py +0 -0
  24. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/__main__.py +0 -0
  25. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  26. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  27. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  28. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/assets/icon.png +0 -0
  29. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/core/__init__.py +0 -0
  30. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  31. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  32. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/main.py +0 -0
  33. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  34. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  35. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  36. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  37. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  38. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  39. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  40. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  41. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  42. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  43. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/atom_item.py +0 -0
  44. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  45. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  46. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/bond_item.py +0 -0
  47. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  48. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  49. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  50. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  51. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  52. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  53. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  54. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  55. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  56. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  57. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  58. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/main_window.py +0 -0
  59. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  60. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  61. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  62. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  63. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  64. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  68. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  69. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  70. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  71. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  72. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  73. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  74. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  75. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  76. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  77. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  78. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  79. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  80. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  81. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  82. {moleditpy_linux-4.3.1 → moleditpy_linux-4.4.0}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.3.1
3
+ Version: 4.4.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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.3.1"
8
+ version = "4.4.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.3.1
3
+ Version: 4.4.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
@@ -420,6 +420,19 @@ class StateManager:
420
420
  # Skip property access if atom data is inconsistent
421
421
  original_id = None
422
422
 
423
+ # Implicit/explicit H counts raise on an unsanitized mol
424
+ # (e.g. an XYZ import whose valences never passed
425
+ # sanitization). Default to 0 so the structure still saves
426
+ # and round-trips via the binary rather than being dropped.
427
+ try:
428
+ num_explicit_hs = atom.GetNumExplicitHs()
429
+ except (RuntimeError, ValueError):
430
+ num_explicit_hs = 0
431
+ try:
432
+ num_implicit_hs = atom.GetNumImplicitHs()
433
+ except (RuntimeError, ValueError):
434
+ num_implicit_hs = 0
435
+
423
436
  atom_3d = {
424
437
  "index": i,
425
438
  "symbol": atom.GetSymbol(),
@@ -428,8 +441,8 @@ class StateManager:
428
441
  "y": pos.y,
429
442
  "z": pos.z,
430
443
  "formal_charge": atom.GetFormalCharge(),
431
- "num_explicit_hs": atom.GetNumExplicitHs(),
432
- "num_implicit_hs": atom.GetNumImplicitHs(),
444
+ "num_explicit_hs": num_explicit_hs,
445
+ "num_implicit_hs": num_implicit_hs,
433
446
  # include original editor atom id when available for round-trip
434
447
  "original_id": original_id,
435
448
  }
@@ -462,17 +475,21 @@ class StateManager:
462
475
  "constraints_3d": json_safe_constraints,
463
476
  }
464
477
 
465
- # Molecular info
466
- json_data["molecular_info"] = {
467
- "num_atoms": self.host.view_3d_manager.current_mol.GetNumAtoms(),
468
- "num_bonds": self.host.view_3d_manager.current_mol.GetNumBonds(),
469
- "molecular_weight": Descriptors.MolWt(
470
- self.host.view_3d_manager.current_mol
471
- ),
472
- "formula": rdMolDescriptors.CalcMolFormula(
473
- self.host.view_3d_manager.current_mol
474
- ),
475
- }
478
+ # Molecular info — MolWt/CalcMolFormula can raise on an
479
+ # unsanitized mol; keep it optional so it never blocks the save.
480
+ try:
481
+ json_data["molecular_info"] = {
482
+ "num_atoms": self.host.view_3d_manager.current_mol.GetNumAtoms(),
483
+ "num_bonds": self.host.view_3d_manager.current_mol.GetNumBonds(),
484
+ "molecular_weight": Descriptors.MolWt(
485
+ self.host.view_3d_manager.current_mol
486
+ ),
487
+ "formula": rdMolDescriptors.CalcMolFormula(
488
+ self.host.view_3d_manager.current_mol
489
+ ),
490
+ }
491
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
492
+ logging.warning("Could not compute molecular info: %s", e)
476
493
 
477
494
  # Identifiers (SMILES/InChI)
478
495
  try:
@@ -637,7 +654,9 @@ class StateManager:
637
654
  ValueError,
638
655
  TypeError,
639
656
  IndexError,
640
- ): # [RDKIT GUARD] RDKit atom property assignment may fail on invalid ids; skip silently.
657
+ ):
658
+ # [RDKIT GUARD] property set may
659
+ # fail on invalid ids; skip.
641
660
  logging.debug(
642
661
  "Suppressed non-critical error",
643
662
  exc_info=True,
@@ -25,6 +25,16 @@ from .atom_picking import pick_atom_index_from_screen
25
25
 
26
26
  from rdkit import Geometry
27
27
 
28
+ # VTK's trackball state id for camera rotation (VTKIS_ROTATE); the enum is not
29
+ # exposed to Python, so the literal is used with this name for clarity.
30
+ _VTKIS_ROTATE = 1
31
+
32
+ # Reference 3D-canvas size (px) that camera rotation speed is normalized to.
33
+ # VTK's stock Rotate divides motion by the live render size, so rotation slows
34
+ # as the canvas grows; normalizing to a fixed reference makes the speed
35
+ # window-size independent and matches the feel at the default window size.
36
+ _ROTATION_REFERENCE_SIZE = 640.0
37
+
28
38
 
29
39
  class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
30
40
  """VTK interactor style extending trackball-camera with 3D atom drag and measurement."""
@@ -81,6 +91,43 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
81
91
  # Safe defensive fallback catching AttributeError, RuntimeError
82
92
  logging.debug("Suppressed non-critical error", exc_info=True)
83
93
 
94
+ def _rotate_size_independent(self) -> None:
95
+ """Rotate the camera at a speed that does not depend on canvas size.
96
+
97
+ Mirrors vtkInteractorStyleTrackballCamera::Rotate but normalizes the
98
+ per-pixel azimuth/elevation to a fixed reference size instead of the
99
+ live render size, and scales by the user's rotation-sensitivity setting.
100
+ """
101
+ renderer = self.GetCurrentRenderer()
102
+ interactor = self.GetInteractor()
103
+ if renderer is None or interactor is None:
104
+ return
105
+
106
+ dx = interactor.GetEventPosition()[0] - interactor.GetLastEventPosition()[0]
107
+ dy = interactor.GetEventPosition()[1] - interactor.GetLastEventPosition()[1]
108
+
109
+ sensitivity = 1.0
110
+ mw = self.main_window
111
+ if mw is not None:
112
+ try:
113
+ sensitivity = float(
114
+ mw.get_settings().get("mouse_rotation_sensitivity", 1.0)
115
+ )
116
+ except (AttributeError, TypeError, ValueError):
117
+ sensitivity = 1.0
118
+
119
+ delta = -20.0 / _ROTATION_REFERENCE_SIZE * self.GetMotionFactor() * sensitivity
120
+ camera = renderer.GetActiveCamera()
121
+ camera.Azimuth(dx * delta)
122
+ camera.Elevation(dy * delta)
123
+ camera.OrthogonalizeViewUp()
124
+
125
+ if self.GetAutoAdjustCameraClippingRange():
126
+ renderer.ResetCameraClippingRange()
127
+ if interactor.GetLightFollowCamera():
128
+ renderer.UpdateLightsGeometryToFollowCamera()
129
+ interactor.Render()
130
+
84
131
  def _stop_vtk_left_button_state(self) -> None:
85
132
  """Clear VTK's button/drag state after a custom-handled left click."""
86
133
  try:
@@ -159,9 +206,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
159
206
  try:
160
207
  move_group_dialog.on_atom_picked(clicked_atom_idx)
161
208
  except (AttributeError, RuntimeError):
162
- # Safe defensive fallback catching AttributeError, RuntimeError
163
- logging.debug(
164
- "Suppressed non-critical error", exc_info=True
209
+ logging.warning(
210
+ "Move-dialog atom toggle failed", exc_info=True
165
211
  )
166
212
 
167
213
  QTimer.singleShot(0, _deferred_toggle)
@@ -273,9 +319,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
273
319
  closest_atom_idx
274
320
  )
275
321
  except (AttributeError, RuntimeError):
276
- # Safe defensive fallback catching AttributeError, RuntimeError
277
- logging.debug(
278
- "Suppressed non-critical error", exc_info=True
322
+ logging.warning(
323
+ "Measurement selection failed", exc_info=True
279
324
  )
280
325
 
281
326
  QTimer.singleShot(0, _deferred_measure)
@@ -383,6 +428,54 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
383
428
  # Standard right-click
384
429
  super().OnRightButtonDown()
385
430
 
431
+ def _heal_stuck_pointer_state(self, move_group_dialog: Any) -> None:
432
+ """Self-heal drag/rotate state whose release event was lost.
433
+
434
+ A release can be lost mid-gesture (e.g. pyvista temporarily swapping
435
+ the interactor style, a dialog grabbing events), leaving this style or
436
+ a Move Group dialog in a permanent drag/rotate state that blocks
437
+ interaction. Verify against the real button state on every move and
438
+ reset when the button is not actually held.
439
+ """
440
+ try:
441
+ buttons = QApplication.mouseButtons()
442
+ left_held = bool(buttons & Qt.MouseButton.LeftButton)
443
+ right_held = bool(buttons & Qt.MouseButton.RightButton)
444
+ any_held = buttons != Qt.MouseButton.NoButton
445
+ except (AttributeError, RuntimeError, TypeError):
446
+ return
447
+
448
+ if self._is_dragging_atom and not left_held:
449
+ self.reset_interactor_state()
450
+
451
+ if not any_held:
452
+ try:
453
+ if self.GetState() != 0: # stuck ROTATE/PAN/etc. without a button
454
+ self.reset_interactor_state()
455
+ except (AttributeError, RuntimeError, TypeError):
456
+ # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
457
+ logging.debug("Suppressed non-critical error", exc_info=True)
458
+
459
+ if move_group_dialog is not None:
460
+ try:
461
+ if (
462
+ getattr(move_group_dialog, "is_dragging_group_vtk", False)
463
+ and not left_held
464
+ ):
465
+ move_group_dialog.is_dragging_group_vtk = False
466
+ move_group_dialog.drag_start_pos_vtk = None
467
+ move_group_dialog.mouse_moved_vtk = False
468
+ if (
469
+ getattr(move_group_dialog, "is_rotating_group_vtk", False)
470
+ and not right_held
471
+ ):
472
+ move_group_dialog.is_rotating_group_vtk = False
473
+ move_group_dialog.rotation_start_pos = None
474
+ move_group_dialog.rotation_mouse_moved = False
475
+ except (AttributeError, RuntimeError, TypeError):
476
+ # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
477
+ logging.debug("Suppressed non-critical error", exc_info=True)
478
+
386
479
  def on_mouse_move(self, obj: Any, event: Any) -> None:
387
480
  """
388
481
  Handle mouse move (drag vs camera/hover).
@@ -402,6 +495,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
402
495
  break
403
496
  except (AttributeError, RuntimeError, TypeError):
404
497
  logging.warning("Caught exception in " + __file__, exc_info=True)
498
+
499
+ self._heal_stuck_pointer_state(move_group_dialog)
405
500
  if move_group_dialog and getattr(
406
501
  move_group_dialog, "is_dragging_group_vtk", False
407
502
  ):
@@ -453,8 +548,14 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
453
548
  # Custom atom drag
454
549
  self.is_dragging = True
455
550
  else:
456
- # Delegate camera rotation to parent
457
- super().OnMouseMove()
551
+ # Camera interaction. VTK's built-in Rotate divides mouse motion by
552
+ # the live render size, so rotation slows as the canvas grows. Handle
553
+ # the rotate state ourselves for window-size-independent speed;
554
+ # delegate pan/dolly/spin to the parent unchanged.
555
+ if self.GetState() == _VTKIS_ROTATE:
556
+ self._rotate_size_independent()
557
+ else:
558
+ super().OnMouseMove()
458
559
 
459
560
  # Update cursor display
460
561
  is_edit_active = (
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ import logging
14
+ from typing import Any, Optional
15
+
16
+ from PyQt6.QtCore import QEvent
17
+ from PyQt6.QtGui import QMouseEvent
18
+ from pyvistaqt import QtInteractor
19
+
20
+
21
+ class CustomQtInteractor(QtInteractor):
22
+ """PyVista QtInteractor subclass that exposes the main window for event handling."""
23
+
24
+ def __init__(
25
+ self,
26
+ parent: Optional[Any] = None,
27
+ main_window: Optional[Any] = None,
28
+ **kwargs: Any,
29
+ ) -> None:
30
+ super().__init__(parent, **kwargs)
31
+ self.main_window = main_window
32
+
33
+ def wheelEvent(self, event: Any) -> None:
34
+ """
35
+ Override the mouse wheel event.
36
+ """
37
+ # First call the parent class event to perform normal zoom processing
38
+ super().wheelEvent(event)
39
+
40
+ # Force focus back to the 2D view after zoom processing
41
+ if self.main_window and hasattr(self.main_window.init_manager, "view_2d"):
42
+ self.main_window.init_manager.view_2d.setFocus()
43
+
44
+ def mouseReleaseEvent(self, event: Any) -> None:
45
+ """
46
+ Override the Qt mouse release event to return focus to the 2D view after
47
+ all 3D view operations.
48
+ """
49
+ super().mouseReleaseEvent(event) # Process parent class event first
50
+ if self.main_window and hasattr(self.main_window.init_manager, "view_2d"):
51
+ self.main_window.init_manager.view_2d.setFocus()
52
+
53
+ def mouseDoubleClickEvent(self, event: Any) -> None:
54
+ """Re-dispatch double-clicks as plain presses so fast clicking works.
55
+
56
+ Qt turns every second fast click into a double-click event. Forwarding
57
+ it unchanged would reach VTK with a repeat count and be dropped by the
58
+ interactor style, so the click would be lost (e.g. when rapidly
59
+ selecting atoms in measurement mode). Synthesizing a normal press keeps
60
+ press/release pairing intact and makes each fast click act as a click.
61
+ """
62
+ try:
63
+ synthetic_press = QMouseEvent(
64
+ QEvent.Type.MouseButtonPress,
65
+ event.position(),
66
+ event.globalPosition(),
67
+ event.button(),
68
+ event.buttons(),
69
+ event.modifiers(),
70
+ )
71
+ super().mousePressEvent(synthetic_press)
72
+ event.accept()
73
+ except (AttributeError, RuntimeError, TypeError):
74
+ # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
75
+ logging.debug("Suppressed non-critical error", exc_info=True)
@@ -82,9 +82,8 @@ class Dialog3DPickingMixin:
82
82
  try:
83
83
  self.on_atom_picked(int(closest_atom_idx))
84
84
  except (AttributeError, RuntimeError):
85
- # Safe defensive fallback catching AttributeError, RuntimeError
86
- logging.debug(
87
- "Suppressed non-critical error", exc_info=True
85
+ logging.warning(
86
+ "Dialog atom pick failed", exc_info=True
88
87
  )
89
88
 
90
89
  from PyQt6.QtCore import QTimer
@@ -35,7 +35,12 @@ from PyQt6.QtWidgets import (
35
35
  from rdkit import Chem
36
36
  from rdkit.Chem import AllChem, rdGeometry, rdMolTransforms, Descriptors
37
37
 
38
- from ..utils.constants import COVALENT_RADII, DUMMY_XYZ_SYMBOLS, VERSION
38
+ from ..utils.constants import (
39
+ COVALENT_RADII,
40
+ DUMMY_XYZ_SYMBOLS,
41
+ VALID_ELEMENT_SYMBOLS,
42
+ VERSION,
43
+ )
39
44
 
40
45
 
41
46
  class IOManager:
@@ -97,6 +102,38 @@ class IOManager:
97
102
  lines[3] = self.fix_mol_counts_line(lines[3])
98
103
  return "\n".join(lines)
99
104
 
105
+ @staticmethod
106
+ def _is_numeric_token(token: str) -> bool:
107
+ """True if *token* parses as a float."""
108
+ try:
109
+ float(token)
110
+ return True
111
+ except ValueError:
112
+ return False
113
+
114
+ @staticmethod
115
+ def _extract_xyz_coords(tokens: list[str]) -> Optional[Tuple[float, float, float]]:
116
+ """Return the first three float-parseable tokens as (x, y, z).
117
+
118
+ Skips non-numeric separator/label columns so ghost-atom rows like
119
+ ``XX : x y z`` (a colon in the second column) parse the same as a
120
+ standard ``symbol x y z`` row. Returns None if fewer than three numbers
121
+ are present.
122
+ """
123
+ nums: list[float] = []
124
+ for tok in tokens:
125
+ try:
126
+ nums.append(float(tok))
127
+ except ValueError:
128
+ if nums:
129
+ break # trailing non-numeric column after the coordinates
130
+ continue # leading label/separator column before the coordinates
131
+ if len(nums) == 3:
132
+ break
133
+ if len(nums) == 3:
134
+ return (nums[0], nums[1], nums[2])
135
+ return None
136
+
100
137
  def _normalize_xyz_symbol(self, raw_symbol: str) -> Tuple[str, bool]:
101
138
  """Return the RDKit symbol for an XYZ atom and whether it is a dummy."""
102
139
  stripped = raw_symbol.strip()
@@ -105,11 +142,11 @@ class IOManager:
105
142
  if stripped.upper() in DUMMY_XYZ_SYMBOLS:
106
143
  return "*", True
107
144
  symbol = stripped.capitalize()
108
- try:
109
- atomic_num = Chem.GetPeriodicTable().GetAtomicNumber(symbol)
110
- except (RuntimeError, ValueError, TypeError):
111
- return "*", True
112
- if atomic_num <= 0:
145
+ # Membership test rather than GetAtomicNumber(symbol): the latter makes
146
+ # RDKit's C++ layer print an "Element 'Xx' not found" violation to
147
+ # stderr for any dummy/pseudo label even though we catch the exception,
148
+ # which users read as a failed load.
149
+ if symbol not in VALID_ELEMENT_SYMBOLS:
113
150
  return "*", True
114
151
  return symbol, False
115
152
 
@@ -138,9 +175,19 @@ class IOManager:
138
175
 
139
176
  atoms_data = []
140
177
  has_dummy_atoms = False
141
- atom_lines = lines[atom_start : atom_start + num_atoms]
178
+ nonstandard_rows = 0
179
+ atom_end = atom_start + num_atoms
180
+ atom_lines = lines[atom_start:atom_end]
142
181
  if len(atom_lines) < num_atoms:
143
- raise ValueError("XYZ file format error: fewer atom rows than expected")
182
+ # Header count exceeds the rows present (truncated / miscounted file).
183
+ # Load the atoms that are actually there rather than refusing the
184
+ # whole file, so any parseable XYZ still opens.
185
+ logging.warning(
186
+ "XYZ declares %d atoms but only %d rows present; loading %d.",
187
+ num_atoms,
188
+ len(atom_lines),
189
+ len(atom_lines),
190
+ )
144
191
 
145
192
  for i, line in enumerate(atom_lines):
146
193
  parts = line.split()
@@ -149,9 +196,15 @@ class IOManager:
149
196
  raw_symbol = parts[0]
150
197
  symbol, is_dummy = self._normalize_xyz_symbol(raw_symbol)
151
198
  has_dummy_atoms = has_dummy_atoms or is_dummy
152
- atoms_data.append(
153
- (symbol, float(parts[1]), float(parts[2]), float(parts[3]))
154
- )
199
+ coords = self._extract_xyz_coords(parts[1:])
200
+ if coords is None:
201
+ raise ValueError(f"Invalid atom data at line {atom_start + i + 1}")
202
+ # Standard rows put coordinates in the first three columns after the
203
+ # symbol; a non-numeric first column means an extra separator/label
204
+ # (e.g. the ':' in "XX : x y z") was skipped — flag it for the user.
205
+ if not self._is_numeric_token(parts[1]):
206
+ nonstandard_rows += 1
207
+ atoms_data.append((symbol, *coords))
155
208
 
156
209
  if not atoms_data:
157
210
  raise ValueError("No valid atoms found in XYZ file")
@@ -162,6 +215,8 @@ class IOManager:
162
215
  atom = Chem.Atom(symbol)
163
216
  atom.SetIntProp("xyz_unique_id", i)
164
217
  if atom.GetAtomicNum() == 0:
218
+ # Ghost/dummy rows (e.g. "XX : x y z") load as the wildcard "*";
219
+ # stash the real label token so it is kept for round-tripping.
165
220
  atom.SetProp("xyz_original_symbol", atom_lines[i].split()[0])
166
221
  mol.AddAtom(atom)
167
222
  conf.SetAtomPosition(i, rdGeometry.Point3D(x, y, z))
@@ -190,7 +245,16 @@ class IOManager:
190
245
  _set_prop(candidate, "_xyz_charge", charge_val)
191
246
  return candidate
192
247
  else:
193
- self.estimate_bonds_from_distances(mol)
248
+ # Distance-based bonding is best-effort: a failure here must not
249
+ # lose the molecule. The skip/dummy path only needs atoms and
250
+ # coordinates, so fall back to a bond-free structure so that any
251
+ # parseable XYZ still loads.
252
+ try:
253
+ self.estimate_bonds_from_distances(mol)
254
+ except (RuntimeError, ValueError, TypeError, AttributeError) as e:
255
+ logging.warning(
256
+ "Bond estimation failed; loading atoms without bonds: %s", e
257
+ )
194
258
  candidate = mol.GetMol()
195
259
  _set_prop(candidate, "_xyz_charge", charge_val)
196
260
  return candidate
@@ -240,6 +304,8 @@ class IOManager:
240
304
 
241
305
  if final_mol:
242
306
  final_mol.xyz_atom_data = atoms_data
307
+ if nonstandard_rows:
308
+ _set_prop(final_mol, "_xyz_nonstandard_rows", nonstandard_rows)
243
309
  return final_mol
244
310
 
245
311
  def _report_load_error(self, title: str, message: str) -> None:
@@ -866,9 +932,17 @@ class IOManager:
866
932
  self.host.view_3d_manager.update_atom_id_menu_state()
867
933
 
868
934
  if self.host.statusBar():
869
- self.host.statusBar().showMessage(
870
- f"3D Viewer Mode: Loaded {os.path.basename(file_path)}"
871
- )
935
+ message = f"3D Viewer Mode: Loaded {os.path.basename(file_path)}"
936
+ nonstandard = 0
937
+ with suppress_log(AttributeError, KeyError, RuntimeError, TypeError):
938
+ if mol.HasProp("_xyz_nonstandard_rows"):
939
+ nonstandard = mol.GetIntProp("_xyz_nonstandard_rows")
940
+ if nonstandard:
941
+ message += (
942
+ f" (non-standard columns in {nonstandard} row(s); "
943
+ "coordinates read from the numeric columns)"
944
+ )
945
+ self.host.statusBar().showMessage(message)
872
946
  self.host.set_current_file_path(file_path)
873
947
  self.host.set_has_unsaved_changes(False)
874
948
  self.host.state_manager.update_window_title()
@@ -696,9 +696,13 @@ class MoveSelectedAtomsDialog(BasePickingDialog):
696
696
  else:
697
697
  btn.setText("Box Selection: OFF")
698
698
  plotter.disable_picking()
699
- # Restore original style
699
+ # Restore original style via pyvista's bookkeeping so its
700
+ # update_style() re-asserts ours instead of RubberBandPick
700
701
  if self.original_style is not None:
701
- plotter.interactor.SetInteractorStyle(self.original_style)
702
+ try:
703
+ plotter.iren.style = self.original_style
704
+ except AttributeError:
705
+ plotter.interactor.SetInteractorStyle(self.original_style)
702
706
 
703
707
  def on_rectangle_picked(self, selection: Any) -> None:
704
708
  """Handle PyVista rectangle picking callback."""
@@ -78,6 +78,16 @@ class Settings3DSceneTab(SettingsTabBase):
78
78
  self.projection_combo.addItems(["Perspective", "Orthographic"])
79
79
  form_layout.addRow("Projection Mode:", self.projection_combo)
80
80
 
81
+ # Camera rotation speed (0.10x–3.00x). Speed is normalized to a fixed
82
+ # reference canvas size, so it stays constant regardless of window size.
83
+ self.rotation_sens_slider, self.rotation_sens_label = self._create_slider(
84
+ 10, 300, 100.0
85
+ )
86
+ form_layout.addRow(
87
+ "Mouse Rotation Speed:",
88
+ self._wrap_layout(self.rotation_sens_slider, self.rotation_sens_label),
89
+ )
90
+
81
91
  def _select_color(self) -> None:
82
92
  color = QColorDialog.getColor(QColor(self.current_bg_color), self)
83
93
  if color.isValid():
@@ -111,6 +121,9 @@ class Settings3DSceneTab(SettingsTabBase):
111
121
  if idx >= 0:
112
122
  self.projection_combo.setCurrentIndex(idx)
113
123
 
124
+ sens_val = settings_dict.get("mouse_rotation_sensitivity", 1.0)
125
+ self.rotation_sens_slider.setValue(int(sens_val * 100))
126
+
114
127
  def get_settings(self) -> dict[str, Any]:
115
128
  return {
116
129
  "background_color": self.current_bg_color,
@@ -120,6 +133,7 @@ class Settings3DSceneTab(SettingsTabBase):
120
133
  "specular": self.specular_slider.value() / 100.0,
121
134
  "specular_power": self.spec_power_slider.value(),
122
135
  "projection_mode": self.projection_combo.currentText(),
136
+ "mouse_rotation_sensitivity": self.rotation_sens_slider.value() / 100.0,
123
137
  }
124
138
 
125
139
 
@@ -322,9 +322,51 @@ class UIManager(QObject):
322
322
  style = CustomInteractorStyle(self.host)
323
323
 
324
324
  # Set interactor style
325
- self.host.view_3d_manager.plotter.interactor.SetInteractorStyle(style)
325
+ self._install_interactor_style(style)
326
326
  self.host.view_3d_manager.plotter.interactor.Initialize()
327
327
 
328
+ # Watchdog: if anything silently replaces the custom style (observed as
329
+ # "rotation works but pick/select is dead"), report it and reinstall.
330
+ self._expected_style = style
331
+ self._style_watchdog = QTimer(self)
332
+ self._style_watchdog.setInterval(2000)
333
+ self._style_watchdog.timeout.connect(self._check_interactor_style)
334
+ self._style_watchdog.start()
335
+
336
+ def _install_interactor_style(self, style: Any) -> None:
337
+ """Install *style* through pyvista's bookkeeping, not raw VTK.
338
+
339
+ pyvista's RenderWindowInteractor re-asserts its own ``_style_class``
340
+ via ``update_style()`` whenever its built-in double-click chart
341
+ handler fires (fast clicks!). Installing through the ``iren.style``
342
+ property keeps that bookkeeping pointing at our style so the
343
+ re-assert is a no-op instead of an eviction.
344
+ """
345
+ plotter = self.host.view_3d_manager.plotter
346
+ try:
347
+ plotter.iren.style = style
348
+ except AttributeError:
349
+ plotter.interactor.SetInteractorStyle(style)
350
+
351
+ def _check_interactor_style(self) -> None:
352
+ """Reinstall CustomInteractorStyle if it was silently replaced."""
353
+ try:
354
+ interactor = self.host.view_3d_manager.plotter.interactor
355
+ current = interactor.GetInteractorStyle()
356
+ if current is self._expected_style:
357
+ return
358
+ name = type(current).__name__
359
+ if "RubberBand" in name: # legitimate temporary box-selection style
360
+ return
361
+ logging.debug(
362
+ "3D interactor style was replaced by %s — reinstalling "
363
+ "CustomInteractorStyle",
364
+ name,
365
+ )
366
+ self._install_interactor_style(self._expected_style)
367
+ except (AttributeError, RuntimeError):
368
+ logging.debug("Style watchdog check failed", exc_info=True)
369
+
328
370
  def handle_drag_enter_event(self, event: QDragEnterEvent) -> None:
329
371
  """Internal handler for drag enter event (bypasses PyQt type checks in tests)."""
330
372
  mime_data = event.mimeData()
@@ -24,7 +24,8 @@ def _get_version() -> str:
24
24
 
25
25
  try:
26
26
  return version("MoleditPy-linux")
27
- except PackageNotFoundError: # [OPTIONAL] Package not installed in editable mode; fall through to pyproject.toml.
27
+ except PackageNotFoundError:
28
+ # [OPTIONAL] Not installed in editable mode; fall through to pyproject.toml.
28
29
  logging.debug("Suppressed non-critical error", exc_info=True)
29
30
  except ImportError:
30
31
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -209,6 +210,13 @@ DEFAULT_CPK_COLORS = {
209
210
 
210
211
 
211
212
  pt = Chem.GetPeriodicTable()
213
+ # Real element symbols (H..Og). Membership-test against this instead of calling
214
+ # GetAtomicNumber() on an unknown label: probing the periodic table with a
215
+ # non-element makes RDKit's C++ layer print an alarming "Element 'Xx' not found"
216
+ # violation to stderr even when the Python exception is caught.
217
+ VALID_ELEMENT_SYMBOLS: frozenset[str] = frozenset(
218
+ pt.GetElementSymbol(i) for i in range(1, 119)
219
+ )
212
220
  # 0.3-scaled vdW radii for 3D display only — not physical values
213
221
  VDW_DISPLAY_RADII = {pt.GetElementSymbol(i): pt.GetRvdw(i) * 0.3 for i in range(1, 119)}
214
222
  # Deprecated alias kept for external plugins
@@ -19,6 +19,8 @@ DEFAULT_SETTINGS = {
19
19
  "specular_power": 20,
20
20
  "light_intensity": 1.0,
21
21
  "show_3d_axes": True,
22
+ # Camera rotation speed multiplier for the 3D scene (window-size independent).
23
+ "mouse_rotation_sensitivity": 1.0,
22
24
  # --- 3D Model Parameters (Ball and Stick) ---
23
25
  "ball_stick_atom_scale": 1.0,
24
26
  "ball_stick_bond_radius": 0.1,
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
-
4
- """
5
- MoleditPy — A Python-based molecular editing software
6
-
7
- Author: Hiromichi Yokoyama
8
- License: GPL-3.0 license
9
- Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
- DOI: 10.5281/zenodo.17268532
11
- """
12
-
13
- import time
14
- from typing import Any, Optional
15
-
16
- from pyvistaqt import QtInteractor
17
-
18
-
19
- class CustomQtInteractor(QtInteractor):
20
- """PyVista QtInteractor subclass that exposes the main window for event handling."""
21
-
22
- def __init__(
23
- self,
24
- parent: Optional[Any] = None,
25
- main_window: Optional[Any] = None,
26
- **kwargs: Any,
27
- ) -> None:
28
- super().__init__(parent, **kwargs)
29
- self.main_window = main_window
30
- self._last_click_time = 0.0
31
- self._click_count = 0
32
- self._ignore_next_release = False
33
-
34
- def wheelEvent(self, event: Any) -> None:
35
- """
36
- Override the mouse wheel event.
37
- """
38
- # First call the parent class event to perform normal zoom processing
39
- super().wheelEvent(event)
40
-
41
- # Force focus back to the 2D view after zoom processing
42
- if self.main_window and hasattr(self.main_window.init_manager, "view_2d"):
43
- self.main_window.init_manager.view_2d.setFocus()
44
-
45
- def mouseReleaseEvent(self, event: Any) -> None:
46
- """
47
- Override the Qt mouse release event to return focus to the 2D view after
48
- all 3D view operations. Also filters out "Ghost Release" (release without
49
- a corresponding press).
50
- """
51
- if self._ignore_next_release:
52
- self._ignore_next_release = False
53
- event.accept()
54
- return
55
-
56
- super().mouseReleaseEvent(event) # Process parent class event first
57
- if self.main_window and hasattr(self.main_window.init_manager, "view_2d"):
58
- self.main_window.init_manager.view_2d.setFocus()
59
-
60
- def mousePressEvent(self, event: Any) -> None:
61
- """
62
- Custom mouse press handling to track accumulated clicks and filter out
63
- triple-clicks.
64
- """
65
- current_time = time.time()
66
- # Reset count if too much time has passed (0.5s is standard double-click time)
67
- if current_time - self._last_click_time > 0.5:
68
- self._click_count = 0
69
-
70
- self._click_count += 1
71
- self._last_click_time = current_time
72
-
73
- # If this is the 3rd click (or more), swallow it to prevent
74
- # the internal state desync that happens with rapid clicking sequences.
75
- if self._click_count >= 3:
76
- self._ignore_next_release = True
77
- event.accept()
78
- return
79
-
80
- super().mousePressEvent(event)
81
-
82
- def mouseDoubleClickEvent(self, event: Any) -> None:
83
- """Ignore mouse double-clicks on the 3D widget to avoid accidental actions.
84
-
85
- Swallow the double-click event so it doesn't trigger selection, editing,
86
- or camera jumps. We intentionally do not call the superclass handler.
87
- Crucially, we also flag the NEXT release event to be swallowed, preventing
88
- a "Ghost Release" (Release without Press) from reaching VTK.
89
- """
90
- current_time = time.time()
91
- self._last_click_time = current_time
92
- # Set to 2 to ensure the next click counts as 3rd
93
- if current_time - self._last_click_time < 0.5:
94
- self._click_count = 2
95
- else:
96
- self._click_count = 2 # Force sync
97
-
98
- self._ignore_next_release = True
99
-
100
- try:
101
- # Accept the event to mark it handled and prevent further processing.
102
- event.accept()
103
- except (AttributeError, RuntimeError, TypeError):
104
- # If event doesn't support accept for some reason, just return.
105
- return
File without changes