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 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,8 @@
1
+ """Allow ``python -m matvu``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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
+ )