matvu 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.
- matvu/__init__.py +45 -0
- matvu/__main__.py +8 -0
- matvu/backends/__init__.py +46 -0
- matvu/backends/base.py +26 -0
- matvu/backends/mpl.py +259 -0
- matvu/backends/tracer.py +434 -0
- matvu/cli.py +313 -0
- matvu/elements.py +162 -0
- matvu/gallery.py +254 -0
- matvu/geometry.py +125 -0
- matvu/io/__init__.py +95 -0
- matvu/io/vasp.py +99 -0
- matvu/palettes.py +104 -0
- matvu/render.py +114 -0
- matvu/scene.py +273 -0
- matvu/structure.py +126 -0
- matvu/style.py +89 -0
- matvu/theme.py +131 -0
- matvu/viewpoint.py +406 -0
- matvu-0.1.0.dist-info/METADATA +194 -0
- matvu-0.1.0.dist-info/RECORD +24 -0
- matvu-0.1.0.dist-info/WHEEL +4 -0
- matvu-0.1.0.dist-info/entry_points.txt +2 -0
- matvu-0.1.0.dist-info/licenses/LICENSE +674 -0
matvu/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""MatVu: automatic viewpoint optimization and visualization.
|
|
2
|
+
|
|
3
|
+
MatVu analyses a materials structure, finds a clear viewing direction
|
|
4
|
+
automatically and renders a publication-ready ball-and-stick figure::
|
|
5
|
+
|
|
6
|
+
from matvu import render
|
|
7
|
+
|
|
8
|
+
render("POSCAR", view="auto")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from .backends import available_backends, register_backend
|
|
14
|
+
from .gallery import rank_candidates, render_candidates
|
|
15
|
+
from .io import available_formats, load_structure, register_reader
|
|
16
|
+
from .palettes import palette_names
|
|
17
|
+
from .render import auto_view, render, resolve_view
|
|
18
|
+
from .structure import Structure
|
|
19
|
+
from .style import Style, style_preset
|
|
20
|
+
from .theme import Theme, export_theme, load_theme
|
|
21
|
+
from .viewpoint import View, pick_view
|
|
22
|
+
from .viewpoint import score as score_view
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"Structure",
|
|
27
|
+
"Style",
|
|
28
|
+
"View",
|
|
29
|
+
"auto_view",
|
|
30
|
+
"available_backends",
|
|
31
|
+
"available_formats",
|
|
32
|
+
"export_theme",
|
|
33
|
+
"load_structure",
|
|
34
|
+
"load_theme",
|
|
35
|
+
"palette_names",
|
|
36
|
+
"pick_view",
|
|
37
|
+
"rank_candidates",
|
|
38
|
+
"register_backend",
|
|
39
|
+
"register_reader",
|
|
40
|
+
"render",
|
|
41
|
+
"render_candidates",
|
|
42
|
+
"resolve_view",
|
|
43
|
+
"score_view",
|
|
44
|
+
"style_preset",
|
|
45
|
+
]
|
matvu/__main__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Backend registry.
|
|
2
|
+
|
|
3
|
+
Third-party backends plug in with::
|
|
4
|
+
|
|
5
|
+
from matvu.backends import Backend, register_backend
|
|
6
|
+
|
|
7
|
+
class MyBackend(Backend):
|
|
8
|
+
name = "mybackend"
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
register_backend(MyBackend)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .base import Backend
|
|
17
|
+
from . import mpl, tracer
|
|
18
|
+
|
|
19
|
+
_BACKENDS: dict[str, type[Backend]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_backend(backend_class: type[Backend], *, overwrite: bool = False) -> None:
|
|
23
|
+
"""Register a backend class under its ``name``."""
|
|
24
|
+
key = str(backend_class.name).lower()
|
|
25
|
+
if not overwrite and key in _BACKENDS:
|
|
26
|
+
raise ValueError(f"a backend named {key!r} is already registered")
|
|
27
|
+
_BACKENDS[key] = backend_class
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_backend(name: str = "matplotlib") -> Backend:
|
|
31
|
+
"""Instantiate a registered backend by name."""
|
|
32
|
+
key = str(name).lower()
|
|
33
|
+
if key not in _BACKENDS:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"unknown backend {name!r}; available: {available_backends()}"
|
|
36
|
+
)
|
|
37
|
+
return _BACKENDS[key]()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def available_backends() -> list[str]:
|
|
41
|
+
"""Names of all registered backends."""
|
|
42
|
+
return sorted(_BACKENDS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
register_backend(mpl.MatplotlibBackend)
|
|
46
|
+
register_backend(tracer.TracerBackend)
|
matvu/backends/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Backend protocol: everything a renderer must implement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Backend(ABC):
|
|
9
|
+
"""A rendering backend.
|
|
10
|
+
|
|
11
|
+
Backends receive a fully resolved :class:`~matvu.scene.Scene` and a
|
|
12
|
+
:class:`~matvu.viewpoint.View`. They are responsible for output only;
|
|
13
|
+
viewpoint selection happens before, in backend-independent code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = "backend"
|
|
17
|
+
interactive = False
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def render(self, scene, view, style, output=None, **kwargs):
|
|
21
|
+
"""Render ``scene`` from ``view``.
|
|
22
|
+
|
|
23
|
+
Returns the output path when a file was written, a figure object when
|
|
24
|
+
the backend produced an in-memory result, or ``None``.
|
|
25
|
+
"""
|
|
26
|
+
raise NotImplementedError
|
matvu/backends/mpl.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Matplotlib backend: static, publication-oriented ball-and-stick rendering.
|
|
2
|
+
|
|
3
|
+
The scene is projected orthographically onto a 2D plane and drawn with a
|
|
4
|
+
painter's algorithm: spheres are shaded sprites placed with ``imshow`` and
|
|
5
|
+
bonds are split half-coloured sticks. The result is deterministic, works in
|
|
6
|
+
headless environments and can be written to PNG, PDF or SVG.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ..scene import CELL_EDGE_INDICES, Scene, cell_corners, project
|
|
16
|
+
from ..style import Style
|
|
17
|
+
from ..viewpoint import View
|
|
18
|
+
from .base import Backend
|
|
19
|
+
|
|
20
|
+
_SPRITE_CACHE: dict[tuple, np.ndarray] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sphere_sprite(colour: np.ndarray, style: Style) -> np.ndarray:
|
|
24
|
+
"""RGBA sprite of a shaded sphere, cached per (colour, style)."""
|
|
25
|
+
key = (
|
|
26
|
+
tuple(np.round(colour, 4)),
|
|
27
|
+
style.sphere_res,
|
|
28
|
+
tuple(np.round(style.light, 3)),
|
|
29
|
+
style.ambient,
|
|
30
|
+
style.diffuse,
|
|
31
|
+
style.specular,
|
|
32
|
+
style.highlight_shininess,
|
|
33
|
+
style.shading,
|
|
34
|
+
)
|
|
35
|
+
cached = _SPRITE_CACHE.get(key)
|
|
36
|
+
if cached is not None:
|
|
37
|
+
return cached
|
|
38
|
+
|
|
39
|
+
resolution = int(style.sphere_res)
|
|
40
|
+
lin = np.linspace(-1.0, 1.0, resolution)
|
|
41
|
+
xx, yy = np.meshgrid(lin, lin[::-1]) # row 0 is the top of the sprite
|
|
42
|
+
r2 = xx * xx + yy * yy
|
|
43
|
+
inside = r2 <= 1.0
|
|
44
|
+
zz = np.sqrt(np.clip(1.0 - r2, 0.0, 1.0))
|
|
45
|
+
|
|
46
|
+
if style.shading:
|
|
47
|
+
light = np.asarray(style.light, dtype=float)
|
|
48
|
+
light = light / np.linalg.norm(light)
|
|
49
|
+
lambert = np.clip(
|
|
50
|
+
xx * light[0] + yy * light[1] + zz * light[2], 0.0, None
|
|
51
|
+
)
|
|
52
|
+
shade = style.ambient + style.diffuse * lambert
|
|
53
|
+
half = light + np.array([0.0, 0.0, 1.0])
|
|
54
|
+
half = half / np.linalg.norm(half)
|
|
55
|
+
specular = (
|
|
56
|
+
np.clip(xx * half[0] + yy * half[1] + zz * half[2], 0.0, None)
|
|
57
|
+
** style.highlight_shininess
|
|
58
|
+
)
|
|
59
|
+
edge = np.clip(zz, 0.0, 1.0) ** 0.3
|
|
60
|
+
rgb = np.clip(
|
|
61
|
+
colour[None, None, :] * shade[..., None] * edge[..., None]
|
|
62
|
+
+ style.specular * specular[..., None],
|
|
63
|
+
0.0,
|
|
64
|
+
1.0,
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
rgb = np.broadcast_to(np.asarray(colour, dtype=float), (resolution, resolution, 3)).copy()
|
|
68
|
+
|
|
69
|
+
alpha = np.clip((1.0 - np.sqrt(r2)) * (resolution / 3.0), 0.0, 1.0) * inside
|
|
70
|
+
sprite = np.dstack([rgb, alpha])
|
|
71
|
+
_SPRITE_CACHE[key] = sprite
|
|
72
|
+
return sprite
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MatplotlibBackend(Backend):
|
|
76
|
+
"""Static ball-and-stick renderer on top of matplotlib."""
|
|
77
|
+
|
|
78
|
+
name = "matplotlib"
|
|
79
|
+
interactive = False
|
|
80
|
+
|
|
81
|
+
def render(
|
|
82
|
+
self,
|
|
83
|
+
scene: Scene,
|
|
84
|
+
view: View,
|
|
85
|
+
style: Style,
|
|
86
|
+
output=None,
|
|
87
|
+
show: bool = False,
|
|
88
|
+
**kwargs,
|
|
89
|
+
):
|
|
90
|
+
import matplotlib.pyplot as plt
|
|
91
|
+
|
|
92
|
+
projection = project(scene, view)
|
|
93
|
+
xy, depth, radii = projection.xy, projection.depth, projection.radii
|
|
94
|
+
|
|
95
|
+
lower = (xy - radii[:, None]).min(axis=0)
|
|
96
|
+
upper = (xy + radii[:, None]).max(axis=0)
|
|
97
|
+
|
|
98
|
+
corners = cell_corners(scene) if style.draw_cell else None
|
|
99
|
+
edge_data: list[tuple[np.ndarray, np.ndarray, float]] = []
|
|
100
|
+
if corners is not None:
|
|
101
|
+
planar_corners = corners @ projection.basis.T
|
|
102
|
+
lower = np.minimum(lower, planar_corners[:, :2].min(axis=0))
|
|
103
|
+
upper = np.maximum(upper, planar_corners[:, :2].max(axis=0))
|
|
104
|
+
for a, b in CELL_EDGE_INDICES:
|
|
105
|
+
edge_data.append(
|
|
106
|
+
(
|
|
107
|
+
planar_corners[a, :2],
|
|
108
|
+
planar_corners[b, :2],
|
|
109
|
+
float(planar_corners[a, 2] + planar_corners[b, 2]) * 0.5,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
pad = np.maximum(upper - lower, 1e-9) * style.margin + 1e-9
|
|
114
|
+
lower = lower - pad
|
|
115
|
+
upper = upper + pad
|
|
116
|
+
nominal_width: float | None = None
|
|
117
|
+
if style.canvas is not None:
|
|
118
|
+
canvas_width, canvas_height = (float(value) for value in style.canvas)
|
|
119
|
+
centre_x = 0.5 * (float(lower[0]) + float(upper[0]))
|
|
120
|
+
centre_y = 0.5 * (float(lower[1]) + float(upper[1]))
|
|
121
|
+
used_width = max(canvas_width, float(upper[0] - lower[0]))
|
|
122
|
+
used_height = max(canvas_height, float(upper[1] - lower[1]))
|
|
123
|
+
x0, x1 = centre_x - used_width / 2.0, centre_x + used_width / 2.0
|
|
124
|
+
y0, y1 = centre_y - used_height / 2.0, centre_y + used_height / 2.0
|
|
125
|
+
nominal_width = canvas_width
|
|
126
|
+
else:
|
|
127
|
+
x0, x1 = float(lower[0]), float(upper[0])
|
|
128
|
+
y0, y1 = float(lower[1]), float(upper[1])
|
|
129
|
+
x_span = max(x1 - x0, 1e-9)
|
|
130
|
+
y_span = max(y1 - y0, 1e-9)
|
|
131
|
+
|
|
132
|
+
width_in = float(style.width_in)
|
|
133
|
+
if nominal_width is not None:
|
|
134
|
+
# Same scale as the nominal canvas: the figure grows instead.
|
|
135
|
+
scale_in_per_unit = width_in / nominal_width
|
|
136
|
+
width_in = x_span * scale_in_per_unit
|
|
137
|
+
height_in = y_span * scale_in_per_unit
|
|
138
|
+
else:
|
|
139
|
+
height_in = width_in * y_span / x_span
|
|
140
|
+
figure = plt.figure(figsize=(width_in, height_in), dpi=style.dpi)
|
|
141
|
+
figure.patch.set_facecolor(style.background)
|
|
142
|
+
axes = figure.add_axes((0.0, 0.0, 1.0, 1.0))
|
|
143
|
+
axes.set_facecolor(style.background)
|
|
144
|
+
axes.set_axis_off()
|
|
145
|
+
axes.set_xlim(x0, x1)
|
|
146
|
+
axes.set_ylim(y0, y1)
|
|
147
|
+
|
|
148
|
+
# Painter's algorithm: far primitives first; on equal depth draw bonds
|
|
149
|
+
# before atoms so spheres always sit on top of their bond caps.
|
|
150
|
+
items: list[tuple[float, int, int, int]] = [
|
|
151
|
+
(float(depth[i]), 1, 0, i) for i in range(len(xy))
|
|
152
|
+
]
|
|
153
|
+
if style.draw_bonds:
|
|
154
|
+
for bond_index, (a, b) in enumerate(scene.bonds):
|
|
155
|
+
items.append(
|
|
156
|
+
(float(depth[a] + depth[b]) * 0.5, 0, 1, bond_index)
|
|
157
|
+
)
|
|
158
|
+
for edge_index, (_, _, edge_depth) in enumerate(edge_data):
|
|
159
|
+
items.append((edge_depth, 0, 2, edge_index))
|
|
160
|
+
items.sort(key=lambda item: (item[0], item[1]))
|
|
161
|
+
|
|
162
|
+
points_per_angstrom = width_in * 72.0 / x_span
|
|
163
|
+
for rank, (_, _, kind, index) in enumerate(items):
|
|
164
|
+
if kind == 0:
|
|
165
|
+
radius = float(radii[index])
|
|
166
|
+
x, y = float(xy[index, 0]), float(xy[index, 1])
|
|
167
|
+
sprite = _sphere_sprite(scene.colours[index], style)
|
|
168
|
+
axes.imshow(
|
|
169
|
+
sprite,
|
|
170
|
+
extent=(x - radius, x + radius, y - radius, y + radius),
|
|
171
|
+
origin="upper",
|
|
172
|
+
interpolation="bilinear",
|
|
173
|
+
zorder=rank,
|
|
174
|
+
)
|
|
175
|
+
elif kind == 1:
|
|
176
|
+
_draw_bond(
|
|
177
|
+
axes,
|
|
178
|
+
scene,
|
|
179
|
+
xy,
|
|
180
|
+
index,
|
|
181
|
+
points_per_angstrom,
|
|
182
|
+
rank,
|
|
183
|
+
style,
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
start_2d, end_2d, _ = edge_data[index]
|
|
187
|
+
axes.plot(
|
|
188
|
+
[start_2d[0], end_2d[0]],
|
|
189
|
+
[start_2d[1], end_2d[1]],
|
|
190
|
+
color=style.cell_colour,
|
|
191
|
+
lw=max(0.4, style.cell_linewidth),
|
|
192
|
+
solid_capstyle="round",
|
|
193
|
+
zorder=rank,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if output is not None:
|
|
197
|
+
output = Path(output)
|
|
198
|
+
figure.savefig(
|
|
199
|
+
output,
|
|
200
|
+
dpi=style.dpi,
|
|
201
|
+
facecolor=style.background,
|
|
202
|
+
transparent=style.transparent,
|
|
203
|
+
)
|
|
204
|
+
plt.close(figure)
|
|
205
|
+
return output
|
|
206
|
+
if show:
|
|
207
|
+
plt.show()
|
|
208
|
+
return None
|
|
209
|
+
return figure
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _draw_bond(axes, scene, xy, index, points_per_angstrom, zorder, style) -> None:
|
|
213
|
+
a, b = scene.bonds[index]
|
|
214
|
+
start = xy[a].astype(float)
|
|
215
|
+
end = xy[b].astype(float)
|
|
216
|
+
direction_2d = end - start
|
|
217
|
+
length = float(np.linalg.norm(direction_2d))
|
|
218
|
+
if length < 1e-9:
|
|
219
|
+
return
|
|
220
|
+
unit = direction_2d / length
|
|
221
|
+
# Clip the stick at the projected sphere surfaces. For an orthographic
|
|
222
|
+
# projection this is exact, and it stops sticks lying flat on the balls.
|
|
223
|
+
start = start + unit * float(scene.radii[a])
|
|
224
|
+
end = end - unit * float(scene.radii[b])
|
|
225
|
+
if float(np.linalg.norm(end - start)) <= 1e-6:
|
|
226
|
+
return
|
|
227
|
+
middle = 0.5 * (start + end)
|
|
228
|
+
line_width = max(0.4, 2.0 * style.bond_radius * points_per_angstrom)
|
|
229
|
+
outline = np.clip(
|
|
230
|
+
0.5 * (scene.colours[a] + scene.colours[b]) * 0.40, 0.0, 1.0
|
|
231
|
+
)
|
|
232
|
+
# Slightly darker than the atom colour so light atoms (H) stay visible
|
|
233
|
+
# on a light background.
|
|
234
|
+
colour_a = np.clip(scene.colours[a] * 0.80, 0.0, 1.0)
|
|
235
|
+
colour_b = np.clip(scene.colours[b] * 0.80, 0.0, 1.0)
|
|
236
|
+
axes.plot(
|
|
237
|
+
[start[0], end[0]],
|
|
238
|
+
[start[1], end[1]],
|
|
239
|
+
color=outline,
|
|
240
|
+
lw=line_width * 1.18,
|
|
241
|
+
solid_capstyle="round",
|
|
242
|
+
zorder=zorder,
|
|
243
|
+
)
|
|
244
|
+
axes.plot(
|
|
245
|
+
[start[0], middle[0]],
|
|
246
|
+
[start[1], middle[1]],
|
|
247
|
+
color=colour_a,
|
|
248
|
+
lw=line_width,
|
|
249
|
+
solid_capstyle="round",
|
|
250
|
+
zorder=zorder + 0.2,
|
|
251
|
+
)
|
|
252
|
+
axes.plot(
|
|
253
|
+
[middle[0], end[0]],
|
|
254
|
+
[middle[1], end[1]],
|
|
255
|
+
color=colour_b,
|
|
256
|
+
lw=line_width,
|
|
257
|
+
solid_capstyle="round",
|
|
258
|
+
zorder=zorder + 0.2,
|
|
259
|
+
)
|