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 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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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