MoleditPy-linux 4.3.0__py3-none-any.whl → 4.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,8 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
65
65
  self.constraints: list[Any] = [] # (type, atoms_indices, value)
66
66
  self.constraint_labels: list[Any] = [] # 3D label actors
67
67
  self._opt_thread: Any = None
68
+ # Closed dialogs must discard late optimization-thread results
69
+ self._closed = False
68
70
  self.init_ui()
69
71
  self.enable_picking()
70
72
 
@@ -599,6 +601,11 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
599
601
  self.optimize_button.setEnabled(True)
600
602
 
601
603
  def _on_optimization_error(self, err_msg: str) -> None:
604
+ if self._closed:
605
+ logging.warning(
606
+ "Constrained optimization failed after dialog close: %s", err_msg
607
+ )
608
+ return
602
609
  self.optimize_button.setEnabled(True)
603
610
  status_bar = self.main_window.statusBar()
604
611
  if status_bar is not None:
@@ -606,6 +613,9 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
606
613
  QMessageBox.critical(self, "Error", f"Optimization error: {err_msg}")
607
614
 
608
615
  def _on_optimization_finished(self, ff_name: str, conf: Any) -> None:
616
+ if self._closed:
617
+ logging.info("Discarding constrained optimization result after close.")
618
+ return
609
619
  self.optimize_button.setEnabled(True)
610
620
  try:
611
621
  # Apply optimized coordinates to the main window's numpy array
@@ -619,25 +629,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
619
629
  pos.z,
620
630
  ]
621
631
 
622
- # Update 3D view
623
- self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
624
- self.main_window.view_3d_manager.update_chiral_labels()
625
- self.main_window.edit_actions_manager.push_undo_state()
626
- status_bar = self.main_window.statusBar()
627
- if status_bar is not None:
628
- status_bar.showMessage("Constrained optimization finished.")
629
-
630
- try:
631
- constrained_method_name = f"Constrained_{ff_name}"
632
- self.main_window.compute_manager.last_successful_optimization_method = (
633
- constrained_method_name
634
- )
635
- except (AttributeError, RuntimeError, ValueError, TypeError) as e:
636
- logging.warning(
637
- "Failed to set last_successful_optimization_method: %s", e
638
- )
639
-
640
- # Save constraints list to MainWindow on success (same logic as reject)
632
+ # Sync constraints to MainWindow before push_undo_state so the snapshot records them
641
633
  try:
642
634
  # Save as list for JSON compatibility
643
635
  json_safe_constraints = []
@@ -669,6 +661,24 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
669
661
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
670
662
  logging.warning("Failed to save constraints post-optimization: %s", e)
671
663
 
664
+ # Update 3D view
665
+ self.main_window.view_3d_manager.draw_molecule_3d(self.mol)
666
+ self.main_window.view_3d_manager.update_chiral_labels()
667
+ self.main_window.edit_actions_manager.push_undo_state()
668
+ status_bar = self.main_window.statusBar()
669
+ if status_bar is not None:
670
+ status_bar.showMessage("Constrained optimization finished.")
671
+
672
+ try:
673
+ constrained_method_name = f"Constrained_{ff_name}"
674
+ self.main_window.compute_manager.last_successful_optimization_method = (
675
+ constrained_method_name
676
+ )
677
+ except (AttributeError, RuntimeError, ValueError, TypeError) as e:
678
+ logging.warning(
679
+ "Failed to set last_successful_optimization_method: %s", e
680
+ )
681
+
672
682
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
673
683
  logging.exception("Optimization failed: %s", e)
674
684
 
@@ -679,6 +689,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
679
689
 
680
690
  def reject(self) -> None:
681
691
  """Clear labels, disable picking, and save constraints before closing."""
692
+ self._closed = True
682
693
  self.clear_constraint_labels()
683
694
  self.clear_selection_labels()
684
695
  self.disable_picking()
@@ -706,6 +717,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
706
717
  True # Mark as unsaved changes
707
718
  )
708
719
  self.main_window.state_manager.update_window_title()
720
+ self.main_window.edit_actions_manager.push_undo_state()
709
721
 
710
722
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
711
723
  logging.warning("Failed to save constraints to main window: %s", e)
@@ -159,9 +159,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
159
159
  try:
160
160
  move_group_dialog.on_atom_picked(clicked_atom_idx)
161
161
  except (AttributeError, RuntimeError):
162
- # Safe defensive fallback catching AttributeError, RuntimeError
163
- logging.debug(
164
- "Suppressed non-critical error", exc_info=True
162
+ logging.warning(
163
+ "Move-dialog atom toggle failed", exc_info=True
165
164
  )
166
165
 
167
166
  QTimer.singleShot(0, _deferred_toggle)
@@ -273,9 +272,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
273
272
  closest_atom_idx
274
273
  )
275
274
  except (AttributeError, RuntimeError):
