vimol 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {vimol-0.1.1/src/vimol.egg-info → vimol-0.1.2}/PKG-INFO +7 -2
  2. {vimol-0.1.1 → vimol-0.1.2}/README.md +5 -1
  3. {vimol-0.1.1 → vimol-0.1.2}/pyproject.toml +5 -1
  4. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/__init__.py +1 -1
  5. vimol-0.1.2/src/vimol/_render_fast.py +418 -0
  6. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/app.py +20 -0
  7. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/editor.py +37 -28
  8. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/elements.py +23 -0
  9. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/gl_adapter.py +30 -5
  10. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/gl_render.py +107 -55
  11. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/input.py +18 -1
  12. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/kitty.py +226 -30
  13. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/xyz.py +1 -1
  14. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/render.py +206 -63
  15. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/scene.py +69 -13
  16. vimol-0.1.2/src/vimol/structures.py +398 -0
  17. vimol-0.1.2/src/vimol/theme.py +142 -0
  18. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/viewer.py +997 -73
  19. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/widget.py +151 -27
  20. {vimol-0.1.1 → vimol-0.1.2/src/vimol.egg-info}/PKG-INFO +7 -2
  21. {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/SOURCES.txt +6 -0
  22. {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/requires.txt +1 -0
  23. {vimol-0.1.1 → vimol-0.1.2}/tests/test_editing.py +283 -14
  24. {vimol-0.1.1 → vimol-0.1.2}/tests/test_gl_render.py +196 -0
  25. vimol-0.1.2/tests/test_render_fast.py +208 -0
  26. vimol-0.1.2/tests/test_scene_structures.py +357 -0
  27. vimol-0.1.2/tests/test_structures.py +421 -0
  28. vimol-0.1.2/tests/test_vimol.py +2632 -0
  29. vimol-0.1.1/tests/test_vimol.py +0 -552
  30. {vimol-0.1.1 → vimol-0.1.2}/LICENSE +0 -0
  31. {vimol-0.1.1 → vimol-0.1.2}/setup.cfg +0 -0
  32. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/__main__.py +0 -0
  33. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/arrows.py +0 -0
  34. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/bonds.py +0 -0
  35. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/camera.py +0 -0
  36. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/molecule.py +0 -0
  37. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/__init__.py +0 -0
  38. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/mol.py +0 -0
  39. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/parsers/pdb.py +0 -0
  40. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/periodic_table.py +0 -0
  41. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/py.typed +0 -0
  42. {vimol-0.1.1 → vimol-0.1.2}/src/vimol/templates.py +0 -0
  43. {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/dependency_links.txt +0 -0
  44. {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/entry_points.txt +0 -0
  45. {vimol-0.1.1 → vimol-0.1.2}/src/vimol.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vimol
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A terminal molecular viewer and editor using the Kitty graphics protocol
5
5
  Author: vimol contributors
6
6
  License-Expression: MIT
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20
29
29
  Requires-Dist: moderngl>=5.6
30
+ Requires-Dist: numba>=0.59
30
31
  Provides-Extra: test
31
32
  Requires-Dist: pytest>=7; extra == "test"
32
33
  Dynamic: license-file
@@ -54,7 +55,11 @@ available. MIT licensed.
54
55
  ![C60 spinning in the terminal, rendered by vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/spin.gif)
55
56
 
56
57
  `vimol` with no file opens this bundled C60 demo. Drag to rotate, scroll to
57
- zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals.
58
+ zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
59
+ — with more than one structure loaded, a live column tracks it in a
60
+ comparison table next to the structure list, evaluated for every loaded
61
+ structure at once; move on to a different measurement (or a different frame)
62
+ and it locks in as its own column (click a column's `×` to remove it).
58
63
  Editing is on by default: `a` to append (grow fragments, swap elements — the
59
64
  status-bar pills pick the element and geometry), option-drag to draw bonds,
60
65
  `x` to delete, `c` to relax clashes, `u` to undo, `s` to save. `?` lists
@@ -21,7 +21,11 @@ available. MIT licensed.
21
21
  ![C60 spinning in the terminal, rendered by vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/spin.gif)
22
22
 
23
23
  `vimol` with no file opens this bundled C60 demo. Drag to rotate, scroll to
24
- zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals.
24
+ zoom, hover an atom to identify it, `m` to measure distances/angles/dihedrals
25
+ — with more than one structure loaded, a live column tracks it in a
26
+ comparison table next to the structure list, evaluated for every loaded
27
+ structure at once; move on to a different measurement (or a different frame)
28
+ and it locks in as its own column (click a column's `×` to remove it).
25
29
  Editing is on by default: `a` to append (grow fragments, swap elements — the
26
30
  status-bar pills pick the element and geometry), option-drag to draw bonds,
27
31
  `x` to delete, `c` to relax clashes, `u` to undo, `s` to save. `?` lists
@@ -37,7 +37,11 @@ classifiers = [
37
37
  # moderngl is a hard dependency (~120 KB of wheels): the GL backend is always
38
38
  # available, and `backend="auto"` still falls back to the CPU raycaster at
39
39
  # runtime wherever no OpenGL context can be created (headless boxes, clusters).
40
- dependencies = ["numpy>=1.20", "moderngl>=5.6"]
40
+ # numba compiles that CPU raycaster's inner loop (_render_fast) to machine
41
+ # code: ~10-25x faster CPU rendering, bit-identical output. render.py still
42
+ # tolerates numba being uninstalled/uncompilable (falls back to the pure-numpy
43
+ # path) since it's a best-effort JIT, not because it's meant to be omitted.
44
+ dependencies = ["numpy>=1.20", "moderngl>=5.6", "numba>=0.59"]
41
45
 
42
46
  [project.optional-dependencies]
43
47
  test = ["pytest>=7"]
@@ -33,7 +33,7 @@ from . import elements
33
33
  from . import templates
34
34
  from . import editor
35
35
 
36
- __version__ = "0.1.1"
36
+ __version__ = "0.1.2"
37
37
 
38
38
  __all__ = [
39
39
  "Molecule", "VectorField", "Camera", "Renderer", "Style", "Scene", "MoleculeWidget",
@@ -0,0 +1,418 @@
1
+ """Numba-compiled inner loop for the CPU raycaster.
2
+
3
+ The numpy renderer (``render.py``) vectorizes per primitive over its
4
+ screen-space bounding box: every sphere/cylinder costs ~40 full-box array
5
+ passes, most of them over pixels that fail the z-test, plus thousands of
6
+ per-call overheads per frame. That is a structural ceiling -- measured
7
+ ~130 ms/frame for C60 at 2560x1440.
8
+
9
+ This module compiles the same impostor math to machine code (per-pixel loops
10
+ with early z-rejection *before* shading, the way PyMOL's C++ ray tracer and
11
+ every other compiled rasterizer do it), which measures 10-25x faster. numba
12
+ is a hard dependency (see pyproject.toml), but this module still degrades
13
+ gracefully if it's ever missing or fails to compile on some platform:
14
+ ``ready()`` stays False and ``render.py``'s numpy path runs unchanged.
15
+
16
+ BIT-PARITY CONTRACT: the kernels below mirror render.py's numpy operations
17
+ one-for-one -- same dtypes (float32 per-pixel math, float64 screen setup),
18
+ same operation order and association, same scalar casts (numpy converts a
19
+ python-float scalar to float32 when it meets a float32 array; typed numpy
20
+ float64 scalars promote instead -- both cases are reproduced explicitly).
21
+ ``tests/test_render_fast.py`` verifies the output is bit-identical to the
22
+ numpy path -- run it after touching either side of the contract.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import threading
28
+
29
+ import numpy as np
30
+
31
+ # numba's disk cache (cache=True below) defaults to __pycache__ next to this
32
+ # source file -- churn a sync client (Dropbox) then uploads on every
33
+ # recompile. Redirect it to the user cache dir before numba is first
34
+ # imported (the env var is read at numba import; if some other package
35
+ # imported numba already, this is a harmless no-op and the default location
36
+ # applies). setdefault, so an explicit NUMBA_CACHE_DIR always wins.
37
+ os.environ.setdefault(
38
+ "NUMBA_CACHE_DIR",
39
+ os.path.join(os.path.expanduser("~"), ".cache", "vimol", "numba"))
40
+
41
+ try:
42
+ from numba import njit, prange
43
+ _HAVE_NUMBA = True
44
+ except Exception: # hard dependency, but degrade gracefully
45
+ _HAVE_NUMBA = False
46
+
47
+ def njit(*args, **kwargs): # stub so module import never fails
48
+ def wrap(fn):
49
+ return fn
50
+ return wrap if not (args and callable(args[0])) else args[0]
51
+
52
+ def prange(*args):
53
+ return range(*args)
54
+
55
+
56
+ # 0 = cold, 1 = compiling, 2 = ready, 3 = unavailable/failed
57
+ _state = 0
58
+ _lock = threading.Lock()
59
+ # Set once the one compile attempt (by whichever caller -- warm_async's
60
+ # thread or a direct warm_sync() caller -- got there first) finishes, success
61
+ # or failure. Callers that lost the race to start it wait on this instead of
62
+ # calling the compile routine themselves: numba's dispatcher is not safe to
63
+ # enter from two threads at once for the same not-yet-compiled function --
64
+ # observed reliably (SIGABRT/SIGSEGV) when a second, unsynchronized caller
65
+ # invoked the compile while warm_async's background thread was mid-compile.
66
+ _done = threading.Event()
67
+
68
+
69
+ def available() -> bool:
70
+ """numba importable on this machine (kernel may still be compiling)."""
71
+ return _HAVE_NUMBA
72
+
73
+
74
+ def ready() -> bool:
75
+ """Kernel compiled and safe to call (first call triggers compilation)."""
76
+ return _state == 2
77
+
78
+
79
+ def warm_async() -> None:
80
+ """Compile the kernel in a daemon thread so the first frames don't stall.
81
+
82
+ JIT compilation takes ~1-3 s once per process (cached on disk afterwards
83
+ where the package dir is writable). Frames rendered while this runs use
84
+ the numpy path; the renderer switches over the moment ready() flips.
85
+ """
86
+ if not _HAVE_NUMBA:
87
+ return
88
+ if not _claim():
89
+ return
90
+ threading.Thread(target=_run_once, daemon=True).start()
91
+
92
+
93
+ def warm_sync(timeout: float | None = None) -> bool:
94
+ """Force the kernel to be ready (or known unavailable) before returning.
95
+
96
+ If nobody has started the compile yet, this thread does it (blocking).
97
+ If warm_async's background thread already claimed it, this waits for
98
+ that thread to finish instead of compiling again -- see the note on
99
+ ``_done`` above for why a second concurrent attempt is unsafe. Returns
100
+ ``ready()``. Used by callers (tests, the benchmark scripts) that need a
101
+ definite answer and can't rely on warm_async's fire-and-forget timing.
102
+ """
103
+ if not _HAVE_NUMBA:
104
+ return False
105
+ if _claim():
106
+ _run_once()
107
+ else:
108
+ _done.wait(timeout)
109
+ return ready()
110
+
111
+
112
+ def _claim() -> bool:
113
+ """Atomically move state 0 -> 1. True for the one caller that must
114
+ actually run the compile; False for everyone else (already compiling,
115
+ ready, or failed)."""
116
+ global _state
117
+ with _lock:
118
+ if _state != 0:
119
+ return False
120
+ _state = 1
121
+ return True
122
+
123
+
124
+ def _run_once() -> None:
125
+ global _state
126
+ try:
127
+ # (8, 8, 4) rgba: (H, W, 3) and (H, W, 4) C-contiguous buffers share
128
+ # one numba type (uint8 3d C), so this single call compiles the only
129
+ # signature both opaque and transparent frames use.
130
+ color = np.zeros((8, 8, 4), np.uint8)
131
+ zbuf = np.full((8, 8), -np.inf, np.float32)
132
+ bands = np.array([[0, 8]], np.int64)
133
+ sx = np.array([4.0], np.float64)
134
+ sy = np.array([4.0], np.float64)
135
+ sz = np.array([0.0], np.float64)
136
+ radii = np.array([0.5], np.float64)
137
+ acol = np.array([[0.5, 0.5, 0.5]], np.float32)
138
+ aflat = np.zeros(1, np.uint8)
139
+ order = np.array([0], np.int64)
140
+ barr = np.zeros((0, 14), np.float64)
141
+ bcol = np.zeros((0, 6), np.float32)
142
+ bflat = np.zeros(0, np.uint8)
143
+ render_frame(color, zbuf, True, bands,
144
+ sx, sy, sz, radii, acol, aflat, order,
145
+ barr, bcol, bflat,
146
+ 10.0, 4.0, 4.0,
147
+ 0.3, 0.5, 0.7, -0.3, -0.5, 0.6, 0.4, 0.5, 0.7,
148
+ 0.28, 0.25, 0.55, 0.55, -1.0, 2.0, 5)
149
+ _state = 2
150
+ except Exception:
151
+ _state = 3
152
+ finally:
153
+ _done.set()
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # compiled kernels
158
+ # ---------------------------------------------------------------------------
159
+ @njit(inline="always")
160
+ def _shade_pixel(color, y, x, alpha_on, depth, nx, ny, nz,
161
+ ar, ag, ab, flat,
162
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
163
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
164
+ """Shade one winning fragment and write color/alpha/depth.
165
+
166
+ ``color`` is (H, W, 3) opaque or (H, W, 4) rgba; ``alpha_on`` means the
167
+ 4th channel exists and coverage is written there. Mirrors
168
+ render.shade_write's per-element op sequence (see module docstring).
169
+ nx/ny/nz/ar..ab/depth are float32; the light scalars are float64
170
+ python-side values that numpy would have cast to float32 at each use --
171
+ the casts here make that explicit.
172
+ """
173
+ if flat:
174
+ if depth_cue > 0.0:
175
+ fog = (depth - np.float32(zmin)) * np.float32(1.0 / zspan)
176
+ if fog < np.float32(0.0):
177
+ fog = np.float32(0.0)
178
+ if fog > np.float32(1.0):
179
+ fog = np.float32(1.0)
180
+ fog *= np.float32(depth_cue)
181
+ fog += np.float32(1.0 - depth_cue)
182
+ rr = ar * fog
183
+ rr *= np.float32(255.0)
184
+ gg = ag * fog
185
+ gg *= np.float32(255.0)
186
+ bb = ab * fog
187
+ bb *= np.float32(255.0)
188
+ else:
189
+ rr = min(ar, np.float32(1.0)) * np.float32(255.0)
190
+ gg = min(ag, np.float32(1.0)) * np.float32(255.0)
191
+ bb = min(ab, np.float32(1.0)) * np.float32(255.0)
192
+ else:
193
+ diff = nx * np.float32(l0) + ny * np.float32(l1) + nz * np.float32(l2)
194
+ if diff < np.float32(0.0):
195
+ diff = np.float32(0.0)
196
+ dfill = nx * np.float32(f0) + ny * np.float32(f1) + nz * np.float32(f2)
197
+ if dfill < np.float32(0.0):
198
+ dfill = np.float32(0.0)
199
+ dh = nx * np.float32(hv0) + ny * np.float32(hv1) + nz * np.float32(hv2)
200
+ if dh < np.float32(0.0):
201
+ dh = np.float32(0.0)
202
+ # _fast_pow for a power-of-two exponent: x*x then repeated squaring,
203
+ # identical rounding sequence to the numpy version.
204
+ spec = dh
205
+ for _ in range(n_squares):
206
+ spec *= spec
207
+ spec *= np.float32(spec_w)
208
+ diff += np.float32(ambient)
209
+ dfill *= np.float32(fill_w)
210
+ diff += dfill
211
+ if depth_cue > 0.0:
212
+ fog = (depth - np.float32(zmin)) * np.float32(1.0 / zspan)
213
+ if fog < np.float32(0.0):
214
+ fog = np.float32(0.0)
215
+ if fog > np.float32(1.0):
216
+ fog = np.float32(1.0)
217
+ fog *= np.float32(depth_cue)
218
+ fog += np.float32(1.0 - depth_cue)
219
+ diff *= fog
220
+ spec *= fog
221
+ rr = ar * diff
222
+ rr += spec
223
+ gg = ag * diff
224
+ gg += spec
225
+ bb = ab * diff
226
+ bb += spec
227
+ if rr > np.float32(1.0):
228
+ rr = np.float32(1.0)
229
+ if gg > np.float32(1.0):
230
+ gg = np.float32(1.0)
231
+ if bb > np.float32(1.0):
232
+ bb = np.float32(1.0)
233
+ rr *= np.float32(255.0)
234
+ gg *= np.float32(255.0)
235
+ bb *= np.float32(255.0)
236
+ # np.copyto(..., casting="unsafe") / setitem float->uint8 truncates.
237
+ color[y, x, 0] = np.uint8(rr)
238
+ color[y, x, 1] = np.uint8(gg)
239
+ color[y, x, 2] = np.uint8(bb)
240
+ if alpha_on:
241
+ color[y, x, 3] = 255
242
+ return
243
+
244
+
245
+ @njit(parallel=True, cache=True)
246
+ def _render_kernel(color, zbuf, alpha_on, bands,
247
+ sx, sy, sz, radii, acol, aflat, order,
248
+ barr, bcol, bflat,
249
+ zoom, ox_s, oy_s,
250
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
251
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
252
+ """Raycast all bonds (list order) then all atoms (far-to-near order)
253
+ into their bands. Bands own disjoint rows, so prange is race-free; the
254
+ z-test makes per-pixel results order-independent up to the same ties the
255
+ numpy path resolves identically (same primitive order per band)."""
256
+ H, W = zbuf.shape
257
+ inv_zoom = 1.0 / zoom
258
+ iz32 = np.float32(1.0 / zoom)
259
+ niz32 = np.float32(-1.0 / zoom)
260
+ izz32 = np.float32(inv_zoom * inv_zoom)
261
+ n_bonds = barr.shape[0]
262
+
263
+ for band in prange(bands.shape[0]):
264
+ y_lo = bands[band, 0]
265
+ y_hi = bands[band, 1]
266
+
267
+ # -- bonds: capped-cylinder impostors (render._draw_cylinder_segment)
268
+ for k in range(n_bonds):
269
+ ax = barr[k, 0]
270
+ ay = barr[k, 1]
271
+ az = barr[k, 2]
272
+ ux = barr[k, 3]
273
+ uy = barr[k, 4]
274
+ uz = barr[k, 5]
275
+ L = barr[k, 6]
276
+ a2 = barr[k, 7]
277
+ radius = barr[k, 8]
278
+ sax = barr[k, 9]
279
+ say = barr[k, 10]
280
+ sbx = barr[k, 11]
281
+ sby = barr[k, 12]
282
+ srb = barr[k, 13]
283
+ if L < 1e-6 or radius <= 0.0:
284
+ continue
285
+ x0 = max(int(np.floor(min(sax, sbx) - srb)), 0)
286
+ x1 = min(int(np.ceil(max(sax, sbx) + srb)) + 1, W)
287
+ y0 = max(int(np.floor(min(say, sby) - srb)), y_lo)
288
+ y1 = min(int(np.ceil(max(say, sby) + srb)) + 1, y_hi)
289
+ if x0 >= x1 or y0 >= y1:
290
+ continue
291
+ ux32 = np.float32(ux)
292
+ uy32 = np.float32(uy)
293
+ uz32 = np.float32(uz)
294
+ b2c32 = np.float32(-2.0 * uz)
295
+ a2x4_32 = np.float32(a2 * 4.0)
296
+ half_a2_32 = np.float32(0.5 / a2) if abs(a2) >= 1e-9 else np.float32(0.0)
297
+ parallel = abs(a2) < 1e-9
298
+ inv_r32 = np.float32(1.0 / radius)
299
+ r2_32 = np.float32(radius * radius)
300
+ half_L32 = np.float32(0.5 * L)
301
+ L32 = np.float32(L)
302
+ az32 = np.float32(az)
303
+ ey_off32 = np.float32(oy_s / zoom - ay)
304
+ ox32 = np.float32(ox_s)
305
+ ax32 = np.float32(ax)
306
+ flat = bflat[k]
307
+ car, cag, cab = bcol[k, 0], bcol[k, 1], bcol[k, 2]
308
+ cbr, cbg, cbb = bcol[k, 3], bcol[k, 4], bcol[k, 5]
309
+ for y in range(y0, y1):
310
+ ey = np.float32(y) * niz32 + ey_off32
311
+ eyey = ey * ey
312
+ eyuy = ey * uy32
313
+ for x in range(x0, x1):
314
+ ex = (np.float32(x) - ox32) * iz32 - ax32
315
+ A0 = ex * ux32 + eyuy
316
+ c2 = ex * ex + eyey - A0 * A0 - r2_32
317
+ if parallel:
318
+ if c2 > np.float32(0.0):
319
+ continue
320
+ s = np.float32(0.0)
321
+ else:
322
+ b2 = A0 * b2c32
323
+ disc = b2 * b2 - a2x4_32 * c2
324
+ if disc < np.float32(0.0):
325
+ continue
326
+ sq = np.sqrt(disc)
327
+ s = (sq - b2) * half_a2_32
328
+ zview = s + az32
329
+ t = A0 + s * uz32
330
+ if t < np.float32(0.0) or t > L32:
331
+ continue
332
+ if zview <= zbuf[y, x]:
333
+ continue
334
+ nx = (ex - t * ux32) * inv_r32
335
+ ny = (ey - t * uy32) * inv_r32
336
+ nz = (s - t * uz32) * inv_r32
337
+ if t < half_L32:
338
+ ar, ag, ab = car, cag, cab
339
+ else:
340
+ ar, ag, ab = cbr, cbg, cbb
341
+ zbuf[y, x] = zview
342
+ _shade_pixel(color, y, x, alpha_on, zview, nx, ny, nz,
343
+ ar, ag, ab, flat,
344
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
345
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan,
346
+ n_squares)
347
+
348
+ # -- atoms: sphere impostors (render.draw_band's atom loop)
349
+ for oi in range(order.shape[0]):
350
+ a = order[oi]
351
+ r = radii[a]
352
+ if r <= 0.0:
353
+ continue
354
+ sr = r * zoom
355
+ if sr < 0.5:
356
+ sr = 0.5
357
+ cx = sx[a]
358
+ cy = sy[a]
359
+ cz = sz[a]
360
+ x0 = max(int(np.floor(cx - sr)), 0)
361
+ x1 = min(int(np.ceil(cx + sr)) + 1, W)
362
+ y0 = max(int(np.floor(cy - sr)), y_lo)
363
+ y1 = min(int(np.ceil(cy + sr)) + 1, y_hi)
364
+ if x0 >= x1 or y0 >= y1:
365
+ continue
366
+ cx32 = np.float32(cx)
367
+ cy32 = np.float32(cy)
368
+ cz32 = np.float32(cz)
369
+ sr2 = sr * sr # float64: d2 <= sr2 promotes
370
+ rr32 = np.float32(r * r)
371
+ inv_r = 1.0 / r
372
+ nxy32 = np.float32(inv_zoom * inv_r)
373
+ nz32 = np.float32(inv_r)
374
+ flat = aflat[a]
375
+ ar = acol[a, 0]
376
+ ag = acol[a, 1]
377
+ ab = acol[a, 2]
378
+ for y in range(y0, y1):
379
+ dyr = np.float32(y) - cy32
380
+ dyr2 = dyr * dyr
381
+ ny = dyr * np.float32(-inv_zoom * inv_r)
382
+ for x in range(x0, x1):
383
+ dxr = np.float32(x) - cx32
384
+ d2 = dxr * dxr + dyr2
385
+ # numpy compares float32 d2 against the float64 scalar
386
+ # sr*sr (typed numpy scalar promotes); mirror exactly.
387
+ if not (d2 <= sr2):
388
+ continue
389
+ h2 = rr32 - d2 * izz32
390
+ if h2 < np.float32(0.0):
391
+ h2 = np.float32(0.0)
392
+ hgt = np.sqrt(h2)
393
+ depth = hgt + cz32
394
+ if depth <= zbuf[y, x]:
395
+ continue
396
+ nx = dxr * nxy32
397
+ nzv = hgt * nz32
398
+ zbuf[y, x] = depth
399
+ _shade_pixel(color, y, x, alpha_on, depth,
400
+ nx, ny, nzv, ar, ag, ab, flat,
401
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
402
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan,
403
+ n_squares)
404
+
405
+
406
+ def render_frame(color, zbuf, alpha_on, bands,
407
+ sx, sy, sz, radii, acol, aflat, order,
408
+ barr, bcol, bflat,
409
+ zoom, ox_s, oy_s,
410
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
411
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares):
412
+ """Thin wrapper so _run_once and render.py share one call signature."""
413
+ _render_kernel(color, zbuf, alpha_on, bands,
414
+ sx, sy, sz, radii, acol, aflat, order,
415
+ barr, bcol, bflat,
416
+ zoom, ox_s, oy_s,
417
+ l0, l1, l2, f0, f1, f2, hv0, hv1, hv2,
418
+ ambient, fill_w, spec_w, depth_cue, zmin, zspan, n_squares)
@@ -71,6 +71,8 @@ def make_parser() -> argparse.ArgumentParser:
71
71
  p.add_argument("--backend", default="auto", choices=["auto", "cpu", "gl"],
72
72
  help="rendering backend: numpy CPU raycaster, GPU (OpenGL), "
73
73
  "or auto (GPU if a context can be created, else CPU)")
74
+ p.add_argument("--theme", default="auto", choices=["auto", "dark", "light"],
75
+ help="chrome color theme; auto detects the terminal's own background")
74
76
  p.add_argument("--size", default="0x0", help="pixel size WxH for --render/--kitty (0=auto)")
75
77
  p.add_argument("--supersample", type=int, default=2, help="anti-aliasing factor for stills")
76
78
  p.add_argument("--rotate", nargs=2, type=float, metavar=("YAW", "PITCH"),
@@ -93,6 +95,23 @@ def make_parser() -> argparse.ArgumentParser:
93
95
  return p
94
96
 
95
97
 
98
+ def _apply_theme_arg(args) -> None:
99
+ """--theme is the highest-precedence source in theme.resolve()'s ladder
100
+ (see docs/design/theme-and-aesthetics.md sec 2) -- implemented by setting
101
+ VIMOL_THEME before the Viewer is constructed, rather than adding a
102
+ parallel parameter to Viewer.__init__ for what VIMOL_THEME already
103
+ covers.
104
+
105
+ "auto" is the DEFAULT, i.e. "the user passed no --theme at all", so it
106
+ must leave VIMOL_THEME alone rather than clearing it -- clearing would
107
+ mean the env var only ever worked for direct Viewer() construction and
108
+ never for the CLI, silently deleting the ladder's second rung on every
109
+ plain `vimol file.xyz`.
110
+ """
111
+ if args.theme != "auto":
112
+ os.environ["VIMOL_THEME"] = args.theme
113
+
114
+
96
115
  def _print_info(mol: Molecule):
97
116
  print(f"name: {mol.name}")
98
117
  print(f"formula: {mol.formula()}")
@@ -142,6 +161,7 @@ def _default_demo_path() -> Optional[str]:
142
161
 
143
162
  def main(argv: List[str] | None = None) -> int:
144
163
  args = make_parser().parse_args(argv)
164
+ _apply_theme_arg(args)
145
165
 
146
166
  if args.version:
147
167
  from . import __version__
@@ -99,43 +99,28 @@ def add_manual_bond(mol: Molecule, i: int, j: int, order: int = 1) -> bool:
99
99
  return True
100
100
 
101
101
 
102
- def measurement(mol: Molecule, sel: Sequence[int]) -> str:
103
- """Render a live distance/angle/dihedral readout for an ordered pick list.
104
-
105
- Pure and side-effect-free (no terminal, no widget state) so it is
106
- testable on its own; the widget only owns *which* atoms are selected
107
- (``measure_sel``), this just formats what they mean:
108
-
109
- * < 2 atoms -> "" (nothing to report yet).
110
- * 2 atoms -> euclidean distance, 3 decimals.
111
- * 3 atoms -> the angle at the middle atom between it and the other two,
112
- 1 decimal.
113
- * 4 atoms -> the standard (signed) dihedral about the middle bond -- the
114
- angle between the (i, j, k) and (j, k, l) planes, 1 decimal. The sign
115
- convention is whatever ``atan2`` gives; callers that care about an
116
- absolute value should take one themselves.
117
-
118
- Extra picks beyond the first four are ignored (the widget itself never
119
- lets the live selection grow past 4 -- see ``MoleculeWidget._measure_click``
120
- -- but this stays defensive rather than raising on a longer sequence).
121
- Indices are rendered with the same ``#idx`` convention as the hover
122
- readout (:meth:`MoleculeWidget.atom_info`).
102
+ def measure_value(mol: Molecule, sel: Sequence[int]) -> Optional[float]:
103
+ """The numeric core of :func:`measurement`: euclidean distance (Å, 2
104
+ atoms), angle at the middle atom (degrees, 3 atoms), or the standard
105
+ signed dihedral about the middle bond (degrees, 4 atoms). ``None`` for
106
+ fewer than 2 picks. Extra picks beyond the first four are ignored, same
107
+ defensiveness as :func:`measurement`. Pure and side-effect-free, so it is
108
+ reusable both for the live status-bar readout and for evaluating a
109
+ pinned measurement against other structures (``StructureSet.measure``).
123
110
  """
124
111
  n = len(sel)
125
112
  if n < 2:
126
- return ""
113
+ return None
127
114
  pos = mol.positions
128
115
  if n == 2:
129
116
  i, j = sel[0], sel[1]
130
- d = float(np.linalg.norm(pos[i] - pos[j]))
131
- return f"d(#{i}–#{j}) = {d:.3f} Å"
117
+ return float(np.linalg.norm(pos[i] - pos[j]))
132
118
  if n == 3:
133
119
  i, j, k = sel[0], sel[1], sel[2]
134
120
  v1 = pos[i] - pos[j]
135
121
  v2 = pos[k] - pos[j]
136
122
  cos_a = float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)))
137
- angle = np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0)))
138
- return f"∠(#{i}–#{j}–#{k}) = {angle:.1f}°"
123
+ return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0))))
139
124
  i, j, k, l = sel[0], sel[1], sel[2], sel[3]
