unit-cell-gui 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.
- unit_cell_gui/__init__.py +39 -0
- unit_cell_gui/hatching.py +229 -0
- unit_cell_gui/models.py +193 -0
- unit_cell_gui/py.typed +1 -0
- unit_cell_gui/viewer.py +1042 -0
- unit_cell_gui-0.1.0.dist-info/METADATA +117 -0
- unit_cell_gui-0.1.0.dist-info/RECORD +10 -0
- unit_cell_gui-0.1.0.dist-info/WHEEL +5 -0
- unit_cell_gui-0.1.0.dist-info/licenses/LICENSE +21 -0
- unit_cell_gui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""A Qt widget that renders complete, caller-supplied unit-cell scenes."""
|
|
2
|
+
|
|
3
|
+
from .hatching import (
|
|
4
|
+
face_hatch_segments,
|
|
5
|
+
hatch_division_count,
|
|
6
|
+
occupancy_fractions,
|
|
7
|
+
propagated_hatch_directions,
|
|
8
|
+
quadrilateral_hatch_line_count,
|
|
9
|
+
)
|
|
10
|
+
from .models import Atom, AtomComponent, DisplayOptions, Polyhedron, Scene, StyleOverrides
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name):
|
|
15
|
+
if name in {"CrystalCanvas", "UnitCellViewer", "screen_drag_orientation"}:
|
|
16
|
+
from .viewer import CrystalCanvas, UnitCellViewer, screen_drag_orientation
|
|
17
|
+
return {
|
|
18
|
+
"CrystalCanvas": CrystalCanvas,
|
|
19
|
+
"UnitCellViewer": UnitCellViewer,
|
|
20
|
+
"screen_drag_orientation": screen_drag_orientation,
|
|
21
|
+
}[name]
|
|
22
|
+
raise AttributeError(name)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Atom",
|
|
26
|
+
"AtomComponent",
|
|
27
|
+
"CrystalCanvas",
|
|
28
|
+
"DisplayOptions",
|
|
29
|
+
"Polyhedron",
|
|
30
|
+
"Scene",
|
|
31
|
+
"StyleOverrides",
|
|
32
|
+
"UnitCellViewer",
|
|
33
|
+
"face_hatch_segments",
|
|
34
|
+
"hatch_division_count",
|
|
35
|
+
"occupancy_fractions",
|
|
36
|
+
"propagated_hatch_directions",
|
|
37
|
+
"quadrilateral_hatch_line_count",
|
|
38
|
+
"screen_drag_orientation",
|
|
39
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""View-dependent hatching used by the unit-cell renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def occupancy_fractions(components: Iterable[object]) -> tuple[float, ...]:
|
|
12
|
+
"""Return drawable site fractions while preserving partial occupancy."""
|
|
13
|
+
occupancies = tuple(max(0.0, float(component.occupancy)) for component in components)
|
|
14
|
+
total = sum(occupancies)
|
|
15
|
+
scale = 1.0 if total <= 1.0 else 1.0 / total
|
|
16
|
+
return tuple(occupancy * scale for occupancy in occupancies)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quadrilateral_hatch_line_count(triangle_count: int) -> int:
|
|
20
|
+
"""Return the number of full, parallel strokes on a quadrilateral."""
|
|
21
|
+
return 2 * max(0, int(triangle_count)) + 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hatch_face_normal(points: np.ndarray) -> np.ndarray:
|
|
25
|
+
normal = np.cross(points[1] - points[0], points[2] - points[0])
|
|
26
|
+
return normal / np.linalg.norm(normal)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _initial_hatch_direction(face: tuple[int, ...], vertices: np.ndarray) -> np.ndarray:
|
|
30
|
+
apex = max(face, key=lambda vertex: float(vertices[vertex, 2]))
|
|
31
|
+
if len(face) == 4:
|
|
32
|
+
# Virtual triangles choose a diagonal direction only. They never
|
|
33
|
+
# become visible boundaries or separately rendered subfaces.
|
|
34
|
+
position = face.index(apex)
|
|
35
|
+
first, second = face[(position - 1) % 4], face[(position + 1) % 4]
|
|
36
|
+
else:
|
|
37
|
+
candidates = [
|
|
38
|
+
(first, face[(position + 1) % len(face)])
|
|
39
|
+
for position, first in enumerate(face)
|
|
40
|
+
if apex not in (first, face[(position + 1) % len(face)])
|
|
41
|
+
]
|
|
42
|
+
first, second = max(
|
|
43
|
+
candidates,
|
|
44
|
+
key=lambda edge: float(np.linalg.norm(
|
|
45
|
+
0.5 * (vertices[edge[0], :2] + vertices[edge[1], :2])
|
|
46
|
+
- vertices[apex, :2]
|
|
47
|
+
)),
|
|
48
|
+
)
|
|
49
|
+
direction = vertices[second] - vertices[first]
|
|
50
|
+
return direction / np.linalg.norm(direction)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _transport_hatch_direction(
|
|
54
|
+
direction: np.ndarray,
|
|
55
|
+
edge: np.ndarray,
|
|
56
|
+
source_normal: np.ndarray,
|
|
57
|
+
target_normal: np.ndarray,
|
|
58
|
+
) -> np.ndarray:
|
|
59
|
+
"""Unfold and refold a tangent vector about the actual shared edge."""
|
|
60
|
+
along = edge / np.linalg.norm(edge)
|
|
61
|
+
source_across = np.cross(source_normal, along)
|
|
62
|
+
target_across = np.cross(target_normal, along)
|
|
63
|
+
transported = (
|
|
64
|
+
float(np.dot(direction, along)) * along
|
|
65
|
+
+ float(np.dot(direction, source_across)) * target_across
|
|
66
|
+
)
|
|
67
|
+
return transported / np.linalg.norm(transported)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def propagated_hatch_directions(
|
|
71
|
+
faces: Iterable[Iterable[int]],
|
|
72
|
+
vertices_camera: np.ndarray,
|
|
73
|
+
visible_faces: Iterable[int],
|
|
74
|
+
blank_faces: Iterable[int] = (),
|
|
75
|
+
) -> dict[int, np.ndarray]:
|
|
76
|
+
"""Choose seed directions, then transport them in each receiving plane."""
|
|
77
|
+
faces = tuple(tuple(int(vertex) for vertex in face) for face in faces)
|
|
78
|
+
vertices = np.asarray(vertices_camera, dtype=float).reshape(-1, 3)
|
|
79
|
+
visible = {int(face_index) for face_index in visible_faces}
|
|
80
|
+
blank = visible.intersection(int(face_index) for face_index in blank_faces)
|
|
81
|
+
if not visible:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
edge_faces: dict[tuple[int, int], list[int]] = {}
|
|
85
|
+
for face_index, face in enumerate(faces):
|
|
86
|
+
edges = tuple(
|
|
87
|
+
tuple(sorted((face[position], face[(position + 1) % len(face)])))
|
|
88
|
+
for position in range(len(face))
|
|
89
|
+
)
|
|
90
|
+
for edge in edges:
|
|
91
|
+
edge_faces.setdefault(edge, []).append(face_index)
|
|
92
|
+
|
|
93
|
+
adjacency = {face_index: set() for face_index in visible}
|
|
94
|
+
shared_edges: dict[tuple[int, int], tuple[int, int]] = {}
|
|
95
|
+
for edge, owners in edge_faces.items():
|
|
96
|
+
visible_owners = [face_index for face_index in owners if face_index in visible]
|
|
97
|
+
for first in visible_owners:
|
|
98
|
+
for second in visible_owners:
|
|
99
|
+
if first == second:
|
|
100
|
+
continue
|
|
101
|
+
adjacency[first].add(second)
|
|
102
|
+
shared_edges[(first, second)] = edge
|
|
103
|
+
|
|
104
|
+
depths = {
|
|
105
|
+
face_index: float(vertices[list(faces[face_index]), 2].mean())
|
|
106
|
+
for face_index in visible
|
|
107
|
+
}
|
|
108
|
+
order = sorted(visible, key=lambda face_index: (-depths[face_index], face_index))
|
|
109
|
+
visible_vertices = {vertex for index in visible for vertex in faces[index]}
|
|
110
|
+
nearest_depth = max(float(vertices[index, 2]) for index in visible_vertices)
|
|
111
|
+
depth_tolerance = max(1e-12, float(np.ptp(vertices[:, 2])) * 1e-9)
|
|
112
|
+
nearest_vertices = {
|
|
113
|
+
index for index in visible_vertices
|
|
114
|
+
if nearest_depth - float(vertices[index, 2]) <= depth_tolerance
|
|
115
|
+
}
|
|
116
|
+
normals = {
|
|
117
|
+
face_index: _hatch_face_normal(vertices[list(faces[face_index])])
|
|
118
|
+
for face_index in visible
|
|
119
|
+
}
|
|
120
|
+
directions: dict[int, np.ndarray] = {}
|
|
121
|
+
for face_index in order:
|
|
122
|
+
if face_index in blank:
|
|
123
|
+
continue
|
|
124
|
+
blank_neighbours = adjacency[face_index].intersection(blank)
|
|
125
|
+
if blank_neighbours:
|
|
126
|
+
parent = min(blank_neighbours)
|
|
127
|
+
first, second = shared_edges[(face_index, parent)]
|
|
128
|
+
direction = vertices[second] - vertices[first]
|
|
129
|
+
directions[face_index] = direction / np.linalg.norm(direction)
|
|
130
|
+
continue
|
|
131
|
+
if nearest_vertices.intersection(faces[face_index]):
|
|
132
|
+
directions[face_index] = _initial_hatch_direction(faces[face_index], vertices)
|
|
133
|
+
continue
|
|
134
|
+
processed_neighbours = [
|
|
135
|
+
neighbour
|
|
136
|
+
for neighbour in adjacency[face_index]
|
|
137
|
+
if neighbour in directions
|
|
138
|
+
and depths[neighbour] > depths[face_index] + depth_tolerance
|
|
139
|
+
]
|
|
140
|
+
if processed_neighbours:
|
|
141
|
+
parent = max(
|
|
142
|
+
processed_neighbours,
|
|
143
|
+
key=lambda neighbour: (depths[neighbour], -neighbour),
|
|
144
|
+
)
|
|
145
|
+
first, second = shared_edges[(face_index, parent)]
|
|
146
|
+
directions[face_index] = _transport_hatch_direction(
|
|
147
|
+
directions[parent],
|
|
148
|
+
vertices[second] - vertices[first],
|
|
149
|
+
normals[parent],
|
|
150
|
+
normals[face_index],
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
directions[face_index] = _initial_hatch_direction(faces[face_index], vertices)
|
|
154
|
+
return directions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def face_hatch_segments(
|
|
158
|
+
face_vertices: np.ndarray,
|
|
159
|
+
direction: np.ndarray,
|
|
160
|
+
line_count: int,
|
|
161
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
162
|
+
"""Clip full parallel strokes to a convex face in its own 3-D plane."""
|
|
163
|
+
points = np.asarray(face_vertices, dtype=float)
|
|
164
|
+
if len(points) < 3 or line_count <= 0:
|
|
165
|
+
return []
|
|
166
|
+
origin = points[0]
|
|
167
|
+
normal = _hatch_face_normal(points)
|
|
168
|
+
across = np.cross(normal, np.asarray(direction, dtype=float))
|
|
169
|
+
length = float(np.linalg.norm(across))
|
|
170
|
+
if length < 1e-12:
|
|
171
|
+
return []
|
|
172
|
+
across /= length
|
|
173
|
+
values = (points - origin) @ across
|
|
174
|
+
lower, upper = float(values.min()), float(values.max())
|
|
175
|
+
extent = float(np.max(np.linalg.norm(points - origin, axis=1)))
|
|
176
|
+
tolerance = max(1e-12, extent * 1e-10)
|
|
177
|
+
if upper - lower <= tolerance:
|
|
178
|
+
return []
|
|
179
|
+
segments = []
|
|
180
|
+
for step in range(1, int(line_count) + 1):
|
|
181
|
+
constant = lower + step * (upper - lower) / (line_count + 1.0)
|
|
182
|
+
crossings = []
|
|
183
|
+
for index, first in enumerate(points):
|
|
184
|
+
next_index = (index + 1) % len(points)
|
|
185
|
+
first_side = values[index] - constant
|
|
186
|
+
second_side = values[next_index] - constant
|
|
187
|
+
if abs(first_side) <= tolerance:
|
|
188
|
+
crossings.append(first.copy())
|
|
189
|
+
if first_side * second_side < 0.0:
|
|
190
|
+
fraction = first_side / (first_side - second_side)
|
|
191
|
+
crossings.append(first + fraction * (points[next_index] - first))
|
|
192
|
+
unique = []
|
|
193
|
+
for crossing in crossings:
|
|
194
|
+
if not any(np.linalg.norm(crossing - other) <= tolerance for other in unique):
|
|
195
|
+
unique.append(crossing)
|
|
196
|
+
if len(unique) >= 2:
|
|
197
|
+
first, second = max(
|
|
198
|
+
itertools.combinations(unique, 2),
|
|
199
|
+
key=lambda pair: float(np.linalg.norm(pair[1] - pair[0])),
|
|
200
|
+
)
|
|
201
|
+
if np.linalg.norm(second - first) > tolerance:
|
|
202
|
+
segments.append((first, second))
|
|
203
|
+
return segments
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def hatch_division_count(
|
|
207
|
+
far_factor: float,
|
|
208
|
+
density_setting: int,
|
|
209
|
+
depth_setting: int,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Return the original density with a separate GRIP depth coefficient."""
|
|
212
|
+
density_norm = (float(density_setting) - 4.0) / (42.0 - 4.0)
|
|
213
|
+
density_norm = max(0.0, min(1.0, density_norm))
|
|
214
|
+
near_count = 4.0 + 8.0 * density_norm
|
|
215
|
+
base_difference = 4.0 + 10.0 * density_norm
|
|
216
|
+
far_count = near_count + base_difference * (float(depth_setting) / 50.0)
|
|
217
|
+
depth = max(0.0, min(1.0, float(far_factor)))
|
|
218
|
+
smooth_depth = depth * depth * (3.0 - 2.0 * depth)
|
|
219
|
+
count = near_count + (far_count - near_count) * smooth_depth
|
|
220
|
+
return max(1, int(round(count)))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = [
|
|
224
|
+
"face_hatch_segments",
|
|
225
|
+
"hatch_division_count",
|
|
226
|
+
"occupancy_fractions",
|
|
227
|
+
"propagated_hatch_directions",
|
|
228
|
+
"quadrilateral_hatch_line_count",
|
|
229
|
+
]
|
unit_cell_gui/models.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Toolkit-neutral input types for the unit-cell renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _array(value, shape, dtype=float) -> np.ndarray:
|
|
13
|
+
result = np.asarray(value, dtype=dtype).reshape(shape).copy()
|
|
14
|
+
if not np.all(np.isfinite(result)):
|
|
15
|
+
raise ValueError("Scene coordinates must be finite.")
|
|
16
|
+
result.setflags(write=False)
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AtomComponent:
|
|
22
|
+
"""One coloured component of an already resolved atomic position."""
|
|
23
|
+
|
|
24
|
+
key: str
|
|
25
|
+
label: str
|
|
26
|
+
element: str
|
|
27
|
+
occupancy: float
|
|
28
|
+
colour: str
|
|
29
|
+
radius: float
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
occupancy, radius = float(self.occupancy), float(self.radius)
|
|
33
|
+
if not np.isfinite(occupancy) or not np.isfinite(radius) or radius <= 0:
|
|
34
|
+
raise ValueError("Component occupancy and radius must be finite; radius must be positive.")
|
|
35
|
+
object.__setattr__(self, "occupancy", occupancy)
|
|
36
|
+
object.__setattr__(self, "radius", radius)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Atom:
|
|
41
|
+
"""One displayed site image, possibly with mixed occupancy."""
|
|
42
|
+
|
|
43
|
+
site_key: str
|
|
44
|
+
site_label: str
|
|
45
|
+
components: tuple[AtomComponent, ...]
|
|
46
|
+
external: bool = False
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
object.__setattr__(self, "components", tuple(self.components))
|
|
50
|
+
if not self.components:
|
|
51
|
+
raise ValueError("An atom must contain at least one component.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Polyhedron:
|
|
56
|
+
"""Ready world-space vertices and polygon faces around one site."""
|
|
57
|
+
|
|
58
|
+
center: np.ndarray
|
|
59
|
+
vertices: np.ndarray
|
|
60
|
+
faces: tuple[tuple[int, ...], ...]
|
|
61
|
+
site_key: str
|
|
62
|
+
site_label: str
|
|
63
|
+
components: tuple[AtomComponent, ...]
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
center = _array(self.center, (3,))
|
|
67
|
+
vertices = _array(self.vertices, (-1, 3))
|
|
68
|
+
faces = tuple(tuple(int(index) for index in face) for face in self.faces)
|
|
69
|
+
if any(len(face) < 3 or any(index < 0 or index >= len(vertices) for index in face)
|
|
70
|
+
for face in faces):
|
|
71
|
+
raise ValueError("Every polyhedron face must contain valid vertex indices.")
|
|
72
|
+
object.__setattr__(self, "center", center)
|
|
73
|
+
object.__setattr__(self, "vertices", vertices)
|
|
74
|
+
object.__setattr__(self, "faces", faces)
|
|
75
|
+
object.__setattr__(self, "components", tuple(self.components))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def elements(self) -> tuple[str, ...]:
|
|
79
|
+
return tuple(component.element for component in self.components)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def coordination_number(self) -> int:
|
|
83
|
+
return len(self.vertices)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Scene:
|
|
88
|
+
"""Complete render input; no file or crystallographic object is required."""
|
|
89
|
+
|
|
90
|
+
atoms: tuple[Atom, ...]
|
|
91
|
+
atom_centers: np.ndarray
|
|
92
|
+
bonds: np.ndarray
|
|
93
|
+
cell_vertices: np.ndarray
|
|
94
|
+
cell_edges: np.ndarray
|
|
95
|
+
basis_vectors: np.ndarray
|
|
96
|
+
polyhedra: tuple[Polyhedron, ...] = ()
|
|
97
|
+
base_radius: float = 1.0
|
|
98
|
+
elements: tuple[str, ...] = ()
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
atoms, polyhedra = tuple(self.atoms), tuple(self.polyhedra)
|
|
102
|
+
atom_centers = _array(self.atom_centers, (-1, 3))
|
|
103
|
+
bonds = np.asarray(self.bonds, dtype=int).reshape(-1, 2).copy()
|
|
104
|
+
cell_vertices = _array(self.cell_vertices, (-1, 3))
|
|
105
|
+
cell_edges = np.asarray(self.cell_edges, dtype=int).reshape(-1, 2).copy()
|
|
106
|
+
basis_vectors = _array(self.basis_vectors, (3, 3))
|
|
107
|
+
if len(atoms) != len(atom_centers):
|
|
108
|
+
raise ValueError("atoms and atom_centers must have the same length.")
|
|
109
|
+
if len(bonds) and (bonds.min() < 0 or bonds.max() >= len(atoms)):
|
|
110
|
+
raise ValueError("Bond indices must refer to atoms in the scene.")
|
|
111
|
+
if len(cell_edges) and (cell_edges.min() < 0 or cell_edges.max() >= len(cell_vertices)):
|
|
112
|
+
raise ValueError("Cell-edge indices must refer to cell vertices.")
|
|
113
|
+
if np.any(np.linalg.norm(basis_vectors, axis=0) <= 1e-12):
|
|
114
|
+
raise ValueError("Every basis vector must be non-zero.")
|
|
115
|
+
radius = float(self.base_radius)
|
|
116
|
+
if not np.isfinite(radius) or radius <= 0:
|
|
117
|
+
raise ValueError("base_radius must be finite and positive.")
|
|
118
|
+
bonds.setflags(write=False)
|
|
119
|
+
cell_edges.setflags(write=False)
|
|
120
|
+
elements = tuple(self.elements) or tuple(sorted({
|
|
121
|
+
component.element for atom in atoms for component in atom.components
|
|
122
|
+
}))
|
|
123
|
+
object.__setattr__(self, "atoms", atoms)
|
|
124
|
+
object.__setattr__(self, "atom_centers", atom_centers)
|
|
125
|
+
object.__setattr__(self, "bonds", bonds)
|
|
126
|
+
object.__setattr__(self, "cell_vertices", cell_vertices)
|
|
127
|
+
object.__setattr__(self, "cell_edges", cell_edges)
|
|
128
|
+
object.__setattr__(self, "basis_vectors", basis_vectors)
|
|
129
|
+
object.__setattr__(self, "polyhedra", polyhedra)
|
|
130
|
+
object.__setattr__(self, "base_radius", radius)
|
|
131
|
+
object.__setattr__(self, "elements", elements)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class DisplayOptions:
|
|
136
|
+
"""All caller-controlled presentation switches and numeric settings."""
|
|
137
|
+
|
|
138
|
+
show_atoms: bool = True
|
|
139
|
+
show_external_atoms: bool = True
|
|
140
|
+
show_bonds: bool = True
|
|
141
|
+
show_cell: bool = True
|
|
142
|
+
show_basis: bool = True
|
|
143
|
+
show_polyhedra: bool = False
|
|
144
|
+
engraving: bool = False
|
|
145
|
+
hatching: bool = True
|
|
146
|
+
hatch_density: int = 22
|
|
147
|
+
hatch_grip: int = 50
|
|
148
|
+
atom_scale: float = 1.0
|
|
149
|
+
rotation_enabled: bool = True
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
density = int(self.hatch_density)
|
|
153
|
+
grip = int(self.hatch_grip)
|
|
154
|
+
scale = float(self.atom_scale)
|
|
155
|
+
if density < 0 or grip < 0:
|
|
156
|
+
raise ValueError("Hatching density and GRIP must be non-negative.")
|
|
157
|
+
if not np.isfinite(scale) or scale <= 0:
|
|
158
|
+
raise ValueError("atom_scale must be finite and positive.")
|
|
159
|
+
object.__setattr__(self, "hatch_density", density)
|
|
160
|
+
object.__setattr__(self, "hatch_grip", grip)
|
|
161
|
+
object.__setattr__(self, "atom_scale", scale)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class StyleOverrides:
|
|
166
|
+
"""Per-site presentation changes supplied by the host application."""
|
|
167
|
+
|
|
168
|
+
atom_component_visibility: Mapping[str, bool] = field(default_factory=dict)
|
|
169
|
+
atom_component_colours: Mapping[str, str] = field(default_factory=dict)
|
|
170
|
+
polyhedron_site_visibility: Mapping[str, bool] = field(default_factory=dict)
|
|
171
|
+
polyhedron_site_colours: Mapping[str, str] = field(default_factory=dict)
|
|
172
|
+
polyhedron_opaque_sites: frozenset[str] = frozenset()
|
|
173
|
+
|
|
174
|
+
def __post_init__(self) -> None:
|
|
175
|
+
visibility = self.atom_component_visibility or {}
|
|
176
|
+
colours = self.atom_component_colours or {}
|
|
177
|
+
poly_visibility = self.polyhedron_site_visibility or {}
|
|
178
|
+
poly_colours = self.polyhedron_site_colours or {}
|
|
179
|
+
object.__setattr__(self, "atom_component_visibility", MappingProxyType({
|
|
180
|
+
str(key): bool(value) for key, value in visibility.items()
|
|
181
|
+
}))
|
|
182
|
+
object.__setattr__(self, "atom_component_colours", MappingProxyType({
|
|
183
|
+
str(key): str(value) for key, value in colours.items()
|
|
184
|
+
}))
|
|
185
|
+
object.__setattr__(self, "polyhedron_site_visibility", MappingProxyType({
|
|
186
|
+
str(key): bool(value) for key, value in poly_visibility.items()
|
|
187
|
+
}))
|
|
188
|
+
object.__setattr__(self, "polyhedron_site_colours", MappingProxyType({
|
|
189
|
+
str(key): str(value) for key, value in poly_colours.items()
|
|
190
|
+
}))
|
|
191
|
+
object.__setattr__(self, "polyhedron_opaque_sites", frozenset(
|
|
192
|
+
str(key) for key in self.polyhedron_opaque_sites
|
|
193
|
+
))
|
unit_cell_gui/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|