molviewer 0.1.1__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.
- molviewer/__init__.py +11 -0
- molviewer/_mol.py +165 -0
- molviewer/attribution.py +221 -0
- molviewer/draw2d.py +97 -0
- molviewer/view3d.py +213 -0
- molviewer-0.1.1.dist-info/METADATA +97 -0
- molviewer-0.1.1.dist-info/RECORD +10 -0
- molviewer-0.1.1.dist-info/WHEEL +5 -0
- molviewer-0.1.1.dist-info/licenses/LICENSE +21 -0
- molviewer-0.1.1.dist-info/top_level.txt +1 -0
molviewer/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
molviewer — 2D and 3D molecular visualization for Jupyter, Colab, and Streamlit.
|
|
3
|
+
|
|
4
|
+
2D drawing with RDKit, 3D viewing with py3Dmol and Speck (ambient occlusion).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.1"
|
|
8
|
+
|
|
9
|
+
from .draw2d import draw
|
|
10
|
+
from .view3d import view3d
|
|
11
|
+
from .attribution import draw_attribution
|
molviewer/_mol.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone molecule utilities — no dependency on molfp.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from rdkit import Chem
|
|
8
|
+
from rdkit.Chem import AllChem, rdMolDescriptors
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_conformer_cache = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_mol(smiles_or_mol):
|
|
15
|
+
"""Convert SMILES to RDKit Mol if needed."""
|
|
16
|
+
if isinstance(smiles_or_mol, Chem.rdchem.Mol):
|
|
17
|
+
return smiles_or_mol
|
|
18
|
+
if isinstance(smiles_or_mol, str):
|
|
19
|
+
mol = Chem.MolFromSmiles(smiles_or_mol)
|
|
20
|
+
if mol is None:
|
|
21
|
+
raise ValueError(f"Invalid SMILES: {smiles_or_mol}")
|
|
22
|
+
return mol
|
|
23
|
+
raise TypeError(f"Expected SMILES string or RDKit Mol, got {type(smiles_or_mol)}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_smiles(input_data, column=None):
|
|
27
|
+
"""Extract a list of SMILES from various input types.
|
|
28
|
+
|
|
29
|
+
Returns (smiles_list, is_batch) where is_batch=False means single molecule.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(input_data, str):
|
|
32
|
+
return input_data, False
|
|
33
|
+
|
|
34
|
+
if isinstance(input_data, Chem.rdchem.Mol):
|
|
35
|
+
return input_data, False
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import pandas as pd
|
|
39
|
+
if isinstance(input_data, pd.DataFrame):
|
|
40
|
+
if column is not None:
|
|
41
|
+
return input_data[column].tolist(), True
|
|
42
|
+
for col in ["SMILES", "smiles", "Smiles", "canonical_smiles", "CANONICAL_SMILES",
|
|
43
|
+
"mol", "Mol", "MOL", "molecule", "smi"]:
|
|
44
|
+
if col in input_data.columns:
|
|
45
|
+
return input_data[col].tolist(), True
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"DataFrame has no recognized SMILES column. "
|
|
48
|
+
"Pass column= or use a column named 'SMILES', 'smiles', or 'canonical_smiles'."
|
|
49
|
+
)
|
|
50
|
+
if isinstance(input_data, pd.Series):
|
|
51
|
+
return input_data.tolist(), True
|
|
52
|
+
except ImportError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
if hasattr(input_data, '__iter__') and not isinstance(input_data, (str, bytes)):
|
|
56
|
+
return list(input_data), True
|
|
57
|
+
|
|
58
|
+
return input_data, False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Conformer generation ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
_METAL_ATOMIC_NUMS = (
|
|
64
|
+
set(range(3, 5))
|
|
65
|
+
| set(range(11, 14))
|
|
66
|
+
| set(range(19, 31))
|
|
67
|
+
| set(range(37, 49))
|
|
68
|
+
| set(range(55, 81))
|
|
69
|
+
| set(range(87, 104))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_LARGE_MOL_THRESHOLD = 80
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _classify_molecule(mol):
|
|
76
|
+
has_metal = any(a.GetAtomicNum() in _METAL_ATOMIC_NUMS for a in mol.GetAtoms())
|
|
77
|
+
is_large = mol.GetNumHeavyAtoms() > _LARGE_MOL_THRESHOLD
|
|
78
|
+
if has_metal and is_large:
|
|
79
|
+
return "metal_large"
|
|
80
|
+
if has_metal:
|
|
81
|
+
return "metal"
|
|
82
|
+
if is_large:
|
|
83
|
+
return "large"
|
|
84
|
+
return "standard"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _estimate_max_iters(mol):
|
|
88
|
+
"""Estimate MMFF iterations from molecular descriptors (MLR on 1,420 FDA drugs)."""
|
|
89
|
+
n_atoms = mol.GetNumHeavyAtoms()
|
|
90
|
+
n_rot = rdMolDescriptors.CalcNumRotatableBonds(mol)
|
|
91
|
+
n_rings = rdMolDescriptors.CalcNumRings(mol)
|
|
92
|
+
fsp3 = rdMolDescriptors.CalcFractionCSP3(mol)
|
|
93
|
+
raw = -10 + 9.9 * n_atoms + 6.8 * n_rot - 12.4 * n_rings + 38.3 * fsp3
|
|
94
|
+
return max(200, min(2000, int(1.5 * raw)))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ensure_conformer_impl(mol):
|
|
98
|
+
"""Core conformer generation logic."""
|
|
99
|
+
if mol.GetNumConformers() > 0:
|
|
100
|
+
return mol
|
|
101
|
+
|
|
102
|
+
mol_class = _classify_molecule(mol)
|
|
103
|
+
mol = Chem.AddHs(mol)
|
|
104
|
+
|
|
105
|
+
if mol_class in ("large", "metal_large"):
|
|
106
|
+
params = AllChem.ETKDGv3()
|
|
107
|
+
params.randomSeed = 42
|
|
108
|
+
status = AllChem.EmbedMolecule(mol, params)
|
|
109
|
+
if status == -1:
|
|
110
|
+
params.useRandomCoords = True
|
|
111
|
+
status = AllChem.EmbedMolecule(mol, params)
|
|
112
|
+
else:
|
|
113
|
+
status = AllChem.EmbedMolecule(mol, randomSeed=42)
|
|
114
|
+
if status == -1:
|
|
115
|
+
status = AllChem.EmbedMolecule(mol, AllChem.ETKDGv3())
|
|
116
|
+
|
|
117
|
+
if status == -1:
|
|
118
|
+
return mol
|
|
119
|
+
|
|
120
|
+
estimated = _estimate_max_iters(mol)
|
|
121
|
+
|
|
122
|
+
if mol_class in ("metal", "metal_large"):
|
|
123
|
+
try:
|
|
124
|
+
for multiplier in [1, 2, 4]:
|
|
125
|
+
max_iters = min(estimated * multiplier, 4000)
|
|
126
|
+
result = AllChem.UFFOptimizeMolecule(mol, maxIters=max_iters)
|
|
127
|
+
if result == 0:
|
|
128
|
+
return mol
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
return mol
|
|
132
|
+
|
|
133
|
+
props = AllChem.MMFFGetMoleculeProperties(mol)
|
|
134
|
+
if props is not None:
|
|
135
|
+
for multiplier in [1, 2, 4]:
|
|
136
|
+
max_iters = min(estimated * multiplier, 4000)
|
|
137
|
+
result = AllChem.MMFFOptimizeMolecule(mol, maxIters=max_iters)
|
|
138
|
+
if result == 0:
|
|
139
|
+
return mol
|
|
140
|
+
return mol
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
for multiplier in [1, 2, 4]:
|
|
144
|
+
max_iters = min(estimated * multiplier, 4000)
|
|
145
|
+
result = AllChem.UFFOptimizeMolecule(mol, maxIters=max_iters)
|
|
146
|
+
if result == 0:
|
|
147
|
+
return mol
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
return mol
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ensure_conformer(mol):
|
|
155
|
+
"""Add optimized 3D conformer with caching."""
|
|
156
|
+
try:
|
|
157
|
+
smi = Chem.MolToSmiles(mol)
|
|
158
|
+
if smi in _conformer_cache:
|
|
159
|
+
return _conformer_cache[smi]
|
|
160
|
+
except Exception:
|
|
161
|
+
return _ensure_conformer_impl(mol)
|
|
162
|
+
|
|
163
|
+
result = _ensure_conformer_impl(mol)
|
|
164
|
+
_conformer_cache[smi] = result
|
|
165
|
+
return result
|
molviewer/attribution.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
2D structural attribution maps — requires molfp for SHAP/explain.
|
|
3
|
+
|
|
4
|
+
Green = active-driving atoms, Red = inactive-driving atoms.
|
|
5
|
+
Two styles: 'highlight' (solid atom/bond circles) and 'contour' (gaussian contour map).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from rdkit import Chem
|
|
14
|
+
from rdkit.Chem import AllChem
|
|
15
|
+
|
|
16
|
+
from ._mol import ensure_mol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bit_shap_to_atom_weights(mol, smiles, fp_type, shap_per_bit):
|
|
20
|
+
"""Aggregate bit-level SHAP values to per-atom weights via explain()."""
|
|
21
|
+
try:
|
|
22
|
+
from molfp.explain import explain
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"molfp is required for draw_attribution(). "
|
|
26
|
+
"Install it with: pip install molfp"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
bit_atoms = explain(smiles, fp_type)
|
|
30
|
+
n_atoms = mol.GetNumAtoms()
|
|
31
|
+
weights = np.zeros(n_atoms)
|
|
32
|
+
for bit, shap_val in shap_per_bit.items():
|
|
33
|
+
if bit not in bit_atoms:
|
|
34
|
+
continue
|
|
35
|
+
for atom_info in bit_atoms[bit]:
|
|
36
|
+
if isinstance(atom_info, (list, tuple)):
|
|
37
|
+
atom_indices = [a for a in atom_info if isinstance(a, int)]
|
|
38
|
+
elif isinstance(atom_info, int):
|
|
39
|
+
atom_indices = [atom_info]
|
|
40
|
+
else:
|
|
41
|
+
continue
|
|
42
|
+
if atom_indices:
|
|
43
|
+
share = shap_val / len(atom_indices)
|
|
44
|
+
for idx in atom_indices:
|
|
45
|
+
if 0 <= idx < n_atoms:
|
|
46
|
+
weights[idx] += share
|
|
47
|
+
return weights
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def draw_attribution(smiles, interpret_result, *,
|
|
51
|
+
class_active="active", class_inactive="inactive",
|
|
52
|
+
size=(400, 400), alpha=0.5, style="highlight", save=None):
|
|
53
|
+
"""Draw 2D structure colored by class-specific SHAP attribution.
|
|
54
|
+
|
|
55
|
+
Green regions drive the active prediction, red regions drive inactive.
|
|
56
|
+
Intensity is proportional to SHAP magnitude. Neutral atoms are uncolored.
|
|
57
|
+
|
|
58
|
+
Requires molfp: pip install molfp
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
smiles: SMILES string of the molecule to visualize.
|
|
62
|
+
interpret_result: dict returned by molfp.interpret().
|
|
63
|
+
class_active: name of the active class in interpret_result.
|
|
64
|
+
class_inactive: name of the inactive class in interpret_result.
|
|
65
|
+
size: (width, height) in pixels.
|
|
66
|
+
alpha: transparency of the heatmap overlay.
|
|
67
|
+
style: "highlight" (solid atom/bond circles) or "contour" (gaussian contour map).
|
|
68
|
+
save: file path to save the image. If None, image is not saved.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
PIL.Image.Image
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
from molfp.fingerprints import FINGERPRINTS
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"molfp is required for draw_attribution(). "
|
|
78
|
+
"Install it with: pip install molfp"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
mol = ensure_mol(smiles)
|
|
82
|
+
AllChem.Compute2DCoords(mol)
|
|
83
|
+
fp_type = interpret_result["fp_type"]
|
|
84
|
+
model = interpret_result["model"]
|
|
85
|
+
label_set = interpret_result["label_set"]
|
|
86
|
+
|
|
87
|
+
fn = FINGERPRINTS[fp_type][0]
|
|
88
|
+
fp = np.array([fn(smiles)], dtype=np.int32)
|
|
89
|
+
|
|
90
|
+
active_idx = label_set.index(class_active) if class_active in label_set else 0
|
|
91
|
+
inactive_idx = label_set.index(class_inactive) if class_inactive in label_set else -1
|
|
92
|
+
|
|
93
|
+
import shap
|
|
94
|
+
explainer = shap.TreeExplainer(model)
|
|
95
|
+
sv = explainer.shap_values(fp)
|
|
96
|
+
|
|
97
|
+
if isinstance(sv, np.ndarray) and sv.ndim == 3:
|
|
98
|
+
mol_shap_active = sv[0, :, active_idx]
|
|
99
|
+
mol_shap_inactive = sv[0, :, inactive_idx] if inactive_idx >= 0 else -sv[0, :, active_idx]
|
|
100
|
+
elif isinstance(sv, list):
|
|
101
|
+
mol_shap_active = sv[active_idx][0]
|
|
102
|
+
mol_shap_inactive = sv[inactive_idx][0] if inactive_idx >= 0 else -sv[active_idx][0]
|
|
103
|
+
else:
|
|
104
|
+
mol_shap_active = sv[0]
|
|
105
|
+
mol_shap_inactive = -sv[0]
|
|
106
|
+
|
|
107
|
+
fp_vec = fn(smiles)
|
|
108
|
+
on_bits = [i for i, v in enumerate(fp_vec) if v > 0]
|
|
109
|
+
|
|
110
|
+
active_bit_shap = {b: float(mol_shap_active[b]) for b in on_bits}
|
|
111
|
+
inactive_bit_shap = {b: float(mol_shap_inactive[b]) for b in on_bits}
|
|
112
|
+
|
|
113
|
+
active_weights = _bit_shap_to_atom_weights(mol, smiles, fp_type, active_bit_shap)
|
|
114
|
+
inactive_weights = _bit_shap_to_atom_weights(mol, smiles, fp_type, inactive_bit_shap)
|
|
115
|
+
|
|
116
|
+
# ── Contour style ─────────────────────────────────────────────────────
|
|
117
|
+
def _render_contour(weights, cmap):
|
|
118
|
+
import matplotlib
|
|
119
|
+
matplotlib.use("Agg")
|
|
120
|
+
import matplotlib.pyplot as plt
|
|
121
|
+
from rdkit.Chem.Draw import SimilarityMaps, rdMolDraw2D
|
|
122
|
+
from PIL import Image as PILImage
|
|
123
|
+
from io import BytesIO
|
|
124
|
+
|
|
125
|
+
max_w = max(abs(weights.max()), abs(weights.min()), 1e-8)
|
|
126
|
+
normalized = list(weights / max_w)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
d2d = rdMolDraw2D.MolDraw2DCairo(size[0], size[1])
|
|
130
|
+
except Exception:
|
|
131
|
+
d2d = rdMolDraw2D.MolDraw2DSVG(size[0], size[1])
|
|
132
|
+
d2d.drawOptions().bondLineWidth = 2.0
|
|
133
|
+
|
|
134
|
+
result_d2d = SimilarityMaps.GetSimilarityMapFromWeights(
|
|
135
|
+
mol, normalized, colorMap=cmap, alpha=alpha, size=size,
|
|
136
|
+
draw2d=d2d, contourLines=10
|
|
137
|
+
)
|
|
138
|
+
result_d2d.FinishDrawing()
|
|
139
|
+
png = result_d2d.GetDrawingText()
|
|
140
|
+
return PILImage.open(BytesIO(png)).copy()
|
|
141
|
+
|
|
142
|
+
# ── Highlight style ───────────────────────────────────────────────────
|
|
143
|
+
def _render_highlight(weights, color_pos, color_neg):
|
|
144
|
+
from rdkit.Chem.Draw import rdMolDraw2D
|
|
145
|
+
from PIL import Image as PILImage
|
|
146
|
+
from io import BytesIO
|
|
147
|
+
|
|
148
|
+
max_w = max(abs(weights.max()), abs(weights.min()), 1e-8)
|
|
149
|
+
norm = weights / max_w
|
|
150
|
+
|
|
151
|
+
def _color_for_weight(w):
|
|
152
|
+
if abs(w) < 0.05:
|
|
153
|
+
return None
|
|
154
|
+
color = color_pos if w > 0 else color_neg
|
|
155
|
+
intensity = min(abs(w), 1.0)
|
|
156
|
+
r = color[0] + (1 - color[0]) * (1 - intensity)
|
|
157
|
+
g = color[1] + (1 - color[1]) * (1 - intensity)
|
|
158
|
+
b = color[2] + (1 - color[2]) * (1 - intensity)
|
|
159
|
+
return (r, g, b)
|
|
160
|
+
|
|
161
|
+
highlight_atoms = []
|
|
162
|
+
highlight_colors = {}
|
|
163
|
+
for i, w in enumerate(norm):
|
|
164
|
+
c = _color_for_weight(w)
|
|
165
|
+
if c is not None:
|
|
166
|
+
highlight_atoms.append(i)
|
|
167
|
+
highlight_colors[i] = c
|
|
168
|
+
|
|
169
|
+
highlight_bonds = []
|
|
170
|
+
highlight_bond_colors = {}
|
|
171
|
+
highlight_atom_set = set(highlight_atoms)
|
|
172
|
+
for bond in mol.GetBonds():
|
|
173
|
+
a1 = bond.GetBeginAtomIdx()
|
|
174
|
+
a2 = bond.GetEndAtomIdx()
|
|
175
|
+
if a1 in highlight_atom_set or a2 in highlight_atom_set:
|
|
176
|
+
bond_idx = bond.GetIdx()
|
|
177
|
+
highlight_bonds.append(bond_idx)
|
|
178
|
+
avg_w = (norm[a1] + norm[a2]) / 2
|
|
179
|
+
c = _color_for_weight(avg_w)
|
|
180
|
+
if c is None:
|
|
181
|
+
c = (0.95, 0.95, 0.95)
|
|
182
|
+
highlight_bond_colors[bond_idx] = c
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
drawer = rdMolDraw2D.MolDraw2DCairo(size[0], size[1])
|
|
186
|
+
except Exception:
|
|
187
|
+
drawer = rdMolDraw2D.MolDraw2DSVG(size[0], size[1])
|
|
188
|
+
drawer.drawOptions().clearBackground = True
|
|
189
|
+
|
|
190
|
+
highlight_radii = {i: 0.4 for i in highlight_atoms}
|
|
191
|
+
drawer.DrawMolecule(
|
|
192
|
+
mol,
|
|
193
|
+
highlightAtoms=highlight_atoms,
|
|
194
|
+
highlightAtomColors=highlight_colors,
|
|
195
|
+
highlightAtomRadii=highlight_radii,
|
|
196
|
+
highlightBonds=highlight_bonds,
|
|
197
|
+
highlightBondColors=highlight_bond_colors,
|
|
198
|
+
)
|
|
199
|
+
drawer.FinishDrawing()
|
|
200
|
+
png = drawer.GetDrawingText()
|
|
201
|
+
return PILImage.open(BytesIO(png)).copy()
|
|
202
|
+
|
|
203
|
+
green = (0.13, 0.55, 0.13)
|
|
204
|
+
red = (0.8, 0.1, 0.1)
|
|
205
|
+
|
|
206
|
+
active_pos = np.clip(active_weights, 0, None)
|
|
207
|
+
inactive_pos = np.clip(-inactive_weights, 0, None)
|
|
208
|
+
dual_weights = active_pos - inactive_pos
|
|
209
|
+
|
|
210
|
+
if style == "contour":
|
|
211
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
212
|
+
cmap_dual = LinearSegmentedColormap.from_list("dual", [red, (1, 1, 1), green])
|
|
213
|
+
img = _render_contour(dual_weights, cmap_dual)
|
|
214
|
+
elif style == "highlight":
|
|
215
|
+
img = _render_highlight(dual_weights, green, red)
|
|
216
|
+
else:
|
|
217
|
+
raise ValueError(f"Unknown style: {style}. Use 'highlight' or 'contour'.")
|
|
218
|
+
|
|
219
|
+
if save:
|
|
220
|
+
img.save(save)
|
|
221
|
+
return img
|
molviewer/draw2d.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
2D molecular drawing for Jupyter, Colab, and Streamlit.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
from rdkit import Chem
|
|
10
|
+
from rdkit.Chem import Draw
|
|
11
|
+
|
|
12
|
+
from ._mol import ensure_mol, extract_smiles
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def draw(input_data, *, highlight_bits=None, fp_type=None, legends=None,
|
|
16
|
+
size=(300, 300), mols_per_row=3, column=None):
|
|
17
|
+
"""Draw 2D molecular structure(s).
|
|
18
|
+
|
|
19
|
+
Works in Jupyter, Colab, and Streamlit (pass returned image to st.image()).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
input_data: SMILES string, list of SMILES, RDKit Mol, pd.Series, or pd.DataFrame.
|
|
23
|
+
highlight_bits: list of fingerprint bit indices to highlight (requires fp_type and molfp).
|
|
24
|
+
fp_type: fingerprint type for bit highlighting (e.g. "ecfp4", "maccs").
|
|
25
|
+
legends: list of labels for each molecule in grid mode.
|
|
26
|
+
size: (width, height) per molecule in pixels.
|
|
27
|
+
mols_per_row: molecules per row in grid mode.
|
|
28
|
+
column: column name for DataFrame input.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
PIL.Image.Image
|
|
32
|
+
"""
|
|
33
|
+
data, is_batch = extract_smiles(input_data, column=column)
|
|
34
|
+
|
|
35
|
+
if not is_batch:
|
|
36
|
+
mol = ensure_mol(data)
|
|
37
|
+
highlight_atoms = _get_highlight_atoms(mol, highlight_bits, fp_type)
|
|
38
|
+
img = Draw.MolToImage(mol, size=size, highlightAtoms=highlight_atoms)
|
|
39
|
+
return img
|
|
40
|
+
|
|
41
|
+
smiles_list = data
|
|
42
|
+
mols = []
|
|
43
|
+
for s in smiles_list:
|
|
44
|
+
try:
|
|
45
|
+
mols.append(ensure_mol(s))
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
mols.append(None)
|
|
48
|
+
|
|
49
|
+
if legends is None:
|
|
50
|
+
legends = [s if isinstance(s, str) else "" for s in smiles_list]
|
|
51
|
+
|
|
52
|
+
if highlight_bits and fp_type:
|
|
53
|
+
highlight_atom_lists = []
|
|
54
|
+
for mol in mols:
|
|
55
|
+
if mol is not None:
|
|
56
|
+
highlight_atom_lists.append(
|
|
57
|
+
_get_highlight_atoms(mol, highlight_bits, fp_type)
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
highlight_atom_lists.append([])
|
|
61
|
+
else:
|
|
62
|
+
highlight_atom_lists = None
|
|
63
|
+
|
|
64
|
+
img = Draw.MolsToGridImage(
|
|
65
|
+
mols,
|
|
66
|
+
molsPerRow=mols_per_row,
|
|
67
|
+
subImgSize=size,
|
|
68
|
+
legends=legends,
|
|
69
|
+
highlightAtomLists=highlight_atom_lists,
|
|
70
|
+
)
|
|
71
|
+
return img
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_highlight_atoms(mol, highlight_bits, fp_type):
|
|
75
|
+
"""Get atom indices to highlight for given fingerprint bits."""
|
|
76
|
+
if not highlight_bits or not fp_type:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
from molfp.explain import explain
|
|
81
|
+
except ImportError:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
smiles = Chem.MolToSmiles(mol)
|
|
85
|
+
bit_info = explain(smiles, fp_type)
|
|
86
|
+
|
|
87
|
+
atoms = set()
|
|
88
|
+
for bit in highlight_bits:
|
|
89
|
+
if bit in bit_info:
|
|
90
|
+
for atom_info in bit_info[bit]:
|
|
91
|
+
if isinstance(atom_info, (list, tuple)):
|
|
92
|
+
for a in atom_info:
|
|
93
|
+
if isinstance(a, int):
|
|
94
|
+
atoms.add(a)
|
|
95
|
+
elif isinstance(atom_info, int):
|
|
96
|
+
atoms.add(atom_info)
|
|
97
|
+
return list(atoms)
|
molviewer/view3d.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
3D molecular viewing for Jupyter, Colab, and Streamlit.
|
|
3
|
+
|
|
4
|
+
Supports py3Dmol styles (stick, sphere, line, cartoon, ball_and_stick)
|
|
5
|
+
and Speck ambient-occlusion renderer with presets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from rdkit import Chem
|
|
13
|
+
from rdkit.Chem import AllChem
|
|
14
|
+
|
|
15
|
+
from ._mol import ensure_mol, ensure_conformer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _detect_env():
|
|
19
|
+
"""Detect runtime environment: 'jupyter', 'streamlit', or 'script'."""
|
|
20
|
+
if "streamlit" in sys.modules:
|
|
21
|
+
return "streamlit"
|
|
22
|
+
try:
|
|
23
|
+
shell = get_ipython().__class__.__name__
|
|
24
|
+
if shell in ("ZMQInteractiveShell", "Shell"):
|
|
25
|
+
return "jupyter"
|
|
26
|
+
except NameError:
|
|
27
|
+
pass
|
|
28
|
+
return "script"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def view3d(input_data, *, style="stick", size=(600, 400), spin=False,
|
|
32
|
+
background="white", preset=None,
|
|
33
|
+
atom_scale=None, bond_scale=None, ao=None, brightness=None,
|
|
34
|
+
outline=None, bonds=None):
|
|
35
|
+
"""Interactive 3D molecular viewer.
|
|
36
|
+
|
|
37
|
+
Auto-detects environment:
|
|
38
|
+
- Jupyter/Colab: renders inline interactive widget
|
|
39
|
+
- Streamlit: renders via st.components.v1.html()
|
|
40
|
+
- Script: returns HTML string (py3Dmol) or widget (speck)
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
input_data: SMILES string or RDKit Mol.
|
|
44
|
+
style: py3Dmol styles ("stick", "sphere", "line", "cartoon", "ball_and_stick")
|
|
45
|
+
or "speck" for the Speck ambient-occlusion renderer.
|
|
46
|
+
size: (width, height) in pixels.
|
|
47
|
+
spin: if True, auto-rotate the molecule.
|
|
48
|
+
background: background color (default "white", py3Dmol only).
|
|
49
|
+
preset: Speck preset. molviewer presets: "ball_and_stick" (default look),
|
|
50
|
+
"toon" (flat shading + outlines), "licorice" (stick model),
|
|
51
|
+
"houkmol" (matte + outlines, Houk group style approximation).
|
|
52
|
+
ipyspeck presets: "default", "matte", "glossy", "cover",
|
|
53
|
+
"metal", "glass", "goodsell".
|
|
54
|
+
atom_scale: Speck atom radius (0.0-1.0).
|
|
55
|
+
bond_scale: Speck bond radius (0.0-1.0).
|
|
56
|
+
ao: Speck ambient occlusion strength (0.0-1.0).
|
|
57
|
+
brightness: Speck brightness (0.0-1.0).
|
|
58
|
+
outline: Speck depth-aware outline strength (0.0-1.0).
|
|
59
|
+
bonds: Speck show bonds (True/False).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
In script mode: HTML string (py3Dmol) or Speck widget.
|
|
63
|
+
In notebook/Streamlit: displays inline.
|
|
64
|
+
"""
|
|
65
|
+
if style == "speck":
|
|
66
|
+
return _view3d_speck(input_data, size=size, spin=spin, preset=preset,
|
|
67
|
+
atom_scale=atom_scale, bond_scale=bond_scale,
|
|
68
|
+
ao=ao, brightness=brightness, outline=outline,
|
|
69
|
+
bonds=bonds)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
import py3Dmol
|
|
73
|
+
except ImportError:
|
|
74
|
+
raise ImportError(
|
|
75
|
+
"py3Dmol is required for 3D viewing. "
|
|
76
|
+
"Install it with: pip install py3Dmol"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
mol = ensure_mol(input_data)
|
|
80
|
+
mol = ensure_conformer(mol)
|
|
81
|
+
mol_block = Chem.MolToMolBlock(mol)
|
|
82
|
+
|
|
83
|
+
viewer = py3Dmol.view(width=size[0], height=size[1])
|
|
84
|
+
viewer.addModel(mol_block, "mol")
|
|
85
|
+
|
|
86
|
+
style_spec = _build_style(style)
|
|
87
|
+
viewer.setStyle(style_spec)
|
|
88
|
+
viewer.setBackgroundColor(background)
|
|
89
|
+
if spin:
|
|
90
|
+
viewer.spin(True)
|
|
91
|
+
viewer.zoomTo()
|
|
92
|
+
|
|
93
|
+
env = _detect_env()
|
|
94
|
+
|
|
95
|
+
if env == "jupyter":
|
|
96
|
+
return viewer.show()
|
|
97
|
+
elif env == "streamlit":
|
|
98
|
+
import streamlit.components.v1 as components
|
|
99
|
+
html = viewer._make_html()
|
|
100
|
+
components.html(html, height=size[1] + 20, width=size[0] + 20)
|
|
101
|
+
else:
|
|
102
|
+
return viewer._make_html()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Speck presets ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
_MOLVIEWER_PRESETS = {
|
|
108
|
+
"ball_and_stick": {
|
|
109
|
+
"atomScale": 0.24, "relativeAtomScale": 0.64, "atomShade": 0.5,
|
|
110
|
+
"bonds": True, "bondScale": 0.5, "bondShade": 0.5,
|
|
111
|
+
"ao": 0.75, "brightness": 0.5, "outline": 0.0,
|
|
112
|
+
"dofStrength": 0.0,
|
|
113
|
+
},
|
|
114
|
+
"toon": {
|
|
115
|
+
"atomScale": 0.24, "relativeAtomScale": 0.64, "atomShade": 0.5,
|
|
116
|
+
"bonds": True, "bondScale": 0.5, "bondShade": 0.5,
|
|
117
|
+
"ao": 0.0, "brightness": 0.5, "outline": 1.0,
|
|
118
|
+
"dofStrength": 0.0,
|
|
119
|
+
},
|
|
120
|
+
"licorice": {
|
|
121
|
+
"atomScale": 0.1, "relativeAtomScale": 0.0, "atomShade": 0.5,
|
|
122
|
+
"bonds": True, "bondScale": 1.0, "bondShade": 0.5,
|
|
123
|
+
"ao": 0.75, "brightness": 0.5, "outline": 0.0,
|
|
124
|
+
"dofStrength": 0.0,
|
|
125
|
+
},
|
|
126
|
+
"houkmol": {
|
|
127
|
+
"atomScale": 0.24, "relativeAtomScale": 0.64, "atomShade": 0.5,
|
|
128
|
+
"bonds": True, "bondScale": 0.5, "bondShade": 0.1,
|
|
129
|
+
"ao": 0.5, "brightness": 0.65, "outline": 1.0,
|
|
130
|
+
"outlineColor": "#000000", "outlineWidth": 2.5,
|
|
131
|
+
"outlineMode": "depth",
|
|
132
|
+
"gloss": 0.0, "metallic": 0.0, "specular": 0.1,
|
|
133
|
+
"saturation": 0.7, "dofStrength": 0.0, "fog": 0.0,
|
|
134
|
+
"fxaa": 1, "shadows": 0.0, "rim": 0.0,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _view3d_speck(input_data, *, size=(600, 400), spin=False, preset=None,
|
|
140
|
+
atom_scale=None, bond_scale=None, ao=None, brightness=None,
|
|
141
|
+
outline=None, bonds=None):
|
|
142
|
+
"""Render molecule with the Speck ambient-occlusion renderer."""
|
|
143
|
+
try:
|
|
144
|
+
from ipyspeck import Speck
|
|
145
|
+
except ImportError:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"ipyspeck is required for style='speck'. "
|
|
148
|
+
"Install it with: pip install ipyspeck"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
mol = ensure_mol(input_data)
|
|
152
|
+
mol = Chem.AddHs(mol)
|
|
153
|
+
mol = ensure_conformer(mol)
|
|
154
|
+
|
|
155
|
+
w = Speck.from_rdkit(mol)
|
|
156
|
+
w.layout.width = f"{size[0]}px"
|
|
157
|
+
w.layout.height = f"{size[1]}px"
|
|
158
|
+
|
|
159
|
+
# Apply preset (default to ball_and_stick if none specified)
|
|
160
|
+
active_preset = preset if preset else "ball_and_stick"
|
|
161
|
+
if active_preset in _MOLVIEWER_PRESETS:
|
|
162
|
+
for key, value in _MOLVIEWER_PRESETS[active_preset].items():
|
|
163
|
+
setattr(w, key, value)
|
|
164
|
+
else:
|
|
165
|
+
try:
|
|
166
|
+
w.apply_preset(active_preset)
|
|
167
|
+
except (ValueError, Exception):
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
# Apply user-specified parameters (override preset/defaults)
|
|
171
|
+
if atom_scale is not None:
|
|
172
|
+
w.atomScale = atom_scale
|
|
173
|
+
if bond_scale is not None:
|
|
174
|
+
w.bondScale = bond_scale
|
|
175
|
+
if ao is not None:
|
|
176
|
+
w.ao = ao
|
|
177
|
+
if brightness is not None:
|
|
178
|
+
w.brightness = brightness
|
|
179
|
+
if outline is not None:
|
|
180
|
+
w.outline = outline
|
|
181
|
+
if bonds is not None:
|
|
182
|
+
w.bonds = bonds
|
|
183
|
+
if spin:
|
|
184
|
+
w.autoRotate = True
|
|
185
|
+
|
|
186
|
+
env = _detect_env()
|
|
187
|
+
if env == "streamlit":
|
|
188
|
+
try:
|
|
189
|
+
import stspeck
|
|
190
|
+
mol_block = Chem.MolToMolBlock(mol)
|
|
191
|
+
kwargs = {"data": mol_block}
|
|
192
|
+
if preset:
|
|
193
|
+
kwargs["preset"] = preset
|
|
194
|
+
stspeck.speck(**kwargs, height=size[1])
|
|
195
|
+
return
|
|
196
|
+
except ImportError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
return w
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_style(style):
|
|
203
|
+
"""Convert style name to py3Dmol style dict."""
|
|
204
|
+
styles = {
|
|
205
|
+
"stick": {"stick": {}},
|
|
206
|
+
"sphere": {"sphere": {"radius": 0.4}},
|
|
207
|
+
"line": {"line": {}},
|
|
208
|
+
"cartoon": {"cartoon": {}},
|
|
209
|
+
"ball_and_stick": {"stick": {}, "sphere": {"radius": 0.3}},
|
|
210
|
+
}
|
|
211
|
+
if style in styles:
|
|
212
|
+
return styles[style]
|
|
213
|
+
return {"stick": {}}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: molviewer
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 2D and 3D molecular visualization for Jupyter, Colab, and Streamlit
|
|
5
|
+
Author: Chanin Nantasenamat
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dataprofessor/molviewer
|
|
8
|
+
Project-URL: Repository, https://github.com/dataprofessor/molviewer
|
|
9
|
+
Keywords: cheminformatics,molecular-visualization,drug-discovery,RDKit,py3Dmol,speck,jupyter,streamlit
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.22
|
|
25
|
+
Requires-Dist: rdkit>=2023.03
|
|
26
|
+
Requires-Dist: py3Dmol>=2.0
|
|
27
|
+
Requires-Dist: ipyspeck>=0.8
|
|
28
|
+
Requires-Dist: molfp>=0.3.4
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# molviewer
|
|
34
|
+
|
|
35
|
+
**2D and 3D molecular visualization for Jupyter, Colab, and Streamlit.**
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install molviewer
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import molviewer
|
|
47
|
+
|
|
48
|
+
# 2D drawing
|
|
49
|
+
molviewer.draw("CC(=O)Oc1ccccc1C(=O)O")
|
|
50
|
+
molviewer.draw(["CCO", "c1ccccc1"], legends=["Ethanol", "Benzene"])
|
|
51
|
+
|
|
52
|
+
# 3D viewing — py3Dmol
|
|
53
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="stick")
|
|
54
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="ball_and_stick", spin=True)
|
|
55
|
+
|
|
56
|
+
# 3D viewing — Speck (ambient occlusion)
|
|
57
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="speck")
|
|
58
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="speck", preset="houkmol")
|
|
59
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="speck", preset="toon")
|
|
60
|
+
molviewer.view3d("CC(=O)Oc1ccccc1C(=O)O", style="speck", preset="licorice")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Speck Presets
|
|
64
|
+
|
|
65
|
+
| Preset | Description |
|
|
66
|
+
|--------|-------------|
|
|
67
|
+
| `ball_and_stick` | Default — atoms + bonds with ambient occlusion |
|
|
68
|
+
| `toon` | Flat shading with depth-aware outlines |
|
|
69
|
+
| `licorice` | Thick bonds, small atoms |
|
|
70
|
+
| `houkmol` | Houk group style — matte, outlines, bright atoms |
|
|
71
|
+
| `metal` | Metallic material (ipyspeck) |
|
|
72
|
+
| `glass` | Transparent glass (ipyspeck) |
|
|
73
|
+
| `glossy` | Shiny surface (ipyspeck) |
|
|
74
|
+
| `goodsell` | David Goodsell style (ipyspeck) |
|
|
75
|
+
|
|
76
|
+
## Custom Parameters
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
molviewer.view3d("CCO", style="speck", atom_scale=0.6, ao=0.9, outline=1.0)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Parameters: `atom_scale`, `bond_scale`, `ao`, `brightness`, `outline`, `bonds`.
|
|
83
|
+
|
|
84
|
+
## Attribution Maps
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import molfp
|
|
88
|
+
import molviewer
|
|
89
|
+
|
|
90
|
+
result = molfp.interpret(smiles, activities, fp_type="maccs")
|
|
91
|
+
molviewer.draw_attribution("CC(=O)Oc1ccccc1C(=O)O", result, style="highlight", save="attr.png")
|
|
92
|
+
molviewer.draw_attribution("CC(=O)Oc1ccccc1C(=O)O", result, style="contour", save="attr.png")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
molviewer/__init__.py,sha256=uBGS6VbzptfTKb4EpUm_gQB_Y3hKfly-9YyKlA_o_uY,288
|
|
2
|
+
molviewer/_mol.py,sha256=-m4-43T8EmoroyY8GmwygVfPWmOgs1PqZG55crZ-npk,5148
|
|
3
|
+
molviewer/attribution.py,sha256=zWjTes1y40lCWiQsmZoiKtH5bCy8TLT6iyIRz3rq9Yk,8325
|
|
4
|
+
molviewer/draw2d.py,sha256=U2x-4L6lK_JhTZFPry0GgPm2lABGMWOSr2qHjeuarsU,2945
|
|
5
|
+
molviewer/view3d.py,sha256=2iz17y3Y335dQw1LkisO6ph_6PS_S4x2nLrfNoUIyK4,7212
|
|
6
|
+
molviewer-0.1.1.dist-info/licenses/LICENSE,sha256=ZeG6sH00neNTW8T5LmF3zovcUKtvxT0DPnAxr6eaajc,1076
|
|
7
|
+
molviewer-0.1.1.dist-info/METADATA,sha256=MyzN5mnRMvEKWtOjoIBpbqT_TyPqidapIeXpUtvXT2k,3076
|
|
8
|
+
molviewer-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
molviewer-0.1.1.dist-info/top_level.txt,sha256=q2i-26f56uC9AYWtrJqrhPbw4FWIOHKZbnMK6-l4vG4,10
|
|
10
|
+
molviewer-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chanin Nantasenamat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
molviewer
|