140
125
  b1 = pos[j] - pos[i]
141
126
  b2 = pos[k] - pos[j]
@@ -145,8 +130,32 @@ def measurement(mol: Molecule, sel: Sequence[int]) -> str:
145
130
  m1 = np.cross(n1, b2 / np.linalg.norm(b2))
146
131
  x = float(np.dot(n1, n2))
147
132
  y = float(np.dot(m1, n2))
148
- phi = np.degrees(np.arctan2(y, x))
149
- return f"φ(#{i}–#{j}–#{k}–#{l}) = {phi:.1f}°"
133
+ return float(np.degrees(np.arctan2(y, x)))
134
+
135
+
136
+ def measurement(mol: Molecule, sel: Sequence[int]) -> str:
137
+ """Render a live distance/angle/dihedral readout for an ordered pick list.
138
+
139
+ Pure and side-effect-free (no terminal, no widget state) so it is
140
+ testable on its own; the widget only owns *which* atoms are selected
141
+ (``measure_sel``), this just formats what they mean -- see
142
+ :func:`measure_value` for the numbers themselves.
143
+
144
+ Indices are rendered with the same ``#idx`` convention as the hover
145
+ readout (:meth:`MoleculeWidget.atom_info`).
146
+ """
147
+ n = len(sel)
148
+ if n < 2:
149
+ return ""
150
+ value = measure_value(mol, sel)
151
+ if n == 2:
152
+ i, j = sel[0], sel[1]
153
+ return f"d(#{i}–#{j}) = {value:.3f} Å"
154
+ if n == 3:
155
+ i, j, k = sel[0], sel[1], sel[2]
156
+ return f"∠(#{i}–#{j}–#{k}) = {value:.1f}°"
157
+ i, j, k, l = sel[0], sel[1], sel[2], sel[3]
158
+ return f"φ(#{i}–#{j}–#{k}–#{l}) = {value:.1f}°"
150
159
 