276
- # Safe defensive fallback catching AttributeError, RuntimeError
277
- logging.debug(
278
- "Suppressed non-critical error", exc_info=True
275
+ logging.warning(
276
+ "Measurement selection failed", exc_info=True
279
277
  )
280
278
 
281
279
  QTimer.singleShot(0, _deferred_measure)
@@ -383,6 +381,54 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
383
381
  # Standard right-click
384
382
  super().OnRightButtonDown()
385
383
 
384
+ def _heal_stuck_pointer_state(self, move_group_dialog: Any) -> None:
385
+ """Self-heal drag/rotate state whose release event was lost.
386
+
387
+ A release can be lost mid-gesture (e.g. pyvista temporarily swapping
388
+ the interactor style, a dialog grabbing events), leaving this style or
389
+ a Move Group dialog in a permanent drag/rotate state that blocks
390
+ interaction. Verify against the real button state on every move and
391
+ reset when the button is not actually held.
392
+ """
393
+ try:
394
+ buttons = QApplication.mouseButtons()
395
+ left_held = bool(buttons & Qt.MouseButton.LeftButton)
396
+ right_held = bool(buttons & Qt.MouseButton.RightButton)
397
+ any_held = buttons != Qt.MouseButton.NoButton
398
+ except (AttributeError, RuntimeError, TypeError):
399
+ return
400
+
401
+ if self._is_dragging_atom and not left_held:
402
+ self.reset_interactor_state()
403
+
404
+ if not any_held:
405
+ try:
406
+ if self.GetState() != 0: # stuck ROTATE/PAN/etc. without a button
407
+ self.reset_interactor_state()
408
+ except (AttributeError, RuntimeError, TypeError):
409
+ # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
410
+ logging.debug("Suppressed non-critical error", exc_info=True)
411
+
412
+ if move_group_dialog is not None:
413
+ try:
414
+ if (
415
+ getattr(move_group_dialog, "is_dragging_group_vtk", False)
416
+ and not left_held
417
+ ):
418
+ move_group_dialog.is_dragging_group_vtk = False
419
+ move_group_dialog.drag_start_pos_vtk = None
420
+ move_group_dialog.mouse_moved_vtk = False
421
+ if (
422
+ getattr(move_group_dialog, "is_rotating_group_vtk", False)
423
+ and not right_held
424
+ ):
425
+ move_group_dialog.is_rotating_group_vtk = False
426
+ move_group_dialog.rotation_start_pos = None
427
+ move_group_dialog.rotation_mouse_moved = False
428
+ except (AttributeError, RuntimeError, TypeError):
429
+ # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
430
+ logging.debug("Suppressed non-critical error", exc_info=True)
431
+
386
432
  def on_mouse_move(self, obj: Any, event: Any) -> None:
387
433
  """
388
434
  Handle mouse move (drag vs camera/hover).
@@ -402,6 +448,8 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
402
448
  break
403
449
  except (AttributeError, RuntimeError, TypeError):
404
450
  logging.warning("Caught exception in " + __file__, exc_info=True)
451
+
452
+ self._heal_stuck_pointer_state(move_group_dialog)
405
453
  if move_group_dialog and getattr(
406
454
  move_group_dialog, "is_dragging_group_vtk", False
407
455
  ):
@@ -10,9 +10,11 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
- import time
13
+ import logging
14
14
  from typing import Any, Optional
15
15
 
16
+ from PyQt6.QtCore import QEvent
17
+ from PyQt6.QtGui import QMouseEvent
16
18
  from pyvistaqt import QtInteractor
17
19
 
18
20
 
@@ -27,9 +29,6 @@ class CustomQtInteractor(QtInteractor):
27
29
  ) -> None:
28
30
  super().__init__(parent, **kwargs)
29
31
  self.main_window = main_window
30
- self._last_click_time = 0.0
31
- self._click_count = 0
32
- self._ignore_next_release = False
33
32
 
34
33
  def wheelEvent(self, event: Any) -> None:
35
34
  """
@@ -45,61 +44,32 @@ class CustomQtInteractor(QtInteractor):
45
44
  def mouseReleaseEvent(self, event: Any) -> None:
46
45
  """
47
46
  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).
47
+ all 3D view operations.
50
48
  """
51
- if self._ignore_next_release:
52
- self._ignore_next_release = False
53
- event.accept()
54
- return
55
-
56
49
  super().mouseReleaseEvent(event) # Process parent class event first
57
50
  if self.main_window and hasattr(self.main_window.init_manager, "view_2d"):
58
51
  self.main_window.init_manager.view_2d.setFocus()
59
52
 
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
53
  def mouseDoubleClickEvent(self, event: Any) -> None:
83
- """Ignore mouse double-clicks on the 3D widget to avoid accidental actions.
54
+ """Re-dispatch double-clicks as plain presses so fast clicking works.
84
55
 
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.
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.
89
61
  """
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
62
  try:
101
- # Accept the event to mark it handled and prevent further processing.
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)
102
72
  event.accept()
103
73
  except (AttributeError, RuntimeError, TypeError):
104
- # If event doesn't support accept for some reason, just return.
105
- return
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
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING
21
21
  import numpy as np
22
22
 
23
23
  from ..core.mol_geometry import identify_valence_problems
24
+ from .app_state import _serialize_constraints
24
25
 
25
26
  # RDKit imports (explicit to satisfy flake8 and used features)
26
27
  from PyQt6.QtCore import QByteArray, QMimeData, QPointF, Qt, QTimer
@@ -116,6 +117,15 @@ class EditActionsManager:
116
117
  self.redo_stack.clear()
117
118
  self.push_undo_state()
118
119
 
120
+ def _constraints_for_comparison(self) -> List[Any]:
121
+ """Current 3D constraints in the serialized form stored in snapshots."""
122
+ constraints = getattr(
123
+ getattr(self.host, "edit_3d_manager", None), "constraints_3d", None
124
+ )
125
+ if not isinstance(constraints, list):
126
+ return []
127
+ return _serialize_constraints(constraints)
128
+
119
129
  def push_undo_state(self) -> None:
120
130
  """Saves current molecular state to undo stack for history tracking."""
121
131
  if getattr(self.host, "is_restoring_state", False):
@@ -150,6 +160,7 @@ class EditActionsManager:
150
160
  ]
151
161
  if self.host.view_3d_manager.current_mol
152
162
  else None,
163
+ "constraints_3d": self._constraints_for_comparison(),
153
164
  }
154
165
 
155
166
  last_state_for_comparison = None
@@ -174,6 +185,7 @@ class EditActionsManager:
174
185
  "_next_atom_id": last_state.get("_next_atom_id"),
175
186
  "mol_3d": last_state.get("mol_3d", None),
176
187
  "mol_3d_atom_ids": last_state.get("mol_3d_atom_ids", None),
188
+ "constraints_3d": last_state.get("constraints_3d") or [],
177
189
  }
178
190
 
179
191
  if (
@@ -116,8 +116,8 @@ class ExportManager:
116
116
  if not file_path.lower().endswith(".obj"):
117
117
  file_path += ".obj"
118
118
 
119
- # Save as OBJ+MTL with material per object
120
- mtl_path = file_path.replace(".obj", ".mtl")
119
+ # splitext, not str.replace: ".OBJ" must not make mtl_path collide with file_path
120
+ mtl_path = os.path.splitext(file_path)[0] + ".mtl"
121
121
 
122
122
  self.create_multi_material_obj(meshes_with_colors, file_path, mtl_path)
123
123
 
@@ -242,6 +242,14 @@ class IOManager:
242
242
  final_mol.xyz_atom_data = atoms_data
243
243
  return final_mol
244
244
 
245
+ def _report_load_error(self, title: str, message: str) -> None:
246
+ """Report a load failure on the status bar and, in GUI mode, a dialog."""
247
+ status_bar = self.host.statusBar()
248
+ if status_bar is not None:
249
+ status_bar.showMessage(message)
250
+ if not os.environ.get("MOLEDITPY_HEADLESS"):
251
+ QMessageBox.warning(self.host, title, message)
252
+
245
253
  def load_xyz_file(self, file_path: str) -> Optional[Any]:
246
254
  """Load XYZ file and create RDKit Mol with charge prompt and bond determination."""
247
255
  if not self.host.state_manager.check_unsaved_changes():
@@ -349,6 +357,19 @@ class IOManager:
349
357
  # Unreachable via OK (validated inline)
350
358
  return 0, True, False
351
359
 
360
+ @staticmethod
361
+ def _covalent_radius(symbol: str, atomic_num: int) -> float:
362
+ """Covalent radius from the local table, RDKit's periodic table for
363
+ elements the table lacks (it stops at V), or 1.0 as a last resort."""
364
+ radius = COVALENT_RADII.get(symbol)
365
+ if radius:
366
+ return radius
367
+ try:
368
+ radius = Chem.GetPeriodicTable().GetRcovalent(atomic_num)
369
+ except (ValueError, RuntimeError):
370
+ radius = 0.0
371
+ return radius if radius > 0.0 else 1.0
372
+
352
373
  def estimate_bonds_from_distances(self, mol: Chem.RWMol) -> int:
353
374
  """Estimate bonds based on interatomic distances using covalent radii."""
354
375
  conf = mol.GetConformer()
@@ -364,8 +385,8 @@ class IOManager:
364
385
  distance = rdMolTransforms.GetBondLength(conf, i, j)
365
386
  symbol_i = atom_i.GetSymbol()
366
387
  symbol_j = atom_j.GetSymbol()
367
- radius_i = COVALENT_RADII.get(symbol_i, 1.0)
368
- radius_j = COVALENT_RADII.get(symbol_j, 1.0)
388
+ radius_i = self._covalent_radius(symbol_i, atom_i.GetAtomicNum())
389
+ radius_j = self._covalent_radius(symbol_j, atom_j.GetAtomicNum())
369
390
  expected = radius_i + radius_j
370
391
  tolerance = 1.2 if (symbol_i == "H" or symbol_j == "H") else 1.3
371
392
  if expected * 0.5 <= distance <= expected * tolerance:
@@ -493,8 +514,9 @@ class IOManager:
493
514
  elif file_path.lower().endswith(".pmeraw"):
494
515
  self.load_raw_data(file_path)
495
516
  else:
496
- self.host.statusBar().showMessage(
497
- "Error: Unable to determine file format or file corrupted."
517
+ self._report_load_error(
518
+ "Project Load Error",
519
+ "Error: Unable to determine file format or file corrupted.",
498
520
  )
499
521
 
500
522
  def save_as_json(self) -> None:
@@ -592,14 +614,23 @@ class IOManager:
592
614
  QTimer.singleShot(100, self.host.view_3d_manager.fit_to_view)
593
615
 
594
616
  except FileNotFoundError:
595
- self.host.statusBar().showMessage(f"File not found: {file_path}")
617
+ self._report_load_error(
618
+ "Project Load Error", f"File not found: {file_path}"
619
+ )
596
620
  except json.JSONDecodeError as e:
597
- self.host.statusBar().showMessage(f"Invalid JSON format: {e}")
621
+ self._report_load_error("Project Load Error", f"Invalid JSON format: {e}")
598
622
  except (OSError, IOError) as e:
599
- self.host.statusBar().showMessage(f"File I/O error: {e}")
600
- except (TypeError, ValueError, RuntimeError, AttributeError, KeyError) as e:
601
- self.host.statusBar().showMessage(
602
- f"Data corruption in PME Project file: {e}"
623
+ self._report_load_error("Project Load Error", f"File I/O error: {e}")
624
+ except (
625
+ TypeError,
626
+ ValueError,
627
+ RuntimeError,
628
+ AttributeError,
629
+ KeyError,
630
+ IndexError,
631
+ ) as e:
632
+ self._report_load_error(
633
+ "Project Load Error", f"Data corruption in PME Project file: {e}"
603
634
  )
604
635
 
605
636
  def save_raw_data(self) -> None:
@@ -757,7 +788,7 @@ class IOManager:
757
788
  AttributeError,
758
789
  KeyError,
759
790
  ) as e:
760
- self.host.statusBar().showMessage(f"Error loading file: {e}")
791
+ self._report_load_error("MOL Import Error", f"Error loading file: {e}")
761
792
 
762
793
  def save_as_mol(self) -> None:
763
794
  """Save current 2D structure as MOL file."""
@@ -842,8 +873,7 @@ class IOManager:
842
873
  self.host.set_has_unsaved_changes(False)
843
874
  self.host.state_manager.update_window_title()
844
875
  except (OSError, IOError, ValueError, RuntimeError, AttributeError) as e:
845
- if self.host.statusBar():
846
- self.host.statusBar().showMessage(f"XYZ Load failed: {e}")
876
+ self._report_load_error("XYZ Load Error", f"XYZ Load failed: {e}")
847
877
 
848
878
  def load_mol_file_for_3d_viewing(self, file_path: Optional[str] = None) -> None:
849
879
  """Open MOL/SDF file in 3D viewer."""
@@ -897,7 +927,7 @@ class IOManager:
897
927
  self.host.set_has_unsaved_changes(False)
898
928
  self.host.state_manager.update_window_title()
899
929
  except (OSError, IOError, ValueError, RuntimeError, AttributeError) as e:
900
- self.host.statusBar().showMessage(f"3D MOL Load failed: {e}")
930
+ self._report_load_error("3D MOL Load Error", f"3D MOL Load failed: {e}")
901
931
 
902
932
  def save_3d_as_mol(self) -> None:
903
933
  """Save current 3D structure as MOL file."""
@@ -1031,13 +1061,26 @@ class IOManager:
1031
1061
  QTimer.singleShot(100, self._plotter_render)
1032
1062
 
1033
1063
  except FileNotFoundError:
1034
- self.host.statusBar().showMessage(f"File not found: {file_path}")
1064
+ self._report_load_error(
1065
+ "Project Load Error", f"File not found: {file_path}"
1066
+ )
1035
1067
  except (OSError, IOError) as e:
1036
- self.host.statusBar().showMessage(f"File I/O error: {e}")
1037
- except pickle.UnpicklingError as e:
1038
- self.host.statusBar().showMessage(f"Invalid project file format: {e}")
1039
- except (TypeError, ValueError, RuntimeError, AttributeError) as e:
1040
- self.host.statusBar().showMessage(f"Error loading project file: {e}")
1068
+ self._report_load_error("Project Load Error", f"File I/O error: {e}")
1069
+ except (pickle.UnpicklingError, EOFError, ImportError) as e:
1070
+ self._report_load_error(
1071
+ "Project Load Error", f"Invalid project file format: {e}"
1072
+ )
1073
+ except (
1074
+ TypeError,
1075
+ ValueError,
1076
+ RuntimeError,
1077
+ AttributeError,
1078
+ KeyError,
1079
+ IndexError,
1080
+ ) as e:
1081
+ self._report_load_error(
1082
+ "Project Load Error", f"Error loading project file: {e}"
1083
+ )
1041
1084
 
1042
1085
  def _set_mol_prop(self, mol: Chem.Mol, prop_name: str, value: Any) -> None:
1043
1086
  """Set an RDKit molecule property safely."""
@@ -202,7 +202,8 @@ class MainInitManager:
202
202
  self.host.edit_actions_manager.update_edit_menu_actions()
203
203
 
204
204
  if initial_file:
205
- self.load_command_line_file(initial_file)
205
+ # Deferred: a load-error dialog raised in the constructor would block the window from showing
206
+ QTimer.singleShot(0, lambda: self.load_command_line_file(initial_file))
206
207
 
207
208
  QTimer.singleShot(0, self.apply_initial_settings)
208
209
  # Camera initialization flag (permits reset only during the first draw)
@@ -1195,6 +1196,7 @@ class MainInitManager:
1195
1196
 
1196
1197
  file_menu.addSeparator()
1197
1198
  quit_action = QAction("Quit", self.host)
1199
+ quit_action.setShortcut("Ctrl+Q")
1198
1200
  quit_action.triggered.connect(self.host.close)
1199
1201
  file_menu.addAction(quit_action)
1200
1202
 
@@ -1099,11 +1099,13 @@ class KeyboardMixin:
1099
1099
  # 3. Update BondItem properties based on key
1100
1100
  if key == Qt.Key.Key_W:
1101
1101
  if bond.stereo == 1:
1102
+ # Flip mutates the model without changing order/stereo, so set the undo flag here
1102
1103
  bond_data = self.data.bonds.pop(current_key)
1103
1104
  new_key = (current_key[1], current_key[0])
1104
1105
  self.data.bonds[new_key] = bond_data
1105
1106
  bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1106
1107
  bond.update_position()
1108
+ any_bond_changed = True
1107
1109
  else:
1108
1110
  bond.order = 1
1109
1111
  bond.stereo = 1
@@ -1115,6 +1117,7 @@ class KeyboardMixin:
1115
1117
  self.data.bonds[new_key] = bond_data
1116
1118
  bond.atom1, bond.atom2 = bond.atom2, bond.atom1
1117
1119
  bond.update_position()
1120
+ any_bond_changed = True
1118
1121
  else:
1119
1122
  bond.order = 1
1120
1123
  bond.stereo = 2
@@ -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."""
@@ -143,6 +143,20 @@ class PluginMenuManager:
143
143
  except Exception:
144
144
  logging.warning("Plugin rebuild: menu cleanup error", exc_info=True)
145
145
 
146
+ # Drop tagged (plugin-created) top-level menus the clean pass emptied
147
+ try:
148
+ menu_bar = self._im.host.menuBar()
149
+ for top_action in list(menu_bar.actions()):
150
+ if top_action.data() != self._PLUGIN_ACTION_TAG:
151
+ continue
152
+ submenu = top_action.menu()
153
+ if submenu is None or not any(
154
+ not a.isSeparator() for a in submenu.actions()
155
+ ):
156
+ menu_bar.removeAction(top_action)
157
+ except Exception:
158
+ logging.warning("Plugin rebuild: menubar cleanup error", exc_info=True)
159
+
146
160
  self._im.plugin_menubar_separator_added = False
147
161
 
148
162
  for method, label in [
@@ -182,9 +196,11 @@ class PluginMenuManager:
182
196
 
183
197
  if not current_menu:
184
198
  if not self._im.plugin_menubar_separator_added:
185
- self._im.host.menuBar().addSeparator()
199
+ sep = self._im.host.menuBar().addSeparator()
200
+ sep.setData(self._PLUGIN_ACTION_TAG)
186
201
  self._im.plugin_menubar_separator_added = True
187
202
  current_menu = self._im.host.menuBar().addMenu(top_level_title)
203
+ current_menu.menuAction().setData(self._PLUGIN_ACTION_TAG)
188
204
 
189
205
  for part in parts[1:-1]:
190
206
  sub = next(
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.3.0
3
+ Version: 4.3.2
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
@@ -770,16 +770,17 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
770
770
  * **Stereochemistry Display:** Automatically identifies and displays R/S labels for chiral centers in the 3D view after conversion.
771
771
  * **File I/O:**
772
772
  * Save and load entire sessions, including 2D/3D data and constraints, with the native `.pmeprj` project file.
773
- * Import structures from **MOL/SDF** files or **SMILES** strings.
773
+ * Import structures from **MOL/SDF/XYZ** files or **SMILES/InChI** strings.
774
774
  * Export 3D structures to **MOL** or **XYZ** formats, which are compatible with most DFT calculation software.
775
- * Export 2D and 3D views as high-resolution PNG images.
775
+ * Export 2D views as high-resolution PNG or SVG images, and 3D views as PNG images.
776
+ * Export the 3D model as **STL** or **OBJ/MTL** files for 3D printing and rendering.
776
777
  * **Security note on `.pmeraw`:** the legacy `.pmeraw` project format uses Python pickle, which can execute arbitrary code when loaded. Only open `.pmeraw` files that you created yourself; prefer the JSON-based `.pmeprj` format for sharing.
777
778
 
778
779
  ### 4. Programmable & Extensible
779
780
 
780
781
  * **Python Plugin System:** Drop your Python scripts into the plugin folder, and they instantly become part of the application menu.
781
782
  * **Downloadable Plugins:** Explore and download specialized plugins from the [Plugin Explorer](https://hiroyokoyama.github.io/moleditpy-plugins/explorer/).
782
- * **Full API Access:** Plugins have direct access to the `MainWindow`, `RDKit` molecule objects, and `PyGraphics` items, allowing for limitless customization.
783
+ * **Full API Access:** Plugins have direct access to the `MainWindow`, `RDKit` molecule objects, and the Qt graphics items of the 2D scene, allowing for limitless customization.
783
784
  * **Rapid Prototyping:** Ideal for researchers who need to test new algorithms or workflow automations on the fly.
784
785
 
785
786
  ## Installation and Execution
@@ -835,7 +836,7 @@ moleditpy
835
836
  * **GUI and 2D Drawing (PyQt6):** The editor is built on a `QGraphicsScene`, where custom `AtomItem` and `BondItem` objects are interactively manipulated. The Undo/Redo feature is implemented by serializing the application state.
836
837
  * **Chemical Calculations (RDKit / Open Babel):** RDKit is used to generate molecule objects from 2D data, perform 3D coordinate generation, and calculate properties. Open Babel serves as a fallback for 3D conversion. All heavy computations are run on a separate `QThread` to keep the GUI responsive.
837
838
  * **3D Visualization (PyVista / pyvistaqt):** 3D rendering is achieved by generating PyVista meshes (spheres and cylinders) from RDKit conformer coordinates. A custom `vtkInteractorStyle` enables direct drag-and-drop editing of atoms in the 3D view.
838
- * **Modular Architecture:** The codebase is organized into dedicated packages for `core` logic, `ui` components, and `utils`. The main application logic is decomposed into reusable mixins, ensuring long-term maintainability and easier verification.
839
+ * **Modular Architecture:** The codebase is organized into dedicated packages for `core` logic, `ui` components, and `utils`. The `MainWindow` acts as a composition hub that delegates all work to specialized manager objects (state, I/O, 3D view, dialogs), ensuring long-term maintainability and easier verification.
839
840
 
840
841
  ## License & Disclaimer
841
842
 
@@ -905,16 +906,17 @@ Additionally, please cite the plugins you used.
905
906
  * **立体化学表示:** 3D変換後、キラル中心を自動的に認識し、R/Sラベルを3Dビューに表示します。
906
907
  * **ファイル入出力:**
907
908
  * 2D/3Dデータや制約情報を含むセッション全体を、独自のプロジェクトファイル (`.pmeprj`) として保存・読み込みできます。
908
- * **MOL/SDF**ファイルや**SMILES**文字列から構造をインポートできます。
909
+ * **MOL/SDF/XYZ**ファイルや**SMILES/InChI**文字列から構造をインポートできます。
909
910
  * 3D構造を**MOL**または**XYZ**形式でエクスポートでき、これらは多くのDFT計算ソフトウェアと互換性があります。
910
- * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
911
+ * 2Dビューを高解像度のPNG・SVG画像として、3DビューをPNG画像としてエクスポートできます。
912
+ * 3Dモデルを**STL**または**OBJ/MTL**ファイルとしてエクスポートでき、3Dプリントやレンダリングに利用できます。
911
913
  * **`.pmeraw` に関するセキュリティ上の注意:** 旧形式の `.pmeraw` プロジェクトは Python の pickle を使用しており、読み込み時に任意のコードが実行される可能性があります。自分で作成した `.pmeraw` ファイルのみを開き、共有には JSON ベースの `.pmeprj` 形式を使用してください。
912
914
 
913
915
  ### 4. プログラマブルで拡張可能
914
916
 
915
917
  * **Pythonプラグインシステム:** Pythonスクリプトをプラグインフォルダに入れるだけで、即座にアプリケーションメニューの一部として機能します。
916
918
  * **プラグインのダウンロード:** [Plugin Explorer](https://hiroyokoyama.github.io/moleditpy-plugins/explorer/) から特化したプラグインを探索・ダウンロードできます。
917
- * **フルAPIアクセス:** プラグインは `MainWindow`、`RDKit` 分子オブジェクト、`PyGraphics` アイテムに直接アクセスでき、無限のカスタマイズが可能です。
919
+ * **フルAPIアクセス:** プラグインは `MainWindow`、`RDKit` 分子オブジェクト、2D シーンの Qt グラフィックスアイテムに直接アクセスでき、無限のカスタマイズが可能です。
918
920
  * **迅速なプロトタイピング:** 新しいアルゴリズムやワークフローの自動化をその場でテストしたい研究者に最適です。
919
921
 
920
922
  ## インストールと実行
@@ -970,6 +972,7 @@ moleditpy
970
972
  * **GUIと2D描画 (PyQt6):** `QGraphicsScene`上にカスタムの`AtomItem`(原子)と`BondItem`(結合)を配置し、対話的に操作します。Undo/Redo機能は、アプリケーションの状態をシリアライズしてスタックに保存することで実現しています。
971
973
  * **化学計算 (RDKit / Open Babel):** 2DデータからRDKit分子オブジェクトを生成し、3D座標生成や分子特性計算を実行します。RDKitでの3D座標生成が失敗した際は、Open Babelにフォールバックします。重い計算処理は別スレッド (`QThread`) で実行し、GUIの応答性を維持しています。
972
974
  * **3D可視化 (PyVista / pyvistaqt):** RDKitのコンフォーマ座標からPyVistaのメッシュ(球や円柱)を生成して描画します。カスタムの`vtkInteractorStyle`を実装し、3Dビュー内での原子の直接的なドラッグ&ドロップ編集を可能にしています。
975
+ * **モジュラーアーキテクチャ:** コードベースは `core` ロジック、`ui` コンポーネント、`utils` の専用パッケージに整理されています。`MainWindow` は、状態管理・ファイル入出力・3D ビュー・ダイアログなどを専門のマネージャーオブジェクトに委譲するコンポジションハブとして機能し、長期的な保守性と検証の容易さを確保しています。
973
976
 
974
977
  ## ライセンス & 免責事項
975
978
 
@@ -27,33 +27,33 @@ moleditpy_linux/ui/bond_length_dialog.py,sha256=lss8J2g_pUK-BCbqWk25JMFsn-g8SxBj
27
27
  moleditpy_linux/ui/calculation_worker.py,sha256=bwrzCW5935Twe5EY7Mtd35YQaWJILvBPwzPIQgmqz98,41849
28
28
  moleditpy_linux/ui/color_settings_dialog.py,sha256=RFp8h0R1yC8YwqOxc972Da9xO95Gu-0qywAAEeXMj4E,14224
29
29
  moleditpy_linux/ui/compute_logic.py,sha256=DIPmqzlQv-g-MK8EllAD_soG0tk8PMoW5oxEl-uvsJA,31216
30
- moleditpy_linux/ui/constrained_optimization_dialog.py,sha256=Adz27xAMZJrfN3e118Z6h6VMvdmCNcuRyYl755YBSDE,35824
31
- moleditpy_linux/ui/custom_interactor_style.py,sha256=zbZCmAZAR1ywuENDIb_guIhu09IUn_kjeopr6FT2t2s,44529
32
- moleditpy_linux/ui/custom_qt_interactor.py,sha256=AZ5TtQjWUs4F5FFg-tBUZQlbk74UHboTjYpBQRUlhvQ,3827
33
- moleditpy_linux/ui/dialog_3d_picking_mixin.py,sha256=lH7NS7zF_TxhXEIr_mEPg-9LpI1AqpsZzMFAue33AIA,10705
30
+ moleditpy_linux/ui/constrained_optimization_dialog.py,sha256=rc3YK6DOJdIbhriwsM2jOAW9j_-3PvFz1XtFHpGw4E4,36346
31
+ moleditpy_linux/ui/custom_interactor_style.py,sha256=p48f1BD8OK36lyAimK69lXbITjuXRg7QO8aSwk4ogtI,46791
32
+ moleditpy_linux/ui/custom_qt_interactor.py,sha256=76L0Jpbg09VXzHOzJs9kG260Vs9qcwbt8Chn0iOopDg,2820
33
+ moleditpy_linux/ui/dialog_3d_picking_mixin.py,sha256=nsZQ27PYZrO9s_w5EXxxluVfOOmNKcv0vvm89IWN7mU,10604
34
34
  moleditpy_linux/ui/dialog_logic.py,sha256=KCz3ZPJap4K_tiNDjCzGqQHYxdEsvcUG6Lws2tDhvx4,19617
35
35
  moleditpy_linux/ui/dihedral_dialog.py,sha256=0-EeJZG6YCehCWwqCEOougzZjF-zyzKEHCUC_cls7GE,18833
36
36
  moleditpy_linux/ui/edit_3d_logic.py,sha256=DcdZZwPRkJ0RIReyTyvelkZILYG-vggop92dz2hkYSY,19192
37
- moleditpy_linux/ui/edit_actions_logic.py,sha256=QE2w90Lo_0JI78X9Og1qVgX-woZLipxna3aOD0g-j1Y,66080
38
- moleditpy_linux/ui/export_logic.py,sha256=97NrR6lC8U7ChN2EyOuTrl1vHB6g6xuL1BeJmYyy7p8,43720
37
+ moleditpy_linux/ui/edit_actions_logic.py,sha256=no8SM7munZv7Nb5tEEaRr9vCIiDgYCns9wK4QZ1Tq4c,66656
38
+ moleditpy_linux/ui/export_logic.py,sha256=lZnzTLIEy_UWHiZpJ5Decr1-fy1JFMVjJNgXXc2cs2o,43765
39
39
  moleditpy_linux/ui/geometry_base_dialog.py,sha256=jj9uI2cexEjC3WxFm8HAlsrGUcTX6lQv-BX70hkSW7o,4472
40
- moleditpy_linux/ui/io_logic.py,sha256=cGXrECHoCkGIJ7HUZpbm4SrE-Ewmsn6b5nj4V1qsi-M,45975
40
+ moleditpy_linux/ui/io_logic.py,sha256=a5ER4P6NCFfIv4nRiDiclCJ3_9F_OjZkqVUEw26AS8w,47427
41
41
  moleditpy_linux/ui/main_window.py,sha256=qVSN2XclGCBsRCRtC10xBUn5VdzPer0rzMPHOuYw2ZU,12553
42
- moleditpy_linux/ui/main_window_init.py,sha256=I4CmwH7E_xzKmHWYbooarKek2bhznXySViB3lkBJfJ4,77064
42
+ moleditpy_linux/ui/main_window_init.py,sha256=94_Sf_E5ZTtkyQvSIURWXbuXxabg46L0AxOMsVPKHf8,77244
43
43
  moleditpy_linux/ui/mirror_dialog.py,sha256=Tsc2O9Ktjz0zzJWB_YkVgkqgDhytQ7ksN_PwpcY9vJI,5169
44
- moleditpy_linux/ui/molecular_scene_handler.py,sha256=WJ8LM88e5RMLRojClaS2_GC2zsJrN5LgtHK9J1FSRvA,69818
44
+ moleditpy_linux/ui/molecular_scene_handler.py,sha256=Y53CceA2cbtPdY-n7mcnYsfRl2MgQT55R9xCMQ4m18o,70035
45
45
  moleditpy_linux/ui/molecule_scene.py,sha256=ZUZBaCyWaNEAITHI4JmJ__gQcI9kgttj8qBP6dgF6LU,44090
46
46
  moleditpy_linux/ui/move_group_dialog.py,sha256=3jfj8zmKrYs0N9537o3g0V3f0UOnr0pj8uNT0qvUlPM,27063
47
- moleditpy_linux/ui/move_selected_atoms_dialog.py,sha256=6Q43RXKL8Fmkf93emxUJYeel-QqMQ_lCyzeq8Bj6PV4,29656
47
+ moleditpy_linux/ui/move_selected_atoms_dialog.py,sha256=7_Krz69LDx71_pBr9_bP55UhT97SJZU-W6OEN59waDg,29889
48
48
  moleditpy_linux/ui/periodic_table_dialog.py,sha256=yadE1bbJaObAiIfpKwANqiDOBI8R3yOHvDTZYrw1J4Q,6134
49
49
  moleditpy_linux/ui/planarize_dialog.py,sha256=PvEXAx27iO3d9OWrr0kL4WTeNI2eMIx-FRZaFWW-cao,7936
50
- moleditpy_linux/ui/plugin_menu_manager.py,sha256=fxo8osB3AsPWvetJa5skZLymYTEVvgVMyvgxzpuusAs,21317
50
+ moleditpy_linux/ui/plugin_menu_manager.py,sha256=EXju-Bcy-Wxg_MpJLsV2nUgjlwVpyqZBVU5VohzUk5w,22101
51
51
  moleditpy_linux/ui/settings_dialog.py,sha256=tAw-Ko73oBG5o8jnsiK4MiPar88GxiAkcnE5h6aUlYo,7319
52
52
  moleditpy_linux/ui/string_importers.py,sha256=cgsMoIYRKKP-RZZzhcpCdlDqr8KJgmazZdJ7C3GCSaE,8078
53
53
  moleditpy_linux/ui/template_preview_item.py,sha256=exJ4r3qmLOCQrEgl9ktNu4zTrgqixZcq82rUb9flWiM,7743
54
54
  moleditpy_linux/ui/template_preview_view.py,sha256=XMJ5tg4CdF2QA_6Yt0DMXAMWFugmiOadhIEvLpclsbo,3817
55
55
  moleditpy_linux/ui/translation_dialog.py,sha256=jAEHOCICixT1TcsQHnW-8h1o1HMxPC8Mw3_qYrUaoI0,16663
56
- moleditpy_linux/ui/ui_manager.py,sha256=7NbTQOdabHmmurReDdJTVPSZbwJzZb9FkHnYZWZApjw,26635
56
+ moleditpy_linux/ui/ui_manager.py,sha256=cljxqBlvlz98n5uiie1auKJdenGAeRIVF9e21R1hkDA,28568
57
57
  moleditpy_linux/ui/user_template_dialog.py,sha256=ZKGMyhqzS14CKor51YiEcHUXe8bNvughRDTG9-Pc-d0,29198
58
58
  moleditpy_linux/ui/view_3d_logic.py,sha256=fuXQKAQFH8e_Xm6e8mG2y4EN2EnBqFxJRqsloilBU9c,92757
59
59
  moleditpy_linux/ui/zoomable_view.py,sha256=Kpr9SaIYAKH-Fexm5-gi3cswsiT5V7g7yTk7acutZjA,6239
@@ -68,9 +68,9 @@ moleditpy_linux/utils/default_settings.py,sha256=6Q3Gw1vyG7uk-35yBtr1sKeJVnOjb4S
68
68
  moleditpy_linux/utils/sip_isdeleted_safe.py,sha256=aXoW_PDHAsu0SkqcvkUZAsWDhl9Ka8-VnzgNE5ub41Y,1215
69
69
  moleditpy_linux/utils/suppress_log.py,sha256=EFkN79q6QfYXAuii8g1fBuvFJrnVsio3aFTKMvAmXW4,928
70
70
  moleditpy_linux/utils/system_utils.py,sha256=j1RAiiKXg3N-rjpTh7yxKVAYED_kUJy4aVrY-9Hh0Mc,2674
71
- moleditpy_linux-4.3.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
72
- moleditpy_linux-4.3.0.dist-info/METADATA,sha256=lkQg-9e6L8dbr3cKWRE8fMVlrCqTICB6_JtYR90VMI4,64474
73
- moleditpy_linux-4.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
74
- moleditpy_linux-4.3.0.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
75
- moleditpy_linux-4.3.0.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
76
- moleditpy_linux-4.3.0.dist-info/RECORD,,
71
+ moleditpy_linux-4.3.2.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
72
+ moleditpy_linux-4.3.2.dist-info/METADATA,sha256=IE2cOdceTHiUxPeNUczyD4YL3fsLiSZyZwxYa1JwuXQ,65372
73
+ moleditpy_linux-4.3.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
74
+ moleditpy_linux-4.3.2.dist-info/entry_points.txt,sha256=-OzipSi__yVwlimNtu3eiRP5t5UMg55Cs0udyhXYiyw,60
75
+ moleditpy_linux-4.3.2.dist-info/top_level.txt,sha256=qyqe-hDYL6CXyin9E5Me5rVl3PG84VqiOjf9bQvfJLs,16
76
+ moleditpy_linux-4.3.2.dist-info/RECORD,,