AtomVoxelizer 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,30 @@
1
+ """AtomVoxelizer public API."""
2
+
3
+ from .voxelgrid import VoxelGrid, VoxelGridNumPy
4
+ from .analysis import VoxelGridAnalysis, VoxelRegion
5
+
6
+ __all__ = [
7
+ "VoxelGrid",
8
+ "VoxelGridAnalysis",
9
+ "VoxelGridCuPy",
10
+ "VoxelGridNumPy",
11
+ "VoxelGridNumba",
12
+ "VoxelGridTaichi",
13
+ "VoxelRegion",
14
+ ]
15
+
16
+
17
+ def __getattr__(name):
18
+ if name == "VoxelGridCuPy":
19
+ from .cupy_backend import VoxelGridCuPy
20
+
21
+ return VoxelGridCuPy
22
+ if name == "VoxelGridNumba":
23
+ from .numba_backend import VoxelGridNumba
24
+
25
+ return VoxelGridNumba
26
+ if name == "VoxelGridTaichi":
27
+ from .taichi_backend import VoxelGridTaichi
28
+
29
+ return VoxelGridTaichi
30
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class VoxelRegion:
10
+ """Summary of one connected voxel region."""
11
+
12
+ label: int
13
+ voxel_count: int
14
+ volume: float
15
+ surface_area: float
16
+
17
+
18
+ class VoxelGridAnalysis:
19
+ """Analyze connected voxel volumes and their surfaces."""
20
+
21
+ def __init__(self, voxel_grid):
22
+ self.voxel_grid = voxel_grid
23
+
24
+ @property
25
+ def grid(self):
26
+ return self.voxel_grid.to_numpy()
27
+
28
+ @property
29
+ def cell(self):
30
+ return self.voxel_grid.cell
31
+
32
+ @property
33
+ def gpts(self):
34
+ return self.voxel_grid.gpts
35
+
36
+ @property
37
+ def voxel_volume(self):
38
+ return abs(float(np.linalg.det(self.cell))) / float(np.prod(self.gpts))
39
+
40
+ def mask(self, min_value=None, max_value=None, threshold=None, above=True):
41
+ """Build a boolean mask from voxel values."""
42
+ if threshold is not None and (min_value is not None or max_value is not None):
43
+ raise ValueError("Specify either threshold or min_value/max_value, not both")
44
+
45
+ grid = self.grid
46
+ if threshold is not None:
47
+ return grid > threshold if above else grid < threshold
48
+
49
+ selected = np.ones(grid.shape, dtype=bool)
50
+ if min_value is not None:
51
+ selected &= grid >= min_value
52
+ if max_value is not None:
53
+ selected &= grid <= max_value
54
+ return selected
55
+
56
+ def connected_components(self, selected, connectivity=1, periodic=True):
57
+ """Label connected components in a boolean mask."""
58
+ try:
59
+ from skimage.measure import label
60
+ except ImportError as exc: # pragma: no cover - depends on optional dependency
61
+ raise ImportError(
62
+ "VoxelGridAnalysis requires scikit-image. Install it with "
63
+ "`pip install AtomVoxelizer[analysis]`."
64
+ ) from exc
65
+
66
+ selected = np.asarray(selected, dtype=bool)
67
+ labels = label(selected, connectivity=connectivity)
68
+ if periodic:
69
+ labels = self._merge_periodic_labels(labels, selected)
70
+ return labels, int(labels.max())
71
+
72
+ def region_volume(self, selected):
73
+ """Return the volume represented by a boolean mask."""
74
+ return int(np.count_nonzero(selected)) * self.voxel_volume
75
+
76
+ def surface_area(self, selected, periodic=True):
77
+ """Estimate the surface area of a boolean region with marching cubes."""
78
+ try:
79
+ from skimage.measure import marching_cubes
80
+ except ImportError as exc: # pragma: no cover - depends on optional dependency
81
+ raise ImportError(
82
+ "VoxelGridAnalysis requires scikit-image. Install it with "
83
+ "`pip install AtomVoxelizer[analysis]`."
84
+ ) from exc
85
+
86
+ selected = np.asarray(selected, dtype=bool)
87
+ if not np.any(selected):
88
+ return 0.0
89
+ if periodic and np.all(selected):
90
+ return 0.0
91
+
92
+ if periodic:
93
+ values = np.tile(selected.astype(np.float32), (3, 3, 3))
94
+ vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
95
+ central_offset = self.gpts
96
+ centroids = vertices[faces].mean(axis=1)
97
+ in_central_cell = np.all((centroids >= central_offset) & (centroids < 2 * central_offset), axis=1)
98
+ faces = faces[in_central_cell]
99
+ vertices = vertices - central_offset
100
+ else:
101
+ values = np.pad(selected.astype(np.float32), 1, mode="constant", constant_values=0.0)
102
+ vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
103
+ vertices = vertices - 1.0
104
+
105
+ if faces.size == 0:
106
+ return 0.0
107
+
108
+ real_vertices = self._index_vertices_to_real(vertices)
109
+ return self._mesh_surface_area(real_vertices, faces)
110
+
111
+ def analyze_regions(
112
+ self,
113
+ min_value=None,
114
+ max_value=None,
115
+ threshold=None,
116
+ above=True,
117
+ connectivity=1,
118
+ periodic=True,
119
+ ):
120
+ """Return volume and marching-cubes area for each connected region."""
121
+ selected = self.mask(min_value=min_value, max_value=max_value, threshold=threshold, above=above)
122
+ labels, label_count = self.connected_components(selected, connectivity=connectivity, periodic=periodic)
123
+
124
+ regions = []
125
+ for label_id in range(1, label_count + 1):
126
+ region_mask = labels == label_id
127
+ voxel_count = int(np.count_nonzero(region_mask))
128
+ regions.append(
129
+ VoxelRegion(
130
+ label=label_id,
131
+ voxel_count=voxel_count,
132
+ volume=voxel_count * self.voxel_volume,
133
+ surface_area=self.surface_area(region_mask, periodic=periodic),
134
+ )
135
+ )
136
+ return regions
137
+
138
+ @staticmethod
139
+ def volume_angstrom3_to_cm3_per_g(volume_angstrom3, mass_amu):
140
+ """Convert a cell/supercell volume from Angstrom^3 to cm^3/g."""
141
+ if mass_amu <= 0:
142
+ raise ValueError("mass_amu must be positive")
143
+ mass_g = float(mass_amu) * 1.66053906660e-24
144
+ return float(volume_angstrom3) * 1.0e-24 / mass_g
145
+
146
+ @staticmethod
147
+ def area_angstrom2_to_m2_per_g(area_angstrom2, mass_amu):
148
+ """Convert a cell/supercell area from Angstrom^2 to m^2/g."""
149
+ if mass_amu <= 0:
150
+ raise ValueError("mass_amu must be positive")
151
+ mass_g = float(mass_amu) * 1.66053906660e-24
152
+ return float(area_angstrom2) * 1.0e-20 / mass_g
153
+
154
+ def _index_vertices_to_real(self, vertices):
155
+ frac = vertices / self.gpts
156
+ return frac @ self.cell
157
+
158
+ @staticmethod
159
+ def _merge_periodic_labels(labels, selected):
160
+ label_count = int(labels.max())
161
+ if label_count == 0:
162
+ return labels
163
+
164
+ parent = np.arange(label_count + 1)
165
+
166
+ def find(label_id):
167
+ while parent[label_id] != label_id:
168
+ parent[label_id] = parent[parent[label_id]]
169
+ label_id = parent[label_id]
170
+ return label_id
171
+
172
+ def union(a, b):
173
+ if a == 0 or b == 0:
174
+ return
175
+ root_a = find(int(a))
176
+ root_b = find(int(b))
177
+ if root_a != root_b:
178
+ parent[root_b] = root_a
179
+
180
+ for axis in range(labels.ndim):
181
+ first_labels = np.take(labels, 0, axis=axis)
182
+ last_labels = np.take(labels, -1, axis=axis)
183
+ first_selected = np.take(selected, 0, axis=axis)
184
+ last_selected = np.take(selected, -1, axis=axis)
185
+ for a, b in zip(first_labels[first_selected & last_selected], last_labels[first_selected & last_selected]):
186
+ union(a, b)
187
+
188
+ root_to_new_label = {}
189
+ next_label = 1
190
+ merged = np.zeros_like(labels)
191
+ for label_id in range(1, label_count + 1):
192
+ root = find(label_id)
193
+ if root not in root_to_new_label:
194
+ root_to_new_label[root] = next_label
195
+ next_label += 1
196
+ merged[labels == label_id] = root_to_new_label[root]
197
+
198
+ return merged
199
+
200
+ @staticmethod
201
+ def _mesh_surface_area(vertices, faces):
202
+ triangles = vertices[faces]
203
+ cross = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
204
+ return float(0.5 * np.linalg.norm(cross, axis=1).sum())
205
+
206
+
207
+ __all__ = ["VoxelGridAnalysis", "VoxelRegion"]
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from .voxelgrid import VoxelGrid, _cached_sphere_offsets
6
+
7
+ try:
8
+ from .numba_backend import VoxelGridNumba as _BaseVoxelGrid
9
+ except ImportError: # pragma: no cover - depends on optional dependency
10
+ _BaseVoxelGrid = VoxelGrid
11
+
12
+ try:
13
+ import cupy as cp
14
+ except ImportError as exc: # pragma: no cover - depends on optional dependency
15
+ raise ImportError(
16
+ "VoxelGridCuPy requires CuPy. Install it with `pip install AtomVoxelizer[cupy]`."
17
+ ) from exc
18
+
19
+
20
+ class VoxelGridCuPy(_BaseVoxelGrid):
21
+ """CuPy-backed voxel grid.
22
+
23
+ The class keeps the same public API as :class:`atomvoxelizer.VoxelGrid`, uses
24
+ the Numba backend as its base when available, stores ``grid`` as a CuPy array,
25
+ and overrides the mutating sphere operations.
26
+ """
27
+
28
+ @property
29
+ def backend_name(self):
30
+ return "cupy"
31
+
32
+ def __init__(self, cell, resolution=None, gpts=None):
33
+ super().__init__(cell=cell, resolution=resolution, gpts=gpts)
34
+ self.grid = cp.asarray(self.grid)
35
+
36
+ def to_numpy(self):
37
+ """Return the voxel values as a NumPy array."""
38
+ return cp.asnumpy(self.grid)
39
+
40
+ def _sphere_indices(self, center, radius):
41
+ center_frac = np.asarray(center, dtype=np.float64) @ self.cell_inv % 1.0
42
+ center_idx = np.floor(center_frac * self.gpts).astype(np.int32)
43
+ offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
44
+ indices = (offsets + center_idx) % self.gpts
45
+ return tuple(cp.asarray(indices[:, axis]) for axis in range(3))
46
+
47
+ def set_sphere(self, center, radius, value=1):
48
+ self.grid[self._sphere_indices(center, radius)] = value
49
+
50
+ def add_sphere(self, center, radius, value=1):
51
+ cp.add.at(self.grid, self._sphere_indices(center, radius), value)
52
+
53
+ def mul_sphere(self, center, radius, factor=2):
54
+ indices = self._sphere_indices(center, radius)
55
+ self.grid[indices] *= factor
56
+
57
+ def div_sphere(self, center, radius, factor=2):
58
+ indices = self._sphere_indices(center, radius)
59
+ self.grid[indices] /= factor
60
+
61
+ def add_spheres(self, centers, radii, value=1):
62
+ centers = np.asarray(centers, dtype=np.float64)
63
+ radii = np.asarray(radii, dtype=np.float64)
64
+ if centers.ndim != 2 or centers.shape[1] != 3:
65
+ raise ValueError("centers must have shape (N, 3)")
66
+ if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
67
+ raise ValueError("radii must have shape (N,)")
68
+
69
+ for center, radius in zip(centers, radii):
70
+ self.add_sphere(center, float(radius), value=value)
71
+
72
+ def set_spheres(self, centers, radii, value=1):
73
+ centers = np.asarray(centers, dtype=np.float64)
74
+ radii = np.asarray(radii, dtype=np.float64)
75
+ if centers.ndim != 2 or centers.shape[1] != 3:
76
+ raise ValueError("centers must have shape (N, 3)")
77
+ if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
78
+ raise ValueError("radii must have shape (N,)")
79
+
80
+ for center, radius in zip(centers, radii):
81
+ self.set_sphere(center, float(radius), value=value)
82
+
83
+ def clamp_grid(self, min_val=0.0, max_val=1.0):
84
+ self.grid = cp.clip(self.grid, min_val, max_val)
85
+
86
+ def synchronize(self):
87
+ """Synchronize the current CuPy device."""
88
+ cp.cuda.Stream.null.synchronize()
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from numba import njit, prange
5
+
6
+ from .voxelgrid import VoxelGrid, _cached_sphere_offsets
7
+
8
+
9
+ @njit
10
+ def _set_sphere_offsets(grid, center_idx, offsets, value):
11
+ nx, ny, nz = grid.shape
12
+ for n in range(offsets.shape[0]):
13
+ x = (center_idx[0] + offsets[n, 0]) % nx
14
+ y = (center_idx[1] + offsets[n, 1]) % ny
15
+ z = (center_idx[2] + offsets[n, 2]) % nz
16
+ grid[x, y, z] = value
17
+
18
+
19
+ @njit
20
+ def _add_sphere_offsets(grid, center_idx, offsets, value):
21
+ nx, ny, nz = grid.shape
22
+ for n in range(offsets.shape[0]):
23
+ x = (center_idx[0] + offsets[n, 0]) % nx
24
+ y = (center_idx[1] + offsets[n, 1]) % ny
25
+ z = (center_idx[2] + offsets[n, 2]) % nz
26
+ grid[x, y, z] += value
27
+
28
+
29
+ @njit
30
+ def _mul_sphere_offsets(grid, center_idx, offsets, factor):
31
+ nx, ny, nz = grid.shape
32
+ for n in range(offsets.shape[0]):
33
+ x = (center_idx[0] + offsets[n, 0]) % nx
34
+ y = (center_idx[1] + offsets[n, 1]) % ny
35
+ z = (center_idx[2] + offsets[n, 2]) % nz
36
+ grid[x, y, z] *= factor
37
+
38
+
39
+ @njit
40
+ def _div_sphere_offsets(grid, center_idx, offsets, divisor):
41
+ nx, ny, nz = grid.shape
42
+ for n in range(offsets.shape[0]):
43
+ x = (center_idx[0] + offsets[n, 0]) % nx
44
+ y = (center_idx[1] + offsets[n, 1]) % ny
45
+ z = (center_idx[2] + offsets[n, 2]) % nz
46
+ grid[x, y, z] /= divisor
47
+
48
+
49
+ @njit
50
+ def _set_many_sphere_offsets(grid, center_indices, offsets, value):
51
+ nx, ny, nz = grid.shape
52
+ for c in range(center_indices.shape[0]):
53
+ cx = center_indices[c, 0]
54
+ cy = center_indices[c, 1]
55
+ cz = center_indices[c, 2]
56
+ for n in range(offsets.shape[0]):
57
+ x = (cx + offsets[n, 0]) % nx
58
+ y = (cy + offsets[n, 1]) % ny
59
+ z = (cz + offsets[n, 2]) % nz
60
+ grid[x, y, z] = value
61
+
62
+
63
+ @njit
64
+ def _add_many_sphere_offsets(grid, center_indices, offsets, value):
65
+ nx, ny, nz = grid.shape
66
+ for c in range(center_indices.shape[0]):
67
+ cx = center_indices[c, 0]
68
+ cy = center_indices[c, 1]
69
+ cz = center_indices[c, 2]
70
+ for n in range(offsets.shape[0]):
71
+ x = (cx + offsets[n, 0]) % nx
72
+ y = (cy + offsets[n, 1]) % ny
73
+ z = (cz + offsets[n, 2]) % nz
74
+ grid[x, y, z] += value
75
+
76
+
77
+ @njit(parallel=True)
78
+ def _clamp_grid(grid, min_val, max_val):
79
+ nx, ny, nz = grid.shape
80
+ for i in prange(nx):
81
+ for j in range(ny):
82
+ for k in range(nz):
83
+ v = grid[i, j, k]
84
+ if v < min_val:
85
+ grid[i, j, k] = min_val
86
+ elif v > max_val:
87
+ grid[i, j, k] = max_val
88
+
89
+
90
+ class VoxelGridNumba(VoxelGrid):
91
+ """Voxel grid with Numba-compiled mutation kernels."""
92
+
93
+ backend_name = "numba"
94
+
95
+ def set_sphere(self, center, radius, value=1):
96
+ center_idx = self._center_index(center)
97
+ offsets = self._sphere_offsets(radius)
98
+ _set_sphere_offsets(self.grid, center_idx, offsets, value)
99
+
100
+ def add_sphere(self, center, radius, value=1):
101
+ center_idx = self._center_index(center)
102
+ offsets = self._sphere_offsets(radius)
103
+ _add_sphere_offsets(self.grid, center_idx, offsets, value)
104
+
105
+ def mul_sphere(self, center, radius, factor=2):
106
+ center_idx = self._center_index(center)
107
+ offsets = self._sphere_offsets(radius)
108
+ _mul_sphere_offsets(self.grid, center_idx, offsets, factor)
109
+
110
+ def div_sphere(self, center, radius, factor=2):
111
+ center_idx = self._center_index(center)
112
+ offsets = self._sphere_offsets(radius)
113
+ _div_sphere_offsets(self.grid, center_idx, offsets, factor)
114
+
115
+ def add_spheres(self, centers, radii, value=1):
116
+ centers, radii = self._validate_spheres(centers, radii)
117
+ center_indices = self.positions_to_indices(centers)
118
+ for radius in np.unique(radii):
119
+ offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
120
+ subset = center_indices[radii == radius]
121
+ _add_many_sphere_offsets(self.grid, subset, offsets, value)
122
+
123
+ def set_spheres(self, centers, radii, value=1):
124
+ centers, radii = self._validate_spheres(centers, radii)
125
+ center_indices = self.positions_to_indices(centers)
126
+ for radius in np.unique(radii):
127
+ offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
128
+ subset = center_indices[radii == radius]
129
+ _set_many_sphere_offsets(self.grid, subset, offsets, value)
130
+
131
+ def clamp_grid(self, min_val=0.0, max_val=1.0):
132
+ _clamp_grid(self.grid, min_val, max_val)
133
+
134
+
135
+ __all__ = ["VoxelGridNumba"]
@@ -0,0 +1,157 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ from .voxelgrid import VoxelGrid, _cached_sphere_offsets
7
+
8
+ _default_cache = Path.home() / ".cache"
9
+ if not os.access(_default_cache, os.W_OK):
10
+ os.environ.setdefault("XDG_CACHE_HOME", "/tmp/.cache")
11
+ os.environ.setdefault("TI_CACHE_HOME", "/tmp/taichi-cache")
12
+
13
+ try:
14
+ import taichi as ti
15
+ except ImportError as exc: # pragma: no cover - depends on optional dependency
16
+ raise ImportError(
17
+ "VoxelGridTaichi requires Taichi. Install it with `pip install AtomVoxelizer[taichi]`."
18
+ ) from exc
19
+
20
+
21
+ def _init_taichi_cpu_once():
22
+ try:
23
+ runtime = ti.lang.impl.get_runtime()
24
+ if runtime.prog is not None:
25
+ return
26
+ except Exception:
27
+ pass
28
+ ti.init(arch=ti.cpu, offline_cache=False)
29
+
30
+
31
+ _init_taichi_cpu_once()
32
+
33
+
34
+ @ti.kernel
35
+ def _set_sphere_offsets(
36
+ grid: ti.template(),
37
+ center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
38
+ offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
39
+ value: ti.f32,
40
+ ):
41
+ for n in range(offsets.shape[0]):
42
+ x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
43
+ y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
44
+ z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
45
+ grid[x, y, z] = value
46
+
47
+
48
+ @ti.kernel
49
+ def _add_sphere_offsets(
50
+ grid: ti.template(),
51
+ center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
52
+ offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
53
+ value: ti.f32,
54
+ ):
55
+ for n in range(offsets.shape[0]):
56
+ x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
57
+ y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
58
+ z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
59
+ grid[x, y, z] += value
60
+
61
+
62
+ @ti.kernel
63
+ def _mul_sphere_offsets(
64
+ grid: ti.template(),
65
+ center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
66
+ offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
67
+ factor: ti.f32,
68
+ ):
69
+ for n in range(offsets.shape[0]):
70
+ x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
71
+ y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
72
+ z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
73
+ grid[x, y, z] *= factor
74
+
75
+
76
+ @ti.kernel
77
+ def _div_sphere_offsets(
78
+ grid: ti.template(),
79
+ center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
80
+ offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
81
+ divisor: ti.f32,
82
+ ):
83
+ for n in range(offsets.shape[0]):
84
+ x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
85
+ y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
86
+ z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
87
+ grid[x, y, z] /= divisor
88
+
89
+
90
+ @ti.kernel
91
+ def _clamp_grid(grid: ti.template(), min_val: ti.f32, max_val: ti.f32):
92
+ for i, j, k in ti.ndrange(grid.shape[0], grid.shape[1], grid.shape[2]):
93
+ value = grid[i, j, k]
94
+ if value < min_val:
95
+ grid[i, j, k] = min_val
96
+ elif value > max_val:
97
+ grid[i, j, k] = max_val
98
+
99
+
100
+ class VoxelGridTaichi(VoxelGrid):
101
+ """Voxel grid with Taichi CPU kernels for mutating sphere operations."""
102
+
103
+ backend_name = "taichi-cpu"
104
+
105
+ def __init__(self, cell, resolution=None, gpts=None):
106
+ super().__init__(cell=cell, resolution=resolution, gpts=gpts)
107
+ self.grid = ti.field(dtype=ti.f32, shape=tuple(int(x) for x in self.gpts))
108
+
109
+ def to_numpy(self):
110
+ """Return the voxel values as a NumPy array."""
111
+ return self.grid.to_numpy()
112
+
113
+ def set_sphere(self, center, radius, value=1):
114
+ center_idx = self._center_index(center)
115
+ offsets = self._sphere_offsets(radius)
116
+ _set_sphere_offsets(self.grid, center_idx, offsets, float(value))
117
+
118
+ def add_sphere(self, center, radius, value=1):
119
+ center_idx = self._center_index(center)
120
+ offsets = self._sphere_offsets(radius)
121
+ _add_sphere_offsets(self.grid, center_idx, offsets, float(value))
122
+
123
+ def mul_sphere(self, center, radius, factor=2):
124
+ center_idx = self._center_index(center)
125
+ offsets = self._sphere_offsets(radius)
126
+ _mul_sphere_offsets(self.grid, center_idx, offsets, float(factor))
127
+
128
+ def div_sphere(self, center, radius, factor=2):
129
+ center_idx = self._center_index(center)
130
+ offsets = self._sphere_offsets(radius)
131
+ _div_sphere_offsets(self.grid, center_idx, offsets, float(factor))
132
+
133
+ def add_spheres(self, centers, radii, value=1):
134
+ centers, radii = self._validate_spheres(centers, radii)
135
+ center_indices = self.positions_to_indices(centers)
136
+ for radius in np.unique(radii):
137
+ offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
138
+ for center_idx in center_indices[radii == radius]:
139
+ _add_sphere_offsets(self.grid, center_idx, offsets, float(value))
140
+
141
+ def set_spheres(self, centers, radii, value=1):
142
+ centers, radii = self._validate_spheres(centers, radii)
143
+ center_indices = self.positions_to_indices(centers)
144
+ for radius in np.unique(radii):
145
+ offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
146
+ for center_idx in center_indices[radii == radius]:
147
+ _set_sphere_offsets(self.grid, center_idx, offsets, float(value))
148
+
149
+ def clamp_grid(self, min_val=0.0, max_val=1.0):
150
+ _clamp_grid(self.grid, float(min_val), float(max_val))
151
+
152
+ def synchronize(self):
153
+ """Synchronize Taichi kernels."""
154
+ ti.sync()
155
+
156
+
157
+ __all__ = ["VoxelGridTaichi"]
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+
5
+ import numpy as np
6
+
7
+
8
+ @lru_cache(maxsize=50)
9
+ def _cached_sphere_mask(radius, gpts, cell):
10
+ nx, ny, nz = gpts
11
+ ix, iy, iz = np.meshgrid(
12
+ np.arange(nx),
13
+ np.arange(ny),
14
+ np.arange(nz),
15
+ indexing="ij",
16
+ )
17
+ frac_coords = (np.stack([ix, iy, iz], axis=-1) + 0.5) / gpts
18
+ center_frac = np.array([0.5, 0.5, 0.5])
19
+ disp_frac = frac_coords - center_frac
20
+ disp_frac -= np.round(disp_frac)
21
+ disp_mic = disp_frac @ cell
22
+ dist2 = np.sum(disp_mic**2, axis=-1)
23
+ return dist2 <= radius**2
24
+
25
+
26
+ @lru_cache(maxsize=200)
27
+ def _cached_sphere_offsets(radius, gpts, cell):
28
+ gpts_arr = np.array(gpts, dtype=np.int32)
29
+ cell_arr = np.array(cell, dtype=np.float64)
30
+ lengths = np.linalg.norm(cell_arr, axis=1)
31
+ max_offsets = np.ceil(radius / lengths * gpts_arr).astype(np.int32)
32
+
33
+ offsets = []
34
+ for dx in range(-max_offsets[0], max_offsets[0] + 1):
35
+ for dy in range(-max_offsets[1], max_offsets[1] + 1):
36
+ for dz in range(-max_offsets[2], max_offsets[2] + 1):
37
+ disp_frac = np.array([dx, dy, dz], dtype=np.float64) / gpts_arr
38
+ disp = disp_frac @ cell_arr
39
+ if np.dot(disp, disp) <= radius**2:
40
+ offsets.append((dx, dy, dz))
41
+
42
+ return np.array(offsets, dtype=np.int32)
43
+
44
+
45
+ class VoxelGrid:
46
+ """Periodic voxel grid implemented with NumPy only."""
47
+
48
+ backend_name = "numpy"
49
+
50
+ def __init__(self, cell, resolution=None, gpts=None):
51
+ self.cell = np.array(cell, dtype=np.float64)
52
+ self.cell_inv = np.linalg.inv(self.cell)
53
+
54
+ if resolution is None and gpts is None:
55
+ raise ValueError("Either resolution or gpts must be specified")
56
+ if resolution is not None and gpts is not None:
57
+ raise ValueError("Only one of resolution or gpts can be specified")
58
+
59
+ lengths = np.linalg.norm(self.cell, axis=1)
60
+ if resolution is not None:
61
+ self.gpts = np.ceil(lengths / resolution).astype(int)
62
+ else:
63
+ self.gpts = np.array(gpts, dtype=int)
64
+ self.resolution = lengths / self.gpts
65
+
66
+ self.grid = np.zeros(tuple(self.gpts), dtype=np.float32)
67
+
68
+ def to_numpy(self):
69
+ """Return the voxel values as a NumPy array."""
70
+ return self.grid
71
+
72
+ def position_to_index(self, r):
73
+ """Convert real-space position to voxel index using periodic wrapping."""
74
+ frac = np.asarray(r, dtype=np.float64) @ self.cell_inv
75
+ frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
76
+ idx = np.floor(frac_wrapped * self.gpts).astype(int)
77
+ return tuple(idx)
78
+
79
+ def index_to_position(self, i, j, k):
80
+ """Convert a grid index to the real-space voxel center."""
81
+ frac = (np.array([i, j, k]) + 0.5) / self.gpts
82
+ return frac @ self.cell
83
+
84
+ def _center_index(self, center):
85
+ center_frac = np.asarray(center, dtype=np.float64) @ self.cell_inv % 1.0
86
+ return np.floor(center_frac * self.gpts).astype(np.int32)
87
+
88
+ def _offset_indices(self, center_idx, offsets):
89
+ indices = (offsets + center_idx) % self.gpts
90
+ return tuple(indices[:, axis] for axis in range(3))
91
+
92
+ def _sphere_offsets(self, radius):
93
+ return _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
94
+
95
+ def _sphere_indices(self, center, radius):
96
+ return self._offset_indices(self._center_index(center), self._sphere_offsets(radius))
97
+
98
+ def set_sphere(self, center, radius, value=1):
99
+ self.grid[self._sphere_indices(center, radius)] = value
100
+
101
+ def add_sphere(self, center, radius, value=1):
102
+ np.add.at(self.grid, self._sphere_indices(center, radius), value)
103
+
104
+ def mul_sphere(self, center, radius, factor=2):
105
+ self.grid[self._sphere_indices(center, radius)] *= factor
106
+
107
+ def div_sphere(self, center, radius, factor=2):
108
+ self.grid[self._sphere_indices(center, radius)] /= factor
109
+
110
+ def positions_to_indices(self, positions):
111
+ positions = np.asarray(positions, dtype=np.float64)
112
+ frac = positions @ self.cell_inv
113
+ frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
114
+ return np.floor(frac_wrapped * self.gpts).astype(np.int32)
115
+
116
+ def _validate_spheres(self, centers, radii):
117
+ centers = np.asarray(centers, dtype=np.float64)
118
+ radii = np.asarray(radii, dtype=np.float64)
119
+ if centers.ndim != 2 or centers.shape[1] != 3:
120
+ raise ValueError("centers must have shape (N, 3)")
121
+ if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
122
+ raise ValueError("radii must have shape (N,)")
123
+ return centers, radii
124
+
125
+ def add_spheres(self, centers, radii, value=1):
126
+ centers, radii = self._validate_spheres(centers, radii)
127
+ for center, radius in zip(centers, radii):
128
+ self.add_sphere(center, radius, value=value)
129
+
130
+ def set_spheres(self, centers, radii, value=1):
131
+ centers, radii = self._validate_spheres(centers, radii)
132
+ for center, radius in zip(centers, radii):
133
+ self.set_sphere(center, radius, value=value)
134
+
135
+ def clamp_grid(self, min_val=0.0, max_val=1.0):
136
+ np.clip(self.grid, min_val, max_val, out=self.grid)
137
+
138
+ def sample_voxels_in_range(self, min_val=0.0, max_val=1.0, min_dist=0.0, return_indices=False, seed=None):
139
+ """
140
+ Yield voxel positions or indices whose values lie in [min_val, max_val].
141
+
142
+ When returning real-space positions, ``min_dist`` enforces a minimum
143
+ Euclidean separation in Angstrom between yielded samples.
144
+ """
145
+ rng = np.random.default_rng(seed)
146
+ grid = self.to_numpy()
147
+ mask = (grid >= min_val) & (grid <= max_val)
148
+ candidates = np.argwhere(mask)
149
+
150
+ if candidates.shape[0] == 0:
151
+ raise ValueError("No voxels in specified value range.")
152
+ if return_indices and min_dist > 0:
153
+ raise ValueError("min_dist only supported when return_indices=False")
154
+
155
+ positions = candidates if return_indices else np.array([self.index_to_position(*idx) for idx in candidates])
156
+ selected = []
157
+ indices = rng.permutation(len(positions))
158
+ min_dist2 = min_dist**2
159
+
160
+ for i in indices:
161
+ pos = positions[i]
162
+ if min_dist > 0 and selected:
163
+ d2 = np.sum((np.array(selected) - pos) ** 2, axis=1)
164
+ if np.any(d2 < min_dist2):
165
+ continue
166
+ selected.append(pos)
167
+ yield tuple(candidates[i]) if return_indices else pos
168
+
169
+ def plot_3D(self, threshold=0.1, s=5, draw_cell=True):
170
+ """Plot voxels with values above ``threshold`` in real space."""
171
+ import matplotlib.pyplot as plt
172
+
173
+ nx, ny, nz = self.gpts
174
+ ix, iy, iz = np.meshgrid(
175
+ np.arange(nx) + 0.5,
176
+ np.arange(ny) + 0.5,
177
+ np.arange(nz) + 0.5,
178
+ indexing="ij",
179
+ )
180
+
181
+ frac_coords = np.stack([ix / nx, iy / ny, iz / nz], axis=-1)
182
+ real_coords = frac_coords @ self.cell
183
+ grid = self.to_numpy()
184
+ mask = grid > threshold
185
+ xyz = real_coords[mask]
186
+ values = grid[mask]
187
+
188
+ fig = plt.figure()
189
+ ax = fig.add_subplot(projection="3d")
190
+ p = ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], c=values, cmap="viridis", s=s)
191
+ fig.colorbar(p, ax=ax, label="Voxel value")
192
+
193
+ if draw_cell:
194
+ corners_frac = np.array(
195
+ [
196
+ [0, 0, 0],
197
+ [1, 0, 0],
198
+ [0, 1, 0],
199
+ [0, 0, 1],
200
+ [1, 1, 0],
201
+ [1, 0, 1],
202
+ [0, 1, 1],
203
+ [1, 1, 1],
204
+ ]
205
+ )
206
+ corners = corners_frac @ self.cell
207
+ edges = [
208
+ (0, 1),
209
+ (0, 2),
210
+ (0, 3),
211
+ (1, 4),
212
+ (1, 5),
213
+ (2, 4),
214
+ (2, 6),
215
+ (3, 5),
216
+ (3, 6),
217
+ (4, 7),
218
+ (5, 7),
219
+ (6, 7),
220
+ ]
221
+ for i, j in edges:
222
+ ax.plot(
223
+ [corners[i, 0], corners[j, 0]],
224
+ [corners[i, 1], corners[j, 1]],
225
+ [corners[i, 2], corners[j, 2]],
226
+ color="black",
227
+ )
228
+
229
+ all_coords = np.concatenate([xyz, corners]) if draw_cell else xyz
230
+ xlim = [all_coords[:, 0].min(), all_coords[:, 0].max()]
231
+ ylim = [all_coords[:, 1].min(), all_coords[:, 1].max()]
232
+ zlim = [all_coords[:, 2].min(), all_coords[:, 2].max()]
233
+ max_range = max(xlim[1] - xlim[0], ylim[1] - ylim[0], zlim[1] - zlim[0]) / 2.0
234
+ mid_x, mid_y, mid_z = np.mean(xlim), np.mean(ylim), np.mean(zlim)
235
+
236
+ ax.set_xlim(mid_x - max_range, mid_x + max_range)
237
+ ax.set_ylim(mid_y - max_range, mid_y + max_range)
238
+ ax.set_zlim(mid_z - max_range, mid_z + max_range)
239
+ ax.set_xlabel("x")
240
+ ax.set_ylabel("y")
241
+ ax.set_zlabel("z")
242
+ plt.tight_layout()
243
+ plt.show()
244
+
245
+ def plot_2D(self, axis="z", index=None, position=None, threshold=0.1, draw_cell=True, real_space=True):
246
+ """Plot a 2D slice of the voxel grid along ``axis``."""
247
+ import matplotlib.pyplot as plt
248
+
249
+ ax_map = {"x": 0, "y": 1, "z": 2}
250
+ if axis not in ax_map:
251
+ raise ValueError("Axis must be 'x', 'y', or 'z'")
252
+ ax_idx = ax_map[axis]
253
+
254
+ if index is not None and position is not None:
255
+ raise ValueError("Specify either `index` or `position`, not both")
256
+ if position is not None:
257
+ index = self.position_to_index(np.eye(3)[ax_idx] * position)[ax_idx]
258
+ if index is None:
259
+ index = self.gpts[ax_idx] // 2
260
+
261
+ shape = self.grid.shape
262
+ if not (0 <= index < shape[ax_idx]):
263
+ raise IndexError(f"{axis}-index {index} out of bounds (0 to {shape[ax_idx] - 1})")
264
+
265
+ axes = [0, 1, 2]
266
+ axes.remove(ax_idx)
267
+ ax1, ax2 = axes
268
+
269
+ slicers = [slice(None)] * 3
270
+ slicers[ax_idx] = index
271
+ slice_grid = self.to_numpy()[tuple(slicers)]
272
+
273
+ n1, n2 = self.gpts[ax1], self.gpts[ax2]
274
+ if real_space:
275
+ i1 = (np.arange(n1) + 0.5) / n1
276
+ i2 = (np.arange(n2) + 0.5) / n2
277
+ coords = np.meshgrid(i1, i2, indexing="ij")
278
+ frac_coords = np.stack(coords, axis=-1)
279
+ xy = frac_coords @ self.cell[[ax1, ax2], :]
280
+ xvals, yvals = xy[..., 0], xy[..., 1]
281
+ else:
282
+ xvals, yvals = np.meshgrid(np.arange(n1), np.arange(n2), indexing="ij")
283
+
284
+ mask = slice_grid > threshold
285
+ fig, ax = plt.subplots()
286
+ sc = ax.scatter(xvals[mask], yvals[mask], c=slice_grid[mask], cmap="viridis", s=10)
287
+ fig.colorbar(sc, ax=ax, label="Voxel value")
288
+
289
+ if draw_cell and real_space:
290
+ corners_frac = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
291
+ corners_real = corners_frac @ self.cell[[ax1, ax2], :]
292
+ ax.plot(corners_real[:, 0], corners_real[:, 1], "k--", lw=1)
293
+
294
+ ax.set_xlabel(f'{["x", "y", "z"][ax1]}' + (" [Angstrom]" if real_space else " (voxel)"))
295
+ ax.set_ylabel(f'{["x", "y", "z"][ax2]}' + (" [Angstrom]" if real_space else " (voxel)"))
296
+ ax.set_title(f"{axis.upper()} Slice at index {index}")
297
+ ax.set_aspect("equal")
298
+ plt.tight_layout()
299
+ plt.show()
300
+
301
+ def __repr__(self):
302
+ return f"VoxelGrid\n{self.cell} Cell\n{self.resolution} Resolution\n{self.gpts} gpts"
303
+
304
+
305
+ VoxelGridNumPy = VoxelGrid
306
+
307
+
308
+ __all__ = ["VoxelGrid", "VoxelGridNumPy", "_cached_sphere_mask", "_cached_sphere_offsets"]
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: AtomVoxelizer
3
+ Version: 0.1.0
4
+ Summary: Periodic atom-centered voxel grids for atomistic structures.
5
+ Author: AtomVoxelizer contributors
6
+ Project-URL: Homepage, https://gitlab.com/tgmaxson/atomvoxelizer
7
+ Project-URL: Documentation, https://atomvoxelizer.readthedocs.io/
8
+ Project-URL: Repository, https://gitlab.com/tgmaxson/atomvoxelizer
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
19
+ Classifier: Topic :: Scientific/Engineering :: Physics
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: matplotlib
23
+ Requires-Dist: numpy
24
+ Provides-Extra: numba
25
+ Requires-Dist: numba; extra == "numba"
26
+ Provides-Extra: cupy
27
+ Requires-Dist: cupy; extra == "cupy"
28
+ Requires-Dist: numba; extra == "cupy"
29
+ Provides-Extra: taichi
30
+ Requires-Dist: taichi; extra == "taichi"
31
+ Provides-Extra: analysis
32
+ Requires-Dist: scikit-image; extra == "analysis"
33
+ Provides-Extra: examples
34
+ Requires-Dist: ase; extra == "examples"
35
+ Requires-Dist: requests; extra == "examples"
36
+ Provides-Extra: docs
37
+ Requires-Dist: sphinx; extra == "docs"
38
+ Provides-Extra: dev
39
+ Requires-Dist: ase; extra == "dev"
40
+ Requires-Dist: numba; extra == "dev"
41
+ Requires-Dist: pytest; extra == "dev"
42
+ Requires-Dist: requests; extra == "dev"
43
+ Requires-Dist: scikit-image; extra == "dev"
44
+ Requires-Dist: sphinx; extra == "dev"
45
+ Requires-Dist: taichi; extra == "dev"
46
+ Provides-Extra: bench
47
+ Requires-Dist: ase; extra == "bench"
48
+ Requires-Dist: numba; extra == "bench"
49
+ Requires-Dist: taichi; extra == "bench"
50
+ Provides-Extra: publish
51
+ Requires-Dist: build; extra == "publish"
52
+ Requires-Dist: twine; extra == "publish"
53
+
54
+ # AtomVoxelizer
55
+
56
+ AtomVoxelizer builds periodic atom-centered voxel grids for atomistic structures.
57
+ The core `VoxelGrid` class stores a 3D NumPy grid over a periodic cell and provides
58
+ helpers for adding, setting, scaling, sampling, and plotting spherical regions.
59
+
60
+ ## Installation
61
+
62
+ Install from this repository:
63
+
64
+ ```bash
65
+ pip install .
66
+ ```
67
+
68
+ Install optional acceleration backends with extras:
69
+
70
+ ```bash
71
+ pip install ".[numba]"
72
+ pip install ".[taichi]"
73
+ pip install ".[cupy]"
74
+ pip install ".[analysis]"
75
+ ```
76
+
77
+ `VoxelGrid` is always the NumPy backend. Optional acceleration backends are
78
+ explicit: `VoxelGridNumba`, `VoxelGridTaichi`, and `VoxelGridCuPy`.
79
+ `VoxelGridAnalysis` provides connected-volume and marching-cubes surface-area
80
+ analysis when the `analysis` extra is installed.
81
+
82
+ For development, examples, tests, and documentation:
83
+
84
+ ```bash
85
+ pip install -e ".[dev,examples]"
86
+ ```
87
+
88
+ ## Basic Usage
89
+
90
+ ```python
91
+ import numpy as np
92
+
93
+ from atomvoxelizer import VoxelGrid
94
+
95
+ cell = np.eye(3) * 10.0
96
+ grid = VoxelGrid(cell=cell, resolution=0.25)
97
+
98
+ grid.add_sphere(center=np.array([5.0, 5.0, 5.0]), radius=1.0, value=1.0)
99
+ grid.set_sphere(center=np.array([2.0, 2.0, 2.0]), radius=0.5, value=-1.0)
100
+ grid.clamp_grid(min_val=-1.0, max_val=1.0)
101
+ ```
102
+
103
+ ## Zeolite Example
104
+
105
+ The zeolite example and CIF files live in `examples/`.
106
+
107
+ ```bash
108
+ pip install -e ".[examples]"
109
+ python examples/zeolite_voxel.py BEA
110
+ ```
111
+
112
+ The script reads a framework CIF, builds voxel grids at several resolutions, plots
113
+ middle XZ slices, benchmarks supercell scaling, and opens a 3D scatter plot.
114
+
115
+ The analysis example estimates pore volume and internal surface area:
116
+
117
+ ```bash
118
+ pip install -e ".[examples,analysis]"
119
+ python examples/zeolite_analysis.py BEA --resolution 0.25
120
+ python examples/zeolite_analysis.py BEA --convergence 1.0 0.75 0.5 --plot bea_convergence.png
121
+ ```
122
+
123
+ ## Tests and Benchmarks
124
+
125
+ Run the correctness tests with:
126
+
127
+ ```bash
128
+ pytest
129
+ ```
130
+
131
+ Run the backend benchmark with:
132
+
133
+ ```bash
134
+ python benchmarks/benchmark_backends.py --backends numpy numba taichi cupy
135
+ ```
136
+
137
+ Run the built-in structure benchmarks for a zeolite and a roughly 1000 atom Wulff
138
+ construction with:
139
+
140
+ ```bash
141
+ python benchmarks/benchmark_structures.py
142
+ ```
143
+
144
+ Backends whose optional dependencies are not installed are reported as missing.
145
+
146
+ ## Documentation
147
+
148
+ Documentation is scaffolded with Sphinx for Read the Docs.
149
+
150
+ Build it locally with:
151
+
152
+ ```bash
153
+ pip install -e ".[docs]"
154
+ sphinx-build -b html docs/source docs/build/html
155
+ ```
156
+
157
+ Read the Docs can use `.readthedocs.yaml` directly.
158
+
159
+ ## Publishing
160
+
161
+ Build and check PyPI artifacts with:
162
+
163
+ ```bash
164
+ pip install -e ".[publish]"
165
+ python -m build
166
+ twine check dist/*
167
+ ```
168
+
169
+ Upload to TestPyPI first, then PyPI:
170
+
171
+ ```bash
172
+ twine upload --repository testpypi dist/*
173
+ twine upload dist/*
174
+ ```
@@ -0,0 +1,10 @@
1
+ atomvoxelizer/__init__.py,sha256=SA3jmOk191wtX3E_Zs3TbH9A56xzJ-VVStKgBE33eb4,741
2
+ atomvoxelizer/analysis.py,sha256=3NNXJvzPiZ8K52TZ89IPLahC3_PPb7KBMtuFnvPr2DE,7436
3
+ atomvoxelizer/cupy_backend.py,sha256=t95ZZmtIcsGT_A-2x9hlxdk_gX9ziU_t5bfwxzvhGl4,3404
4
+ atomvoxelizer/numba_backend.py,sha256=7jSrwcZOpzR6TnmdmLmeRC83KutlH_y_RZ4CoFhNLpQ,4680
5
+ atomvoxelizer/taichi_backend.py,sha256=NgnGkFAEcMdSx--EHkpo8QBd2ZxeF6ll_oD_FUSvleU,5449
6
+ atomvoxelizer/voxelgrid.py,sha256=HSSUAVDWLkXQ2rEhwgbZEFI4w1tdA8Zw9H6iy-Krb3c,11721
7
+ atomvoxelizer-0.1.0.dist-info/METADATA,sha256=M00nvGjj5ml1_R-o50AUbVJurArZb9szdZEeEwwYzs4,4760
8
+ atomvoxelizer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ atomvoxelizer-0.1.0.dist-info/top_level.txt,sha256=KiH4w3N7wUa8hwDdDLNwfgHHj8BK1uE5LSzb63tzA7E,14
10
+ atomvoxelizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ atomvoxelizer