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/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]