151
160
 
152
161
  def _cap(mol: Molecule, center_idx: int, center: np.ndarray, dirs: np.ndarray,
@@ -92,6 +92,14 @@ _COLORS = {
92
92
 
93
93
  _DEFAULT_COLOR = (1.00, 0.41, 0.71) # hot pink for unknown elements
94
94
 
95
+ # Light-mode overrides for CPK entries that don't survive a light/white
96
+ # terminal background -- only the near-white ones move; everything else in
97
+ # _COLORS is saturated enough to read on either background.
98
+ _LIGHT_OVERRIDES = {
99
+ "H": (0.55, 0.55, 0.58), "He": (0.45, 0.75, 0.78), "Ag": (0.45, 0.45, 0.45),
100
+ "Pt": (0.45, 0.45, 0.55), "Hg": (0.40, 0.40, 0.55),
101
+ }
102
+
95
103
 
96
104
  def normalize_symbol(sym: str) -> str:
97
105
  """Normalize an element token to canonical capitalization (e.g. 'FE' -> 'Fe')."""
@@ -117,6 +125,21 @@ def element_color(sym: str) -> tuple:
117
125
  return _COLORS.get(normalize_symbol(sym), _DEFAULT_COLOR)
118
126
 
119
127
 
128
+ def themed_base_colors(symbols, base_colors, theme: str):
129
+ """*base_colors* (N,3), with _LIGHT_OVERRIDES entries substituted where
130
+ *symbols* names one and *theme* is "light". Returns *base_colors*
131
+ unchanged (same array, no copy) for "dark" -- callers rely on this to
132
+ stay byte-identical to pre-theme rendering when the theme is dark."""
133
+ if theme != "light":
134
+ return base_colors
135
+ out = base_colors.copy()
136
+ for i, sym in enumerate(symbols):
137
+ override = _LIGHT_OVERRIDES.get(normalize_symbol(sym))
138
+ if override is not None:
139
+ out[i] = override
140
+ return out
141
+
142
+
120
143
  def element_name(sym: str) -> str:
121
144
  """Full element name, e.g. 'Carbon' for 'C'. Falls back to the symbol itself."""
122
145
  z = symbol_to_z(sym)