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.
@@ -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
+ ]
@@ -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
+