vimol 0.1.0__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.
- vimol/__init__.py +63 -0
- vimol/__main__.py +4 -0
- vimol/app.py +218 -0
- vimol/arrows.py +91 -0
- vimol/bonds.py +79 -0
- vimol/camera.py +77 -0
- vimol/editor.py +646 -0
- vimol/elements.py +123 -0
- vimol/gl_adapter.py +114 -0
- vimol/gl_render.py +742 -0
- vimol/input.py +252 -0
- vimol/kitty.py +297 -0
- vimol/molecule.py +159 -0
- vimol/parsers/__init__.py +88 -0
- vimol/parsers/mol.py +57 -0
- vimol/parsers/pdb.py +97 -0
- vimol/parsers/xyz.py +68 -0
- vimol/periodic_table.py +97 -0
- vimol/py.typed +0 -0
- vimol/render.py +600 -0
- vimol/scene.py +239 -0
- vimol/templates.py +205 -0
- vimol/viewer.py +1159 -0
- vimol/widget.py +611 -0
- vimol-0.1.0.dist-info/METADATA +302 -0
- vimol-0.1.0.dist-info/RECORD +30 -0
- vimol-0.1.0.dist-info/WHEEL +5 -0
- vimol-0.1.0.dist-info/entry_points.txt +2 -0
- vimol-0.1.0.dist-info/licenses/LICENSE +20 -0
- vimol-0.1.0.dist-info/top_level.txt +1 -0
vimol/render.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Software raycaster: renders a molecule to an RGB pixel buffer with numpy.
|
|
2
|
+
|
|
3
|
+
The technique is *impostor rendering* under an orthographic camera. Each atom
|
|
4
|
+
is a sphere and each bond a cylinder; instead of tessellating them into
|
|
5
|
+
triangles we solve the ray/surface intersection analytically per pixel over the
|
|
6
|
+
primitive's screen-space bounding box. A shared z-buffer resolves occlusion, so
|
|
7
|
+
spheres and cylinders interpenetrate correctly. Everything is vectorized over
|
|
8
|
+
the pixels of each primitive, which keeps interactive frame-rates for
|
|
9
|
+
small/medium molecules in pure Python + numpy.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .arrows import ArrowGeometry, build_arrow_geometry
|
|
20
|
+
from .camera import Camera
|
|
21
|
+
from .molecule import Molecule
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Style:
|
|
26
|
+
"""Rendering options."""
|
|
27
|
+
representation: str = "ball_and_stick" # ball_and_stick | spacefill | wireframe | licorice
|
|
28
|
+
atom_scale: float = 0.25 # multiplier on van der Waals radius (ball_and_stick)
|
|
29
|
+
bond_radius: float = 0.10 # angstrom (cylinder radius)
|
|
30
|
+
background: Tuple[float, float, float] = (0.05, 0.06, 0.09)
|
|
31
|
+
transparent: bool = False # emit RGBA with an alpha cutout (terminal shows through)
|
|
32
|
+
ambient: float = 0.28
|
|
33
|
+
specular_strength: float = 0.55
|
|
34
|
+
shininess: float = 32.0
|
|
35
|
+
light_dir: Tuple[float, float, float] = (0.35, 0.55, 0.75)
|
|
36
|
+
fill_light: float = 0.25 # secondary light from opposite side
|
|
37
|
+
depth_cue: float = 0.55 # 0 = off, 1 = strong fog toward the back
|
|
38
|
+
outline: bool = True # subtle silhouette darkening
|
|
39
|
+
color_override: object = None # optional (N,3) array to replace element colors
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# render worker threads: bands of the frame raycast concurrently (numpy
|
|
43
|
+
# releases the GIL inside array ops). Capped: beyond ~8 the bands get too
|
|
44
|
+
# short and per-primitive python overhead starts to dominate.
|
|
45
|
+
_POOL_WORKERS = min(8, os.cpu_count() or 1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _atom_radii(mol: Molecule, style: Style) -> np.ndarray:
|
|
49
|
+
if style.representation == "spacefill":
|
|
50
|
+
return mol.vdw_radii()
|
|
51
|
+
if style.representation in ("wireframe", "licorice"):
|
|
52
|
+
return np.full(mol.n_atoms, style.bond_radius * (1.6 if style.representation == "licorice" else 1.0))
|
|
53
|
+
# ball-and-stick: scale by van der Waals radius so hydrogens stay visible
|
|
54
|
+
# (their covalent radius is tiny, which would hide them inside the bonds).
|
|
55
|
+
return mol.vdw_radii() * style.atom_scale
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fast_pow(x: np.ndarray, exponent: float) -> np.ndarray:
|
|
59
|
+
"""x ** exponent, using repeated squaring for power-of-two exponents.
|
|
60
|
+
|
|
61
|
+
The specular term raises every fragment's n.h to ``style.shininess``
|
|
62
|
+
(default 32). ``np.power`` with a float exponent goes through exp/log per
|
|
63
|
+
element; for the (very common) integral power-of-two case, log2(e) in-place
|
|
64
|
+
multiplies do the same thing several times faster. Falls back to
|
|
65
|
+
``np.power`` for any other exponent.
|
|
66
|
+
"""
|
|
67
|
+
e = int(exponent)
|
|
68
|
+
if e == exponent and e > 0 and (e & (e - 1)) == 0:
|
|
69
|
+
out = x * x # e >= 2 from here (e == 1 is the loop's no-op)
|
|
70
|
+
for _ in range(e.bit_length() - 2):
|
|
71
|
+
np.multiply(out, out, out=out)
|
|
72
|
+
return out if e > 1 else x.copy()
|
|
73
|
+
return np.power(x, exponent)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Renderer:
|
|
77
|
+
def __init__(self, width: int, height: int):
|
|
78
|
+
self.width = int(width)
|
|
79
|
+
self.height = int(height)
|
|
80
|
+
# persistent framebuffers, re-filled per frame -- allocating ~25 MB of
|
|
81
|
+
# fresh pages every frame (np.full/np.empty) costs real time at
|
|
82
|
+
# supersampled full-screen sizes; reuse keeps the pages warm.
|
|
83
|
+
self._color = None
|
|
84
|
+
self._zbuf = None
|
|
85
|
+
self._bg_template = None # pre-filled background frame (see below)
|
|
86
|
+
self._bg_key = None
|
|
87
|
+
self._pool = None # lazy render thread pool (see _threads)
|
|
88
|
+
|
|
89
|
+
def resize(self, width: int, height: int) -> None:
|
|
90
|
+
self.width = int(width)
|
|
91
|
+
self.height = int(height)
|
|
92
|
+
self._color = None
|
|
93
|
+
self._zbuf = None
|
|
94
|
+
self._bg_template = None
|
|
95
|
+
self._bg_key = None
|
|
96
|
+
|
|
97
|
+
def _framebuffers(self, style: Style):
|
|
98
|
+
H, W = self.height, self.width
|
|
99
|
+
if self._color is None or self._color.shape[:2] != (H, W):
|
|
100
|
+
self._color = np.empty((H, W, 3), np.float32)
|
|
101
|
+
self._zbuf = np.empty((H, W), np.float32)
|
|
102
|
+
if style.transparent:
|
|
103
|
+
self._color.fill(0.0) # premultiplied-zero for undrawn pixels
|
|
104
|
+
else:
|
|
105
|
+
# Broadcasting a (3,) background into (H, W, 3) is a strided store
|
|
106
|
+
# that measures >10x slower than a flat memcpy at full-screen sizes
|
|
107
|
+
# (~100 ms vs ~8 ms at 3200x2000) -- so keep one pre-filled frame
|
|
108
|
+
# per background color and copy it in whole.
|
|
109
|
+
key = tuple(style.background)
|
|
110
|
+
if self._bg_key != key or self._bg_template is None \
|
|
111
|
+
or self._bg_template.shape[:2] != (H, W):
|
|
112
|
+
self._bg_template = np.empty((H, W, 3), np.float32)
|
|
113
|
+
self._bg_template[...] = np.asarray(style.background, np.float32)
|
|
114
|
+
self._bg_key = key
|
|
115
|
+
np.copyto(self._color, self._bg_template)
|
|
116
|
+
self._zbuf.fill(-np.inf)
|
|
117
|
+
return self._color, self._zbuf
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
def render(self, mol: Molecule, camera: Camera, style: Style | None = None) -> np.ndarray:
|
|
121
|
+
style = style or Style()
|
|
122
|
+
W, H = self.width, self.height
|
|
123
|
+
transparent = style.transparent
|
|
124
|
+
color, zbuf = self._framebuffers(style)
|
|
125
|
+
if mol.n_atoms == 0:
|
|
126
|
+
return self._finish(color, zbuf, transparent)
|
|
127
|
+
|
|
128
|
+
vpos = camera.view_positions(mol.positions).astype(np.float64) # (N,3) view space
|
|
129
|
+
zoom = camera.zoom
|
|
130
|
+
ox_s = W * 0.5 + camera.pan[0]
|
|
131
|
+
oy_s = H * 0.5 - camera.pan[1]
|
|
132
|
+
# screen coords (y axis flips: view +y is up, screen +y is down)
|
|
133
|
+
sx = ox_s + vpos[:, 0] * zoom
|
|
134
|
+
sy = oy_s - vpos[:, 1] * zoom
|
|
135
|
+
sz = vpos[:, 2] # depth, larger == nearer viewer
|
|
136
|
+
|
|
137
|
+
base_colors = style.color_override if style.color_override is not None else mol.element_colors()
|
|
138
|
+
base_colors = np.asarray(base_colors, np.float32)
|
|
139
|
+
|
|
140
|
+
light = np.array(style.light_dir, np.float64)
|
|
141
|
+
light = light / (np.linalg.norm(light) + 1e-12)
|
|
142
|
+
view_dir = np.array([0.0, 0.0, 1.0])
|
|
143
|
+
halfv = light + view_dir
|
|
144
|
+
halfv = halfv / (np.linalg.norm(halfv) + 1e-12)
|
|
145
|
+
fill = np.array([-light[0], -light[1], 0.6])
|
|
146
|
+
fill = fill / (np.linalg.norm(fill) + 1e-12)
|
|
147
|
+
|
|
148
|
+
# Build arrow geometry up front (reusing vpos instead of recomputing
|
|
149
|
+
# the view transform) so the fog depth range below can include the
|
|
150
|
+
# arrow endpoints -- the GL backend derives its single depth range
|
|
151
|
+
# from atoms + bonds + arrow endpoints, and the fog must match.
|
|
152
|
+
geom = build_arrow_geometry(mol, camera, view_pos=vpos) if mol.vector_fields else None
|
|
153
|
+
has_arrows = geom is not None and geom.shaft_a.shape[0] > 0
|
|
154
|
+
|
|
155
|
+
# depth range for fog
|
|
156
|
+
z_parts = [sz]
|
|
157
|
+
if has_arrows:
|
|
158
|
+
z_parts += [geom.shaft_a[:, 2], geom.shaft_b[:, 2],
|
|
159
|
+
geom.head_base[:, 2], geom.head_apex[:, 2]]
|
|
160
|
+
z_all = np.concatenate([np.asarray(z, np.float64).reshape(-1) for z in z_parts])
|
|
161
|
+
zmin = float(z_all.min()) if z_all.size else 0.0
|
|
162
|
+
zmax = float(z_all.max()) if z_all.size else 1.0
|
|
163
|
+
zspan = max(zmax - zmin, 1e-6)
|
|
164
|
+
|
|
165
|
+
# columns are the three lighting directions, so a single (M,3)@(3,3)
|
|
166
|
+
# matmul yields all three dot products at once (one BLAS call instead
|
|
167
|
+
# of three), which is cheaper per fragment than dotting separately.
|
|
168
|
+
# float32 throughout: fragment lists are large, and halving their
|
|
169
|
+
# width halves the memory traffic of every shading op.
|
|
170
|
+
shade_mat = np.stack([light, fill, halfv], axis=1).astype(np.float32)
|
|
171
|
+
|
|
172
|
+
def shade(normals, albedo):
|
|
173
|
+
# normals: (M,3) view-space unit float32; albedo: (M,3) or (3,)
|
|
174
|
+
# dots of unit vectors never exceed 1, so a lower clamp at 0 is all
|
|
175
|
+
# that's needed -- np.maximum avoids np.clip's per-call overhead.
|
|
176
|
+
d = normals @ shade_mat # (M,3): n·light, n·fill, n·halfv
|
|
177
|
+
np.maximum(d, 0.0, out=d)
|
|
178
|
+
spec = _fast_pow(d[:, 2], style.shininess)
|
|
179
|
+
spec *= style.specular_strength
|
|
180
|
+
diff = (style.ambient + d[:, 0] + d[:, 1] * style.fill_light)[:, None]
|
|
181
|
+
return albedo * diff + spec[:, None]
|
|
182
|
+
|
|
183
|
+
# scalar copies of the light vectors for the full-bbox shading path
|
|
184
|
+
l0, l1, l2 = (float(v) for v in light)
|
|
185
|
+
f0, f1, f2 = (float(v) for v in fill)
|
|
186
|
+
hv0, hv1, hv2 = (float(v) for v in halfv)
|
|
187
|
+
ambient = float(style.ambient)
|
|
188
|
+
fill_w = float(style.fill_light)
|
|
189
|
+
spec_w = float(style.specular_strength)
|
|
190
|
+
shininess = style.shininess
|
|
191
|
+
depth_cue = float(style.depth_cue)
|
|
192
|
+
|
|
193
|
+
def shade_write(sub_c, sub_z, win, depth, nx, ny, nz, albedo):
|
|
194
|
+
"""Shade a full bounding box and masked-write the winners.
|
|
195
|
+
|
|
196
|
+
``nx``/``ny``/``nz`` may be broadcastable pieces ((1,w), (h,1) or
|
|
197
|
+
(h,w)); ``albedo`` a single (3,) color or an (h,w,3) map. Shading
|
|
198
|
+
the whole box contiguously and writing through
|
|
199
|
+
``np.copyto(..., where=win)`` measures ~3x faster than gathering
|
|
200
|
+
the winners with ``np.nonzero`` and fancy-index scattering them
|
|
201
|
+
back -- the scatter was the single hottest operation of a frame,
|
|
202
|
+
far outweighing the extra arithmetic on masked-out pixels. Fog is
|
|
203
|
+
folded into the diffuse/specular terms ((a*d+s)*m == a*(d*m)+s*m),
|
|
204
|
+
saving a separate full-box multiply of the (h,w,3) color.
|
|
205
|
+
"""
|
|
206
|
+
diff = nx * l0 + ny * l1 + nz * l2
|
|
207
|
+
np.maximum(diff, 0.0, out=diff)
|
|
208
|
+
dfill = nx * f0 + ny * f1 + nz * f2
|
|
209
|
+
np.maximum(dfill, 0.0, out=dfill)
|
|
210
|
+
dh = nx * hv0 + ny * hv1 + nz * hv2
|
|
211
|
+
np.maximum(dh, 0.0, out=dh)
|
|
212
|
+
spec = _fast_pow(dh, shininess)
|
|
213
|
+
spec *= spec_w
|
|
214
|
+
diff += ambient
|
|
215
|
+
dfill *= fill_w
|
|
216
|
+
diff += dfill
|
|
217
|
+
if depth_cue > 0:
|
|
218
|
+
fog = (depth - np.float32(zmin)) * np.float32(1.0 / zspan)
|
|
219
|
+
np.maximum(fog, 0.0, out=fog)
|
|
220
|
+
np.minimum(fog, 1.0, out=fog)
|
|
221
|
+
fog *= depth_cue
|
|
222
|
+
fog += 1.0 - depth_cue
|
|
223
|
+
diff *= fog
|
|
224
|
+
spec *= fog
|
|
225
|
+
rgb = albedo * diff[..., None]
|
|
226
|
+
rgb += spec[..., None]
|
|
227
|
+
np.copyto(sub_c, rgb, where=win[..., None])
|
|
228
|
+
np.copyto(sub_z, depth, where=win)
|
|
229
|
+
|
|
230
|
+
radii = _atom_radii(mol, style)
|
|
231
|
+
draw_bonds = style.representation in ("ball_and_stick", "wireframe", "licorice")
|
|
232
|
+
inv_zoom = 1.0 / zoom
|
|
233
|
+
order = np.argsort(sz) # far to near so specular highlights aren't clobbered oddly
|
|
234
|
+
|
|
235
|
+
# Per-primitive screen y-intervals, precomputed once so each band only
|
|
236
|
+
# touches the primitives that actually reach its rows -- the python
|
|
237
|
+
# per-primitive setup is GIL-serialized across bands, so handing every
|
|
238
|
+
# band the whole primitive list would tax exactly the part threads
|
|
239
|
+
# can't parallelize.
|
|
240
|
+
sr_all = np.maximum(radii * zoom, 0.5)
|
|
241
|
+
atom_ylo = sy - sr_all
|
|
242
|
+
atom_yhi = sy + sr_all
|
|
243
|
+
bond_list = list(mol.bonds) if (draw_bonds and mol.bonds) else []
|
|
244
|
+
if bond_list:
|
|
245
|
+
bi = np.array([b[0] for b in bond_list])
|
|
246
|
+
bj = np.array([b[1] for b in bond_list])
|
|
247
|
+
rb = style.bond_radius if style.representation != "licorice" else style.bond_radius * 1.6
|
|
248
|
+
srb = rb * zoom
|
|
249
|
+
bond_ylo = np.minimum(sy[bi], sy[bj]) - srb
|
|
250
|
+
bond_yhi = np.maximum(sy[bi], sy[bj]) + srb
|
|
251
|
+
|
|
252
|
+
def draw_band(y_lo: int, y_hi: int) -> None:
|
|
253
|
+
"""Raycast the band's primitives into rows [y_lo, y_hi).
|
|
254
|
+
|
|
255
|
+
Bands own disjoint row ranges of the shared color/z buffers, so
|
|
256
|
+
they can run on a thread pool with no locking; each band draws
|
|
257
|
+
its primitives in the same global order, so per-pixel results are
|
|
258
|
+
identical to a single full-height pass (the z-test decides every
|
|
259
|
+
pixel independently). numpy releases the GIL inside its array
|
|
260
|
+
ops, which is where nearly all of the time goes.
|
|
261
|
+
"""
|
|
262
|
+
if bond_list:
|
|
263
|
+
hit = np.nonzero((bond_yhi >= y_lo) & (bond_ylo < y_hi))[0]
|
|
264
|
+
for k in hit:
|
|
265
|
+
i, j, _o = bond_list[k]
|
|
266
|
+
self._draw_cylinder_segment(
|
|
267
|
+
vpos[i], vpos[j], rb, base_colors[i], base_colors[j],
|
|
268
|
+
zoom, ox_s, oy_s, style, color, zbuf, shade_write,
|
|
269
|
+
zmin, zspan, y_lo, y_hi)
|
|
270
|
+
|
|
271
|
+
# atoms drawn after bonds; z-buffer keeps whichever is nearer
|
|
272
|
+
in_band = (atom_yhi[order] >= y_lo) & (atom_ylo[order] < y_hi)
|
|
273
|
+
for a in order[in_band]:
|
|
274
|
+
r = radii[a]
|
|
275
|
+
if r <= 0:
|
|
276
|
+
continue
|
|
277
|
+
sr = r * zoom
|
|
278
|
+
if sr < 0.5:
|
|
279
|
+
sr = 0.5
|
|
280
|
+
cx, cy, cz = sx[a], sy[a], sz[a]
|
|
281
|
+
x0 = max(int(np.floor(cx - sr)), 0)
|
|
282
|
+
x1 = min(int(np.ceil(cx + sr)) + 1, W)
|
|
283
|
+
y0 = max(int(np.floor(cy - sr)), y_lo)
|
|
284
|
+
y1 = min(int(np.ceil(cy + sr)) + 1, y_hi)
|
|
285
|
+
if x0 >= x1 or y0 >= y1:
|
|
286
|
+
continue
|
|
287
|
+
# squared screen-space distance to the atom centre, via
|
|
288
|
+
# broadcasting a row and a column (cheaper than materializing
|
|
289
|
+
# a full meshgrid). float32 per-pixel math: python-float
|
|
290
|
+
# scalars don't upcast it, and it halves the bandwidth of
|
|
291
|
+
# every full-bbox pass.
|
|
292
|
+
dxr = np.arange(x0, x1, dtype=np.float32)
|
|
293
|
+
dxr -= np.float32(cx) # (w,)
|
|
294
|
+
dyr = np.arange(y0, y1, dtype=np.float32)
|
|
295
|
+
dyr -= np.float32(cy) # (h,)
|
|
296
|
+
d2 = (dxr * dxr)[None, :] + (dyr * dyr)[:, None] # (h,w)
|
|
297
|
+
mask = d2 <= sr * sr
|
|
298
|
+
if not mask.any():
|
|
299
|
+
continue
|
|
300
|
+
# sphere depth: front-surface height above the view plane
|
|
301
|
+
h2 = float(r * r) - d2 * float(inv_zoom * inv_zoom)
|
|
302
|
+
np.maximum(h2, 0.0, out=h2)
|
|
303
|
+
hgt = np.sqrt(h2) # height above view plane == r * nz
|
|
304
|
+
depth = hgt + np.float32(cz)
|
|
305
|
+
sub_z = zbuf[y0:y1, x0:x1]
|
|
306
|
+
win = mask & (depth > sub_z)
|
|
307
|
+
if not win.any():
|
|
308
|
+
continue
|
|
309
|
+
# normals as broadcastable row/column/full pieces --
|
|
310
|
+
# shade_write combines them without ever materializing an
|
|
311
|
+
# (h, w, 3) stack or gathering/scattering fragment lists.
|
|
312
|
+
inv_r = 1.0 / r
|
|
313
|
+
nx = (dxr * np.float32(inv_zoom * inv_r))[None, :]
|
|
314
|
+
ny = (dyr * np.float32(-inv_zoom * inv_r))[:, None]
|
|
315
|
+
nz = hgt * np.float32(inv_r)
|
|
316
|
+
shade_write(color[y0:y1, x0:x1], sub_z, win, depth,
|
|
317
|
+
nx, ny, nz, base_colors[a])
|
|
318
|
+
|
|
319
|
+
if has_arrows:
|
|
320
|
+
self._draw_arrow_shafts(geom, zoom, ox_s, oy_s, style, color,
|
|
321
|
+
zbuf, shade_write, zmin, zspan, y_lo, y_hi)
|
|
322
|
+
self._draw_arrow_heads(geom, zoom, ox_s, oy_s, style, color,
|
|
323
|
+
zbuf, shade, zmin, zspan, y_lo, y_hi)
|
|
324
|
+
|
|
325
|
+
bands = self._band_ranges(H, W, len(mol.bonds) + mol.n_atoms)
|
|
326
|
+
if len(bands) == 1:
|
|
327
|
+
draw_band(0, H)
|
|
328
|
+
else:
|
|
329
|
+
futures = [self._threads().submit(draw_band, lo, hi) for lo, hi in bands]
|
|
330
|
+
for f in futures:
|
|
331
|
+
f.result()
|
|
332
|
+
|
|
333
|
+
return self._finish(color, zbuf, transparent)
|
|
334
|
+
|
|
335
|
+
def _band_ranges(self, H: int, W: int, n_primitives: int):
|
|
336
|
+
"""Split the frame into horizontal bands for the render thread pool.
|
|
337
|
+
|
|
338
|
+
Threads only pay off when there is real per-band work: small frames
|
|
339
|
+
or near-empty scenes run single-banded (the per-primitive python
|
|
340
|
+
overhead is serialized by the GIL and would dominate).
|
|
341
|
+
"""
|
|
342
|
+
if H * W < 250_000 or n_primitives < 8 or _POOL_WORKERS < 2:
|
|
343
|
+
return [(0, H)]
|
|
344
|
+
n = min(_POOL_WORKERS, max(2, H * W // 200_000))
|
|
345
|
+
edges = np.linspace(0, H, n + 1).astype(int)
|
|
346
|
+
return [(int(edges[i]), int(edges[i + 1])) for i in range(n)
|
|
347
|
+
if edges[i] < edges[i + 1]]
|
|
348
|
+
|
|
349
|
+
def _threads(self):
|
|
350
|
+
if self._pool is None:
|
|
351
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
352
|
+
self._pool = ThreadPoolExecutor(max_workers=_POOL_WORKERS)
|
|
353
|
+
return self._pool
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _finish(color: np.ndarray, zbuf: np.ndarray, transparent: bool) -> np.ndarray:
|
|
357
|
+
# NOTE: the copying np.clip is deliberate -- np.clip(..., out=...) goes
|
|
358
|
+
# through numpy's slow deprecated-casting path (~4x the copying form),
|
|
359
|
+
# and the copy also keeps the renderer's persistent buffer pristine.
|
|
360
|
+
img = np.clip(color, 0.0, 1.0)
|
|
361
|
+
img *= 255.0
|
|
362
|
+
rgb = img.astype(np.uint8)
|
|
363
|
+
if not transparent:
|
|
364
|
+
return rgb
|
|
365
|
+
alpha = (zbuf > -np.inf).astype(np.uint8)
|
|
366
|
+
alpha *= 255
|
|
367
|
+
return np.dstack([rgb, alpha])
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
def _draw_arrow_shafts(self, geom: ArrowGeometry, zoom, ox_s, oy_s, style,
|
|
371
|
+
color, zbuf, shade_write, zmin, zspan, y_lo, y_hi):
|
|
372
|
+
for k in range(geom.shaft_a.shape[0]):
|
|
373
|
+
c = geom.shaft_color[k].astype(np.float32)
|
|
374
|
+
self._draw_cylinder_segment(geom.shaft_a[k], geom.shaft_b[k], float(geom.shaft_radius[k]),
|
|
375
|
+
c, c, zoom, ox_s, oy_s, style, color, zbuf, shade_write, zmin, zspan,
|
|
376
|
+
y_lo, y_hi)
|
|
377
|
+
|
|
378
|
+
def _draw_arrow_heads(self, geom: ArrowGeometry, zoom, ox_s, oy_s, style,
|
|
379
|
+
color, zbuf, shade, zmin, zspan, y_lo, y_hi):
|
|
380
|
+
for k in range(geom.head_base.shape[0]):
|
|
381
|
+
self._draw_cone_segment(geom.head_base[k], geom.head_apex[k], float(geom.head_radius[k]),
|
|
382
|
+
geom.head_color[k], zoom, ox_s, oy_s, style, color, zbuf, shade,
|
|
383
|
+
zmin, zspan, y_lo, y_hi)
|
|
384
|
+
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
def _draw_cylinder_segment(self, a, b, radius, color_a, color_b, zoom, ox_s, oy_s,
|
|
387
|
+
style, color, zbuf, shade_write, zmin, zspan,
|
|
388
|
+
y_lo=0, y_hi=None):
|
|
389
|
+
"""Rasterize one capped-cylinder impostor from view-space *a* to *b*.
|
|
390
|
+
|
|
391
|
+
Shared by real bonds (``_draw_bonds``, two atom colors split at the
|
|
392
|
+
midpoint) and arrow shafts (``_draw_arrow_shafts``, one uniform color).
|
|
393
|
+
"""
|
|
394
|
+
W, H = self.width, self.height
|
|
395
|
+
ax, ay, az = (float(v) for v in a)
|
|
396
|
+
bx, by, bz = (float(v) for v in b)
|
|
397
|
+
ux_, uy_, uz = bx - ax, by - ay, bz - az
|
|
398
|
+
L = (ux_ * ux_ + uy_ * uy_ + uz * uz) ** 0.5
|
|
399
|
+
if L < 1e-6 or radius <= 0:
|
|
400
|
+
return
|
|
401
|
+
ux_, uy_, uz = ux_ / L, uy_ / L, uz / L
|
|
402
|
+
radius = float(radius)
|
|
403
|
+
srb = radius * zoom
|
|
404
|
+
sax, say = ox_s + ax * zoom, oy_s - ay * zoom
|
|
405
|
+
sbx, sby = ox_s + bx * zoom, oy_s - by * zoom
|
|
406
|
+
x0 = max(int(np.floor(min(sax, sbx) - srb)), 0)
|
|
407
|
+
x1 = min(int(np.ceil(max(sax, sbx) + srb)) + 1, W)
|
|
408
|
+
y0 = max(int(np.floor(min(say, sby) - srb)), y_lo)
|
|
409
|
+
y1 = min(int(np.ceil(max(say, sby) + srb)) + 1, H if y_hi is None else y_hi)
|
|
410
|
+
if x0 >= x1 or y0 >= y1:
|
|
411
|
+
return
|
|
412
|
+
# pixel -> view-plane coordinates (angstrom), broadcasting a row/column.
|
|
413
|
+
# All full-bbox math in float32 (python-float scalars don't upcast it);
|
|
414
|
+
# per-fragment quantities (normals, albedo) are materialized only for
|
|
415
|
+
# the winners of the z-test further down, never over the whole box.
|
|
416
|
+
ex = np.arange(x0, x1, dtype=np.float32)
|
|
417
|
+
ex -= np.float32(ox_s)
|
|
418
|
+
ex *= np.float32(1.0 / zoom)
|
|
419
|
+
ex -= np.float32(ax) # (w,) view-plane x offset from a
|
|
420
|
+
ey = np.arange(y0, y1, dtype=np.float32)
|
|
421
|
+
ey *= np.float32(-1.0 / zoom)
|
|
422
|
+
ey += np.float32(oy_s / zoom - ay) # (h,) view-plane y offset from a
|
|
423
|
+
exr = ex[None, :]
|
|
424
|
+
eyc = ey[:, None]
|
|
425
|
+
A0 = exr * np.float32(ux_) + eyc * np.float32(uy_)
|
|
426
|
+
a2 = 1.0 - uz * uz
|
|
427
|
+
b2 = A0 * np.float32(-2.0 * uz)
|
|
428
|
+
c2 = exr * exr + eyc * eyc - A0 * A0 - np.float32(radius * radius)
|
|
429
|
+
if abs(a2) < 1e-9:
|
|
430
|
+
# cylinder axis ~ parallel to view direction: treat as disc
|
|
431
|
+
mask = c2 <= 0 # radius^2 >= ex^2+ey^2-A0^2
|
|
432
|
+
s = np.zeros_like(A0)
|
|
433
|
+
else:
|
|
434
|
+
disc = b2 * b2 - a2 * 4.0 * c2
|
|
435
|
+
mask = disc >= 0
|
|
436
|
+
np.maximum(disc, 0.0, out=disc)
|
|
437
|
+
sq = np.sqrt(disc)
|
|
438
|
+
s = (sq - b2) * np.float32(0.5 / a2) # front (larger z) root
|
|
439
|
+
if not mask.any():
|
|
440
|
+
return
|
|
441
|
+
zview = s + np.float32(az)
|
|
442
|
+
# axial coordinate to clamp to the segment and pick the color half
|
|
443
|
+
t = A0 + s * np.float32(uz) # (P-a).u
|
|
444
|
+
mask &= (t >= 0) & (t <= L)
|
|
445
|
+
if not mask.any():
|
|
446
|
+
return
|
|
447
|
+
sub_z = zbuf[y0:y1, x0:x1]
|
|
448
|
+
win = mask & (zview > sub_z)
|
|
449
|
+
if not win.any():
|
|
450
|
+
return
|
|
451
|
+
# full-bbox normals ((w-(w.u)u)/radius, w = P - a) for shade_write's
|
|
452
|
+
# contiguous shade-and-masked-write path (see its docstring).
|
|
453
|
+
inv_r = np.float32(1.0 / radius)
|
|
454
|
+
nx = (exr - t * np.float32(ux_)) * inv_r
|
|
455
|
+
ny = (eyc - t * np.float32(uy_)) * inv_r
|
|
456
|
+
nz = (s - t * np.float32(uz)) * inv_r
|
|
457
|
+
# split color at midpoint
|
|
458
|
+
albedo = np.where((t < np.float32(0.5 * L))[..., None], color_a, color_b)
|
|
459
|
+
shade_write(color[y0:y1, x0:x1], sub_z, win, zview, nx, ny, nz,
|
|
460
|
+
albedo.astype(np.float32, copy=False))
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
def _draw_cone_segment(self, base, apex, radius, color_rgb, zoom, ox_s, oy_s,
|
|
464
|
+
style, color, zbuf, shade, zmin, zspan,
|
|
465
|
+
y_lo=0, y_hi=None):
|
|
466
|
+
"""Rasterize one cone impostor: *radius* at *base*, tapering linearly
|
|
467
|
+
to a point at *apex* (view space). Used for arrow heads.
|
|
468
|
+
|
|
469
|
+
Same analytic setup as ``_draw_cylinder_segment`` (``A0``, ``a2``,
|
|
470
|
+
``b2`` from the axis/offset), but the target surface radius varies
|
|
471
|
+
linearly along the axis instead of being constant, which folds an
|
|
472
|
+
extra ``(C + D*s)^2`` term into the quadratic. Unlike the cylinder,
|
|
473
|
+
the resulting leading coefficient isn't sign-guaranteed, so both
|
|
474
|
+
roots are evaluated and the nearer-camera (larger ``s``) one that
|
|
475
|
+
satisfies ``0 <= t <= L`` *and* non-negative radius (rejecting the
|
|
476
|
+
mirror nappe beyond the apex that squaring introduces) wins.
|
|
477
|
+
"""
|
|
478
|
+
W, H = self.width, self.height
|
|
479
|
+
ax, ay, az = base
|
|
480
|
+
bx, by, bz = apex
|
|
481
|
+
axis = np.array([bx - ax, by - ay, bz - az])
|
|
482
|
+
L = np.linalg.norm(axis)
|
|
483
|
+
if L < 1e-6 or radius <= 0:
|
|
484
|
+
return
|
|
485
|
+
u = axis / L
|
|
486
|
+
R = radius
|
|
487
|
+
srb = R * zoom
|
|
488
|
+
sax, say = ox_s + ax * zoom, oy_s - ay * zoom
|
|
489
|
+
sbx, sby = ox_s + bx * zoom, oy_s - by * zoom
|
|
490
|
+
x0 = max(int(np.floor(min(sax, sbx) - srb)), 0)
|
|
491
|
+
x1 = min(int(np.ceil(max(sax, sbx) + srb)) + 1, W)
|
|
492
|
+
y0 = max(int(np.floor(min(say, sby) - srb)), y_lo)
|
|
493
|
+
y1 = min(int(np.ceil(max(say, sby) + srb)) + 1, H if y_hi is None else y_hi)
|
|
494
|
+
if x0 >= x1 or y0 >= y1:
|
|
495
|
+
return
|
|
496
|
+
gx = np.arange(x0, x1)[None, :]
|
|
497
|
+
gy = np.arange(y0, y1)[:, None]
|
|
498
|
+
vx = (gx - ox_s) / zoom
|
|
499
|
+
vy = (oy_s - gy) / zoom
|
|
500
|
+
ex = vx - ax
|
|
501
|
+
ey = vy - ay
|
|
502
|
+
uz = u[2]
|
|
503
|
+
A0 = ex * u[0] + ey * u[1]
|
|
504
|
+
a2 = 1.0 - uz * uz
|
|
505
|
+
b2 = -2.0 * A0 * uz
|
|
506
|
+
c2 = ex * ex + ey * ey - A0 * A0 # no -R^2 here (radius varies with t)
|
|
507
|
+
|
|
508
|
+
k = R / L
|
|
509
|
+
C = R - k * A0
|
|
510
|
+
D = -k * uz
|
|
511
|
+
a2p = a2 - D * D
|
|
512
|
+
b2p = b2 - 2.0 * C * D
|
|
513
|
+
c2p = c2 - C * C
|
|
514
|
+
|
|
515
|
+
near_parallel = np.abs(a2p) < 1e-9
|
|
516
|
+
safe_a2p = np.where(near_parallel, 1.0, a2p)
|
|
517
|
+
disc = b2p * b2p - 4.0 * a2p * c2p
|
|
518
|
+
has_root = disc >= 0
|
|
519
|
+
sq = np.sqrt(np.clip(disc, 0, None))
|
|
520
|
+
s_plus = (-b2p + sq) / (2.0 * safe_a2p)
|
|
521
|
+
s_minus = (-b2p - sq) / (2.0 * safe_a2p)
|
|
522
|
+
|
|
523
|
+
safe_b2p = np.where(np.abs(b2p) < 1e-12, 1.0, b2p)
|
|
524
|
+
s_lin = -c2p / safe_b2p
|
|
525
|
+
lin_valid = near_parallel & (np.abs(b2p) >= 1e-12)
|
|
526
|
+
|
|
527
|
+
def _valid(s):
|
|
528
|
+
t = A0 + uz * s
|
|
529
|
+
radius_at = C + D * s
|
|
530
|
+
return (t >= 0) & (t <= L) & (radius_at >= -1e-6)
|
|
531
|
+
|
|
532
|
+
v_plus = has_root & ~near_parallel & _valid(s_plus)
|
|
533
|
+
v_minus = has_root & ~near_parallel & _valid(s_minus)
|
|
534
|
+
v_lin = lin_valid & _valid(s_lin)
|
|
535
|
+
|
|
536
|
+
neg_inf = -1e30
|
|
537
|
+
s = np.maximum(np.maximum(
|
|
538
|
+
np.where(v_plus, s_plus, neg_inf),
|
|
539
|
+
np.where(v_minus, s_minus, neg_inf)),
|
|
540
|
+
np.where(v_lin, s_lin, neg_inf))
|
|
541
|
+
mask = s > neg_inf / 2
|
|
542
|
+
if not mask.any():
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
t = A0 + uz * s
|
|
546
|
+
zview = az + s
|
|
547
|
+
wx = ex - t * u[0]
|
|
548
|
+
wy = ey - t * u[1]
|
|
549
|
+
wz = s - t * u[2]
|
|
550
|
+
r_t = np.clip(C + D * s, 1e-6, None)
|
|
551
|
+
nx = (wx / r_t) * L + u[0] * R
|
|
552
|
+
ny = (wy / r_t) * L + u[1] * R
|
|
553
|
+
nz = (wz / r_t) * L + u[2] * R
|
|
554
|
+
nlen = np.sqrt(nx * nx + ny * ny + nz * nz)
|
|
555
|
+
nlen = np.where(nlen < 1e-9, 1.0, nlen)
|
|
556
|
+
normals = np.stack([nx / nlen, ny / nlen, nz / nlen], axis=-1)
|
|
557
|
+
albedo = np.asarray(color_rgb, np.float32)
|
|
558
|
+
self._composite(color, zbuf, x0, x1, y0, y1, mask, zview.astype(np.float32),
|
|
559
|
+
normals, albedo, shade, style, zmin, zspan)
|
|
560
|
+
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
@staticmethod
|
|
563
|
+
def _composite(color, zbuf, x0, x1, y0, y1, mask, depth, normals, albedo,
|
|
564
|
+
shade, style, zmin, zspan):
|
|
565
|
+
"""Depth-test the primitive's bounding box, then shade only the pixels
|
|
566
|
+
that survive.
|
|
567
|
+
|
|
568
|
+
Shading (dot products, specular power) and fog are the expensive part,
|
|
569
|
+
so they run *after* the visibility test on the surviving 1-D fragment
|
|
570
|
+
list rather than over the whole rectangle -- pixels outside the mask or
|
|
571
|
+
hidden behind a nearer surface are never shaded at all.
|
|
572
|
+
"""
|
|
573
|
+
sub_z = zbuf[y0:y1, x0:x1]
|
|
574
|
+
win = mask & (depth > sub_z)
|
|
575
|
+
if not win.any():
|
|
576
|
+
return
|
|
577
|
+
nrm = normals[win] # (M,3)
|
|
578
|
+
alb = albedo if albedo.ndim == 1 else albedo[win] # (3,) or (M,3)
|
|
579
|
+
rgb = shade(nrm, alb) # (M,3)
|
|
580
|
+
dw = depth[win]
|
|
581
|
+
Renderer._apply_fog(rgb, dw, style, zmin, zspan)
|
|
582
|
+
sub_c = color[y0:y1, x0:x1]
|
|
583
|
+
sub_c[win] = rgb
|
|
584
|
+
sub_z[win] = dw
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def _apply_fog(rgb, depth_win, style, zmin, zspan):
|
|
588
|
+
"""Darken fragments toward the back of the scene, in place.
|
|
589
|
+
|
|
590
|
+
``rgb`` is the (M,3) list of surviving fragment colors; ``depth_win``
|
|
591
|
+
their depths. Uses ``maximum``/``minimum`` ufuncs rather than
|
|
592
|
+
``np.clip`` to avoid the latter's per-call scalar checks, which are a
|
|
593
|
+
measurable cost when invoked once per primitive per frame.
|
|
594
|
+
"""
|
|
595
|
+
if style.depth_cue <= 0:
|
|
596
|
+
return
|
|
597
|
+
f = (depth_win - zmin) / zspan # 0 back .. 1 front
|
|
598
|
+
np.maximum(f, 0.0, out=f)
|
|
599
|
+
np.minimum(f, 1.0, out=f)
|
|
600
|
+
rgb *= (1.0 - style.depth_cue * (1.0 - f))[:, None]
|