atomforge-py 0.1.0__tar.gz
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.
- atomforge_py-0.1.0/PKG-INFO +70 -0
- atomforge_py-0.1.0/atomforge/__init__.py +21 -0
- atomforge_py-0.1.0/atomforge/_io.py +195 -0
- atomforge_py-0.1.0/atomforge/_structure.py +163 -0
- atomforge_py-0.1.0/atomforge/_viewer.py +97 -0
- atomforge_py-0.1.0/atomforge_py.egg-info/PKG-INFO +70 -0
- atomforge_py-0.1.0/atomforge_py.egg-info/SOURCES.txt +11 -0
- atomforge_py-0.1.0/atomforge_py.egg-info/dependency_links.txt +1 -0
- atomforge_py-0.1.0/atomforge_py.egg-info/requires.txt +3 -0
- atomforge_py-0.1.0/atomforge_py.egg-info/top_level.txt +1 -0
- atomforge_py-0.1.0/pyproject.toml +76 -0
- atomforge_py-0.1.0/setup.cfg +4 -0
- atomforge_py-0.1.0/setup.py +19 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atomforge-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python interface for AtomForge: load, edit, and view atomic structures
|
|
5
|
+
Author: AtomForge Contributors
|
|
6
|
+
Author-email: AtomForge Contributors <albert.hzbn@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/albert-hzbn/AtomForge
|
|
9
|
+
Project-URL: Repository, https://github.com/albert-hzbn/AtomForge
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/albert-hzbn/AtomForge/issues
|
|
11
|
+
Keywords: atomforge,atoms,crystal,materials,structure,viewer
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
Provides-Extra: ase
|
|
25
|
+
Requires-Dist: ase>=3.22; extra == "ase"
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
|
|
29
|
+
atomforge — Python interface for AtomForge
|
|
30
|
+
==========================================
|
|
31
|
+
|
|
32
|
+
Load, programmatically edit, and launch the AtomForge GUI to view atomic
|
|
33
|
+
structures directly from Python.
|
|
34
|
+
|
|
35
|
+
Quick-start
|
|
36
|
+
-----------
|
|
37
|
+
|
|
38
|
+
.. code-block:: python
|
|
39
|
+
|
|
40
|
+
import atomforge as af
|
|
41
|
+
|
|
42
|
+
# Load any format (XYZ/extXYZ natively; CIF/VASP/PDB via ase extra)
|
|
43
|
+
s = af.load("crystal.cif")
|
|
44
|
+
|
|
45
|
+
# Build from scratch
|
|
46
|
+
s2 = af.Structure()
|
|
47
|
+
s2.set_cell(2.87, 2.87, 2.87) # BCC iron unit cell
|
|
48
|
+
s2.add_atom("Fe", 0.0, 0.0, 0.0)
|
|
49
|
+
s2.add_atom("Fe", 1.435, 1.435, 1.435)
|
|
50
|
+
|
|
51
|
+
# Supercell + open in the AtomForge GUI (non-blocking)
|
|
52
|
+
s2.repeat(4, 4, 4).view()
|
|
53
|
+
|
|
54
|
+
# Edit and save
|
|
55
|
+
s2.translate(1, 0, 0).save("shifted.xyz")
|
|
56
|
+
|
|
57
|
+
Formats
|
|
58
|
+
-------
|
|
59
|
+
XYZ and extended-XYZ are handled natively with no dependencies.
|
|
60
|
+
CIF, VASP/POSCAR, PDB, LAMMPS, mol2, and all other ASE-supported formats
|
|
61
|
+
are available via the ``ase`` extra::
|
|
62
|
+
|
|
63
|
+
pip install "atomforge[ase]"
|
|
64
|
+
|
|
65
|
+
Viewing
|
|
66
|
+
-------
|
|
67
|
+
``structure.view()`` serialises the structure to a temporary XYZ file and
|
|
68
|
+
launches the AtomForge executable as a detached subprocess. Set the
|
|
69
|
+
``ATOMFORGE_PATH`` environment variable to the full path of ``AtomForge.exe``
|
|
70
|
+
if it is not on PATH.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
atomforge — Python interface for AtomForge structures.
|
|
3
|
+
|
|
4
|
+
Quick-start
|
|
5
|
+
-----------
|
|
6
|
+
>>> import atomforge as af
|
|
7
|
+
>>> s = af.load("my_crystal.cif")
|
|
8
|
+
>>> s.repeat(2, 2, 1).view() # open in AtomForge GUI
|
|
9
|
+
|
|
10
|
+
>>> s2 = af.Structure()
|
|
11
|
+
>>> s2.add_atom("Fe", 0, 0, 0)
|
|
12
|
+
>>> s2.add_atom("C", 1.8, 0, 0)
|
|
13
|
+
>>> s2.save("dimer.xyz")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._structure import Atom, Structure
|
|
17
|
+
from ._io import load, save
|
|
18
|
+
from ._viewer import view
|
|
19
|
+
|
|
20
|
+
__all__ = ["Atom", "Structure", "load", "save", "view"]
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""File I/O: native XYZ/extxyz, and optional ASE fallback for CIF/VASP/PDB/etc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Tuple
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ._structure import Structure
|
|
11
|
+
|
|
12
|
+
# ── CPK / Jmol colour table (RGB 0-1) ──────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
_COLORS: dict[str, Tuple[float, float, float]] = {
|
|
15
|
+
"H": (1.000, 1.000, 1.000), "He": (0.851, 1.000, 1.000),
|
|
16
|
+
"Li": (0.800, 0.502, 1.000), "Be": (0.761, 1.000, 0.000),
|
|
17
|
+
"B": (1.000, 0.710, 0.710), "C": (0.565, 0.565, 0.565),
|
|
18
|
+
"N": (0.188, 0.314, 0.973), "O": (1.000, 0.051, 0.051),
|
|
19
|
+
"F": (0.565, 0.878, 0.314), "Ne": (0.702, 0.890, 0.961),
|
|
20
|
+
"Na": (0.671, 0.361, 0.949), "Mg": (0.541, 1.000, 0.000),
|
|
21
|
+
"Al": (0.749, 0.651, 0.651), "Si": (0.941, 0.784, 0.627),
|
|
22
|
+
"P": (1.000, 0.502, 0.000), "S": (1.000, 1.000, 0.188),
|
|
23
|
+
"Cl": (0.122, 0.941, 0.122), "Ar": (0.502, 0.820, 0.890),
|
|
24
|
+
"K": (0.561, 0.251, 0.831), "Ca": (0.239, 1.000, 0.000),
|
|
25
|
+
"Sc": (0.902, 0.902, 0.902), "Ti": (0.749, 0.761, 0.780),
|
|
26
|
+
"V": (0.651, 0.651, 0.671), "Cr": (0.541, 0.600, 0.780),
|
|
27
|
+
"Mn": (0.612, 0.478, 0.780), "Fe": (0.878, 0.400, 0.200),
|
|
28
|
+
"Co": (0.941, 0.565, 0.627), "Ni": (0.314, 0.816, 0.314),
|
|
29
|
+
"Cu": (0.784, 0.502, 0.200), "Zn": (0.490, 0.502, 0.690),
|
|
30
|
+
"Ga": (0.761, 0.561, 0.561), "Ge": (0.400, 0.561, 0.561),
|
|
31
|
+
"As": (0.741, 0.502, 0.890), "Se": (1.000, 0.631, 0.000),
|
|
32
|
+
"Br": (0.651, 0.161, 0.161), "Kr": (0.361, 0.722, 0.820),
|
|
33
|
+
"Rb": (0.439, 0.180, 0.690), "Sr": (0.000, 1.000, 0.000),
|
|
34
|
+
"Y": (0.580, 1.000, 1.000), "Zr": (0.580, 0.878, 0.878),
|
|
35
|
+
"Nb": (0.451, 0.761, 0.788), "Mo": (0.329, 0.710, 0.710),
|
|
36
|
+
"Tc": (0.231, 0.620, 0.620), "Ru": (0.141, 0.561, 0.561),
|
|
37
|
+
"Rh": (0.039, 0.490, 0.549), "Pd": (0.000, 0.412, 0.522),
|
|
38
|
+
"Ag": (0.753, 0.753, 0.753), "Cd": (1.000, 0.851, 0.561),
|
|
39
|
+
"In": (0.651, 0.459, 0.451), "Sn": (0.400, 0.502, 0.502),
|
|
40
|
+
"Sb": (0.620, 0.388, 0.710), "Te": (0.831, 0.478, 0.000),
|
|
41
|
+
"I": (0.580, 0.000, 0.580), "Xe": (0.259, 0.620, 0.690),
|
|
42
|
+
"Cs": (0.341, 0.090, 0.561), "Ba": (0.000, 0.788, 0.000),
|
|
43
|
+
"La": (0.439, 0.831, 1.000), "Ce": (1.000, 1.000, 0.780),
|
|
44
|
+
"Pr": (0.851, 1.000, 0.780), "Nd": (0.780, 1.000, 0.780),
|
|
45
|
+
"Pm": (0.639, 1.000, 0.780), "Sm": (0.561, 1.000, 0.780),
|
|
46
|
+
"Eu": (0.380, 1.000, 0.780), "Gd": (0.271, 1.000, 0.780),
|
|
47
|
+
"Tb": (0.188, 1.000, 0.780), "Dy": (0.122, 1.000, 0.780),
|
|
48
|
+
"Ho": (0.000, 1.000, 0.612), "Er": (0.000, 0.902, 0.459),
|
|
49
|
+
"Tm": (0.000, 0.831, 0.322), "Yb": (0.000, 0.749, 0.220),
|
|
50
|
+
"Lu": (0.000, 0.671, 0.141), "Hf": (0.302, 0.761, 1.000),
|
|
51
|
+
"Ta": (0.302, 0.651, 1.000), "W": (0.129, 0.580, 0.839),
|
|
52
|
+
"Re": (0.149, 0.490, 0.671), "Os": (0.149, 0.400, 0.588),
|
|
53
|
+
"Ir": (0.090, 0.329, 0.529), "Pt": (0.816, 0.816, 0.878),
|
|
54
|
+
"Au": (1.000, 0.820, 0.137), "Hg": (0.722, 0.722, 0.816),
|
|
55
|
+
"Tl": (0.651, 0.329, 0.302), "Pb": (0.341, 0.349, 0.380),
|
|
56
|
+
"Bi": (0.620, 0.310, 0.710), "Po": (0.671, 0.361, 0.000),
|
|
57
|
+
"At": (0.459, 0.310, 0.271), "Rn": (0.259, 0.510, 0.588),
|
|
58
|
+
"Fr": (0.259, 0.000, 0.400), "Ra": (0.000, 0.490, 0.000),
|
|
59
|
+
"Ac": (0.439, 0.671, 0.980), "Th": (0.000, 0.729, 1.000),
|
|
60
|
+
"Pa": (0.000, 0.631, 1.000), "U": (0.000, 0.561, 1.000),
|
|
61
|
+
"Np": (0.000, 0.502, 1.000), "Pu": (0.000, 0.420, 1.000),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _default_color(symbol: str) -> Tuple[float, float, float]:
|
|
66
|
+
return _COLORS.get(symbol, (0.8, 0.8, 0.8))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── XYZ / extended-XYZ ─────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def _load_xyz(path: str) -> "Structure":
|
|
72
|
+
from ._structure import Atom, Structure
|
|
73
|
+
s = Structure()
|
|
74
|
+
with open(path, encoding="utf-8") as fh:
|
|
75
|
+
lines = fh.readlines()
|
|
76
|
+
if not lines:
|
|
77
|
+
return s
|
|
78
|
+
n = int(lines[0].strip())
|
|
79
|
+
comment = lines[1] if len(lines) > 1 else ""
|
|
80
|
+
|
|
81
|
+
# Parse extended-XYZ lattice from comment line
|
|
82
|
+
if 'Lattice=' in comment or 'lattice=' in comment:
|
|
83
|
+
import re
|
|
84
|
+
m = re.search(r'[Ll]attice="([^"]+)"', comment)
|
|
85
|
+
if m:
|
|
86
|
+
vals = list(map(float, m.group(1).split()))
|
|
87
|
+
if len(vals) == 9:
|
|
88
|
+
s.cell = [vals[0:3], vals[3:6], vals[6:9]]
|
|
89
|
+
|
|
90
|
+
atom_lines = lines[2:2 + n]
|
|
91
|
+
properties_order = ["species", "pos", "pos", "pos"]
|
|
92
|
+
if 'Properties=' in comment or 'properties=' in comment:
|
|
93
|
+
import re
|
|
94
|
+
m = re.search(r'[Pp]roperties=(\S+)', comment)
|
|
95
|
+
if m:
|
|
96
|
+
properties_order = m.group(1).split(":")
|
|
97
|
+
|
|
98
|
+
for line in atom_lines:
|
|
99
|
+
parts = line.split()
|
|
100
|
+
if not parts:
|
|
101
|
+
continue
|
|
102
|
+
sym = parts[0]
|
|
103
|
+
x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
|
|
104
|
+
cr, cg, cb = _default_color(sym)
|
|
105
|
+
s.atoms.append(Atom(sym, x, y, z, cr, cg, cb))
|
|
106
|
+
return s
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _save_xyz(s: "Structure", path: str) -> None:
|
|
110
|
+
lines = [str(len(s.atoms)) + "\n"]
|
|
111
|
+
if s.cell:
|
|
112
|
+
a, b, c = s.cell
|
|
113
|
+
lat = " ".join(f"{v:.6f}" for row in [a, b, c] for v in row)
|
|
114
|
+
lines.append(f'Lattice="{lat}" Properties=species:S:1:pos:R:3\n')
|
|
115
|
+
else:
|
|
116
|
+
lines.append("AtomForge structure\n")
|
|
117
|
+
for atom in s.atoms:
|
|
118
|
+
lines.append(f"{atom.symbol} {atom.x:.6f} {atom.y:.6f} {atom.z:.6f}\n")
|
|
119
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
120
|
+
fh.writelines(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── ASE fallback ────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _load_ase(path: str) -> "Structure":
|
|
126
|
+
try:
|
|
127
|
+
import ase.io
|
|
128
|
+
import numpy as np
|
|
129
|
+
except ImportError:
|
|
130
|
+
raise ImportError(
|
|
131
|
+
"Install the 'ase' package to load this file format:\n"
|
|
132
|
+
" pip install ase"
|
|
133
|
+
)
|
|
134
|
+
from ._structure import Atom, Structure
|
|
135
|
+
atoms_ase = ase.io.read(path)
|
|
136
|
+
s = Structure()
|
|
137
|
+
if atoms_ase.cell is not None and any(atoms_ase.pbc):
|
|
138
|
+
cell = np.array(atoms_ase.cell)
|
|
139
|
+
s.cell = [list(cell[0]), list(cell[1]), list(cell[2])]
|
|
140
|
+
for sym, (x, y, z) in zip(atoms_ase.get_chemical_symbols(),
|
|
141
|
+
atoms_ase.get_positions()):
|
|
142
|
+
cr, cg, cb = _default_color(sym)
|
|
143
|
+
s.atoms.append(Atom(sym, float(x), float(y), float(z), cr, cg, cb))
|
|
144
|
+
return s
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _save_ase(s: "Structure", path: str) -> None:
|
|
148
|
+
try:
|
|
149
|
+
import ase
|
|
150
|
+
import ase.io
|
|
151
|
+
import numpy as np
|
|
152
|
+
except ImportError:
|
|
153
|
+
raise ImportError(
|
|
154
|
+
"Install the 'ase' package to save this file format:\n"
|
|
155
|
+
" pip install ase"
|
|
156
|
+
)
|
|
157
|
+
syms = [a.symbol for a in s.atoms]
|
|
158
|
+
pos = np.array([[a.x, a.y, a.z] for a in s.atoms])
|
|
159
|
+
cell = np.array(s.cell) if s.cell else None
|
|
160
|
+
pbc = s.cell is not None
|
|
161
|
+
ase_atoms = ase.Atoms(symbols=syms, positions=pos, cell=cell, pbc=pbc)
|
|
162
|
+
ase.io.write(path, ase_atoms)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Format dispatch ─────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
_XYZ_EXTS = {".xyz", ".extxyz"}
|
|
168
|
+
_ASE_EXTS = {".cif", ".vasp", ".poscar", ".pdb", ".mol", ".mol2", ".lmp",
|
|
169
|
+
".lammps", ".dump", ".gen", ".sdf", ".cfg"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load(path: str) -> "Structure":
|
|
173
|
+
"""Load a structure from file. XYZ/extxyz is handled natively; other formats require ASE."""
|
|
174
|
+
ext = Path(path).suffix.lower()
|
|
175
|
+
if ext in _XYZ_EXTS:
|
|
176
|
+
return _load_xyz(path)
|
|
177
|
+
if ext in _ASE_EXTS or not ext:
|
|
178
|
+
return _load_ase(path)
|
|
179
|
+
# Try XYZ first, fall back to ASE
|
|
180
|
+
try:
|
|
181
|
+
return _load_xyz(path)
|
|
182
|
+
except Exception:
|
|
183
|
+
return _load_ase(path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def save(s: "Structure", path: str) -> None:
|
|
187
|
+
"""Save a structure to file. XYZ/extxyz is handled natively; other formats require ASE."""
|
|
188
|
+
ext = Path(path).suffix.lower()
|
|
189
|
+
if ext in _XYZ_EXTS:
|
|
190
|
+
_save_xyz(s, path)
|
|
191
|
+
elif ext in _ASE_EXTS:
|
|
192
|
+
_save_ase(s, path)
|
|
193
|
+
else:
|
|
194
|
+
# Default to XYZ
|
|
195
|
+
_save_xyz(s, path)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Core data types: Atom and Structure."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Atom:
|
|
11
|
+
"""A single atom with Cartesian coordinates (Angstrom) and display colour."""
|
|
12
|
+
|
|
13
|
+
symbol: str
|
|
14
|
+
x: float
|
|
15
|
+
y: float
|
|
16
|
+
z: float
|
|
17
|
+
r: float = field(default=1.0, repr=False)
|
|
18
|
+
g: float = field(default=1.0, repr=False)
|
|
19
|
+
b: float = field(default=1.0, repr=False)
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"Atom({self.symbol!r}, x={self.x:.4f}, y={self.y:.4f}, z={self.z:.4f})"
|
|
23
|
+
|
|
24
|
+
def copy(self) -> "Atom":
|
|
25
|
+
return copy.copy(self)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Structure:
|
|
29
|
+
"""
|
|
30
|
+
A collection of atoms with an optional periodic unit cell.
|
|
31
|
+
|
|
32
|
+
Attributes
|
|
33
|
+
----------
|
|
34
|
+
atoms : list[Atom]
|
|
35
|
+
All atoms in Cartesian coordinates (Angstrom).
|
|
36
|
+
cell : list[list[float]] | None
|
|
37
|
+
3×3 lattice vectors ``[[a1,a2,a3], [b1,b2,b3], [c1,c2,c3]]``
|
|
38
|
+
in Angstrom, or ``None`` for a non-periodic (cluster) structure.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self.atoms: List[Atom] = []
|
|
43
|
+
self.cell: Optional[List[List[float]]] = None
|
|
44
|
+
|
|
45
|
+
# ── Dunder helpers ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return len(self.atoms)
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
periodicity = "periodic" if self.cell else "non-periodic"
|
|
52
|
+
species: dict = {}
|
|
53
|
+
for a in self.atoms:
|
|
54
|
+
species[a.symbol] = species.get(a.symbol, 0) + 1
|
|
55
|
+
comp = " ".join(f"{s}{n}" for s, n in sorted(species.items()))
|
|
56
|
+
return f"Structure({len(self.atoms)} atoms, {comp}, {periodicity})"
|
|
57
|
+
|
|
58
|
+
def copy(self) -> "Structure":
|
|
59
|
+
s = Structure()
|
|
60
|
+
s.atoms = [a.copy() for a in self.atoms]
|
|
61
|
+
s.cell = [list(row) for row in self.cell] if self.cell else None
|
|
62
|
+
return s
|
|
63
|
+
|
|
64
|
+
# ── Atom editing ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def add_atom(self, symbol: str, x: float, y: float, z: float,
|
|
67
|
+
r: float = -1.0, g: float = -1.0, b: float = -1.0) -> Atom:
|
|
68
|
+
"""Append an atom and return it. Default colour is the element's CPK colour."""
|
|
69
|
+
from ._io import _default_color
|
|
70
|
+
cr, cg, cb = _default_color(symbol)
|
|
71
|
+
atom = Atom(symbol, float(x), float(y), float(z),
|
|
72
|
+
r=cr if r < 0 else r,
|
|
73
|
+
g=cg if g < 0 else g,
|
|
74
|
+
b=cb if b < 0 else b)
|
|
75
|
+
self.atoms.append(atom)
|
|
76
|
+
return atom
|
|
77
|
+
|
|
78
|
+
def remove_atom(self, index: int) -> Atom:
|
|
79
|
+
"""Remove and return the atom at *index*."""
|
|
80
|
+
return self.atoms.pop(index)
|
|
81
|
+
|
|
82
|
+
# ── Bulk transforms ─────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def translate(self, dx: float, dy: float, dz: float) -> "Structure":
|
|
85
|
+
"""Shift all atoms in-place by (dx, dy, dz) Angstrom. Returns self."""
|
|
86
|
+
for a in self.atoms:
|
|
87
|
+
a.x += dx
|
|
88
|
+
a.y += dy
|
|
89
|
+
a.z += dz
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def scale(self, factor: float) -> "Structure":
|
|
93
|
+
"""Scale all Cartesian coordinates and cell vectors by *factor*. Returns self."""
|
|
94
|
+
for a in self.atoms:
|
|
95
|
+
a.x *= factor
|
|
96
|
+
a.y *= factor
|
|
97
|
+
a.z *= factor
|
|
98
|
+
if self.cell:
|
|
99
|
+
self.cell = [[v * factor for v in row] for row in self.cell]
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def repeat(self, nx: int, ny: int = 1, nz: int = 1) -> "Structure":
|
|
103
|
+
"""Return a new supercell replicated *nx × ny × nz* times. Requires a unit cell."""
|
|
104
|
+
if not self.cell:
|
|
105
|
+
raise ValueError("repeat() requires a unit cell (set structure.cell first)")
|
|
106
|
+
a, b, c = self.cell
|
|
107
|
+
out = Structure()
|
|
108
|
+
out.cell = [
|
|
109
|
+
[a[i] * nx for i in range(3)],
|
|
110
|
+
[b[i] * ny for i in range(3)],
|
|
111
|
+
[c[i] * nz for i in range(3)],
|
|
112
|
+
]
|
|
113
|
+
for ix in range(nx):
|
|
114
|
+
for iy in range(ny):
|
|
115
|
+
for iz in range(nz):
|
|
116
|
+
for atom in self.atoms:
|
|
117
|
+
new_atom = atom.copy()
|
|
118
|
+
new_atom.x += a[0]*ix + b[0]*iy + c[0]*iz
|
|
119
|
+
new_atom.y += a[1]*ix + b[1]*iy + c[1]*iz
|
|
120
|
+
new_atom.z += a[2]*ix + b[2]*iy + c[2]*iz
|
|
121
|
+
out.atoms.append(new_atom)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def filter_species(self, *symbols: str) -> "Structure":
|
|
125
|
+
"""Return a new Structure containing only atoms of the given element symbols."""
|
|
126
|
+
keep = set(symbols)
|
|
127
|
+
out = self.copy()
|
|
128
|
+
out.atoms = [a for a in out.atoms if a.symbol in keep]
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def set_cell(self, a: float, b: float, c: float,
|
|
132
|
+
alpha: float = 90.0, beta: float = 90.0,
|
|
133
|
+
gamma: float = 90.0) -> "Structure":
|
|
134
|
+
"""
|
|
135
|
+
Set the unit cell from lattice parameters (lengths in Å, angles in degrees).
|
|
136
|
+
Uses the standard convention: **a** along x, **b** in the xy-plane.
|
|
137
|
+
Returns self.
|
|
138
|
+
"""
|
|
139
|
+
rad = math.pi / 180.0
|
|
140
|
+
ca = math.cos(alpha * rad)
|
|
141
|
+
cb = math.cos(beta * rad)
|
|
142
|
+
cg = math.cos(gamma * rad)
|
|
143
|
+
sg = math.sin(gamma * rad)
|
|
144
|
+
ax = a
|
|
145
|
+
bx = b * cg
|
|
146
|
+
by = b * sg
|
|
147
|
+
cx = c * cb
|
|
148
|
+
cy = c * (ca - cb * cg) / sg if sg > 1e-10 else 0.0
|
|
149
|
+
cz = math.sqrt(max(0.0, c * c - cx * cx - cy * cy))
|
|
150
|
+
self.cell = [[ax, 0.0, 0.0], [bx, by, 0.0], [cx, cy, cz]]
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
# ── Viewing / I/O ───────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def view(self) -> None:
|
|
156
|
+
"""Open this structure in the AtomForge GUI (non-blocking)."""
|
|
157
|
+
from ._viewer import view
|
|
158
|
+
view(self)
|
|
159
|
+
|
|
160
|
+
def save(self, path: str) -> None:
|
|
161
|
+
"""Save to file; format is inferred from the file extension."""
|
|
162
|
+
from ._io import save
|
|
163
|
+
save(self, path)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Launch the AtomForge GUI to view a Structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._structure import Structure
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_atomforge() -> Optional[str]:
|
|
17
|
+
"""Return the path to the AtomForge executable, or None if not found."""
|
|
18
|
+
# 1. Explicit env var
|
|
19
|
+
env = os.environ.get("ATOMFORGE_PATH")
|
|
20
|
+
if env and Path(env).is_file():
|
|
21
|
+
return env
|
|
22
|
+
|
|
23
|
+
# 2. Relative to this package file (handles unpacked release: python/ lives next to AtomForge.exe)
|
|
24
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
25
|
+
for candidate in [
|
|
26
|
+
pkg_dir / "AtomForge.exe",
|
|
27
|
+
pkg_dir / "AtomForge",
|
|
28
|
+
pkg_dir.parent / "AtomForge.exe",
|
|
29
|
+
pkg_dir.parent / "AtomForge",
|
|
30
|
+
pkg_dir.parent.parent / "AtomForge.exe",
|
|
31
|
+
pkg_dir.parent.parent / "AtomForge",
|
|
32
|
+
pkg_dir.parent.parent / "build" / "Release" / "AtomForge.exe",
|
|
33
|
+
pkg_dir.parent.parent / "build" / "AtomForge.exe",
|
|
34
|
+
pkg_dir.parent.parent / "build" / "AtomForge",
|
|
35
|
+
]:
|
|
36
|
+
if candidate.is_file():
|
|
37
|
+
return str(candidate)
|
|
38
|
+
|
|
39
|
+
# 3. System PATH
|
|
40
|
+
import shutil
|
|
41
|
+
found = shutil.which("AtomForge") or shutil.which("atomforge")
|
|
42
|
+
if found:
|
|
43
|
+
return found
|
|
44
|
+
|
|
45
|
+
# 4. Common Windows install paths
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
48
|
+
for root in [program_files, os.environ.get("ProgramFiles(x86)", "")]:
|
|
49
|
+
candidate = Path(root) / "AtomForge" / "AtomForge.exe"
|
|
50
|
+
if candidate.is_file():
|
|
51
|
+
return str(candidate)
|
|
52
|
+
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def view(s: "Structure") -> None:
|
|
57
|
+
"""
|
|
58
|
+
Open *s* in the AtomForge GUI (non-blocking).
|
|
59
|
+
|
|
60
|
+
The structure is serialised to a temporary extXYZ file and AtomForge is
|
|
61
|
+
launched as a detached subprocess. The temp file is cleaned up after
|
|
62
|
+
AtomForge exits (on POSIX) or left to the OS temp-file sweeper (Windows),
|
|
63
|
+
because AtomForge must be able to read the file after this function returns.
|
|
64
|
+
"""
|
|
65
|
+
exe = _find_atomforge()
|
|
66
|
+
if exe is None:
|
|
67
|
+
raise FileNotFoundError(
|
|
68
|
+
"AtomForge executable not found. Set the ATOMFORGE_PATH environment "
|
|
69
|
+
"variable to the full path of AtomForge.exe, or ensure it is on PATH."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Write to a named temp file that survives past this function
|
|
73
|
+
suffix = ".xyz"
|
|
74
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
75
|
+
prefix="atomforge_view_", suffix=suffix, delete=False
|
|
76
|
+
)
|
|
77
|
+
tmp_path = tmp.name
|
|
78
|
+
tmp.close()
|
|
79
|
+
|
|
80
|
+
from ._io import _save_xyz
|
|
81
|
+
_save_xyz(s, tmp_path)
|
|
82
|
+
|
|
83
|
+
if sys.platform == "win32":
|
|
84
|
+
# DETACHED_PROCESS: new console, parent death does not kill child
|
|
85
|
+
DETACHED_PROCESS = 0x00000008
|
|
86
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
87
|
+
subprocess.Popen(
|
|
88
|
+
[exe, tmp_path],
|
|
89
|
+
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
|
|
90
|
+
close_fds=True,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
subprocess.Popen(
|
|
94
|
+
[exe, tmp_path],
|
|
95
|
+
start_new_session=True,
|
|
96
|
+
close_fds=True,
|
|
97
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atomforge-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python interface for AtomForge: load, edit, and view atomic structures
|
|
5
|
+
Author: AtomForge Contributors
|
|
6
|
+
Author-email: AtomForge Contributors <albert.hzbn@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/albert-hzbn/AtomForge
|
|
9
|
+
Project-URL: Repository, https://github.com/albert-hzbn/AtomForge
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/albert-hzbn/AtomForge/issues
|
|
11
|
+
Keywords: atomforge,atoms,crystal,materials,structure,viewer
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
Provides-Extra: ase
|
|
25
|
+
Requires-Dist: ase>=3.22; extra == "ase"
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
|
|
29
|
+
atomforge — Python interface for AtomForge
|
|
30
|
+
==========================================
|
|
31
|
+
|
|
32
|
+
Load, programmatically edit, and launch the AtomForge GUI to view atomic
|
|
33
|
+
structures directly from Python.
|
|
34
|
+
|
|
35
|
+
Quick-start
|
|
36
|
+
-----------
|
|
37
|
+
|
|
38
|
+
.. code-block:: python
|
|
39
|
+
|
|
40
|
+
import atomforge as af
|
|
41
|
+
|
|
42
|
+
# Load any format (XYZ/extXYZ natively; CIF/VASP/PDB via ase extra)
|
|
43
|
+
s = af.load("crystal.cif")
|
|
44
|
+
|
|
45
|
+
# Build from scratch
|
|
46
|
+
s2 = af.Structure()
|
|
47
|
+
s2.set_cell(2.87, 2.87, 2.87) # BCC iron unit cell
|
|
48
|
+
s2.add_atom("Fe", 0.0, 0.0, 0.0)
|
|
49
|
+
s2.add_atom("Fe", 1.435, 1.435, 1.435)
|
|
50
|
+
|
|
51
|
+
# Supercell + open in the AtomForge GUI (non-blocking)
|
|
52
|
+
s2.repeat(4, 4, 4).view()
|
|
53
|
+
|
|
54
|
+
# Edit and save
|
|
55
|
+
s2.translate(1, 0, 0).save("shifted.xyz")
|
|
56
|
+
|
|
57
|
+
Formats
|
|
58
|
+
-------
|
|
59
|
+
XYZ and extended-XYZ are handled natively with no dependencies.
|
|
60
|
+
CIF, VASP/POSCAR, PDB, LAMMPS, mol2, and all other ASE-supported formats
|
|
61
|
+
are available via the ``ase`` extra::
|
|
62
|
+
|
|
63
|
+
pip install "atomforge[ase]"
|
|
64
|
+
|
|
65
|
+
Viewing
|
|
66
|
+
-------
|
|
67
|
+
``structure.view()`` serialises the structure to a temporary XYZ file and
|
|
68
|
+
launches the AtomForge executable as a detached subprocess. Set the
|
|
69
|
+
``ATOMFORGE_PATH`` environment variable to the full path of ``AtomForge.exe``
|
|
70
|
+
if it is not on PATH.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
setup.py
|
|
3
|
+
atomforge/__init__.py
|
|
4
|
+
atomforge/_io.py
|
|
5
|
+
atomforge/_structure.py
|
|
6
|
+
atomforge/_viewer.py
|
|
7
|
+
atomforge_py.egg-info/PKG-INFO
|
|
8
|
+
atomforge_py.egg-info/SOURCES.txt
|
|
9
|
+
atomforge_py.egg-info/dependency_links.txt
|
|
10
|
+
atomforge_py.egg-info/requires.txt
|
|
11
|
+
atomforge_py.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atomforge
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atomforge-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python interface for AtomForge: load, edit, and view atomic structures"
|
|
9
|
+
authors = [{ name = "AtomForge Contributors", email = "albert.hzbn@gmail.com" }]
|
|
10
|
+
readme = { text = """
|
|
11
|
+
atomforge — Python interface for AtomForge
|
|
12
|
+
==========================================
|
|
13
|
+
|
|
14
|
+
Load, programmatically edit, and launch the AtomForge GUI to view atomic
|
|
15
|
+
structures directly from Python.
|
|
16
|
+
|
|
17
|
+
Quick-start
|
|
18
|
+
-----------
|
|
19
|
+
|
|
20
|
+
.. code-block:: python
|
|
21
|
+
|
|
22
|
+
import atomforge as af
|
|
23
|
+
|
|
24
|
+
# Load any format (XYZ/extXYZ natively; CIF/VASP/PDB via ase extra)
|
|
25
|
+
s = af.load("crystal.cif")
|
|
26
|
+
|
|
27
|
+
# Build from scratch
|
|
28
|
+
s2 = af.Structure()
|
|
29
|
+
s2.set_cell(2.87, 2.87, 2.87) # BCC iron unit cell
|
|
30
|
+
s2.add_atom("Fe", 0.0, 0.0, 0.0)
|
|
31
|
+
s2.add_atom("Fe", 1.435, 1.435, 1.435)
|
|
32
|
+
|
|
33
|
+
# Supercell + open in the AtomForge GUI (non-blocking)
|
|
34
|
+
s2.repeat(4, 4, 4).view()
|
|
35
|
+
|
|
36
|
+
# Edit and save
|
|
37
|
+
s2.translate(1, 0, 0).save("shifted.xyz")
|
|
38
|
+
|
|
39
|
+
Formats
|
|
40
|
+
-------
|
|
41
|
+
XYZ and extended-XYZ are handled natively with no dependencies.
|
|
42
|
+
CIF, VASP/POSCAR, PDB, LAMMPS, mol2, and all other ASE-supported formats
|
|
43
|
+
are available via the ``ase`` extra::
|
|
44
|
+
|
|
45
|
+
pip install "atomforge[ase]"
|
|
46
|
+
|
|
47
|
+
Viewing
|
|
48
|
+
-------
|
|
49
|
+
``structure.view()`` serialises the structure to a temporary XYZ file and
|
|
50
|
+
launches the AtomForge executable as a detached subprocess. Set the
|
|
51
|
+
``ATOMFORGE_PATH`` environment variable to the full path of ``AtomForge.exe``
|
|
52
|
+
if it is not on PATH.
|
|
53
|
+
""", content-type = "text/x-rst" }
|
|
54
|
+
requires-python = ">=3.8"
|
|
55
|
+
license = "MIT"
|
|
56
|
+
keywords = ["atomforge", "atoms", "crystal", "materials", "structure", "viewer"]
|
|
57
|
+
classifiers = [
|
|
58
|
+
"Development Status :: 3 - Alpha",
|
|
59
|
+
"Intended Audience :: Science/Research",
|
|
60
|
+
"Programming Language :: Python :: 3",
|
|
61
|
+
"Programming Language :: Python :: 3.8",
|
|
62
|
+
"Programming Language :: Python :: 3.9",
|
|
63
|
+
"Programming Language :: Python :: 3.10",
|
|
64
|
+
"Programming Language :: Python :: 3.11",
|
|
65
|
+
"Programming Language :: Python :: 3.12",
|
|
66
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
67
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[project.optional-dependencies]
|
|
71
|
+
ase = ["ase>=3.22"]
|
|
72
|
+
|
|
73
|
+
[project.urls]
|
|
74
|
+
Homepage = "https://github.com/albert-hzbn/AtomForge"
|
|
75
|
+
Repository = "https://github.com/albert-hzbn/AtomForge"
|
|
76
|
+
"Bug Tracker" = "https://github.com/albert-hzbn/AtomForge/issues"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="atomforge-py",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="Python interface for AtomForge: load, edit, and view atomic structures",
|
|
7
|
+
author="AtomForge Contributors",
|
|
8
|
+
python_requires=">=3.8",
|
|
9
|
+
packages=find_packages(),
|
|
10
|
+
install_requires=[],
|
|
11
|
+
extras_require={
|
|
12
|
+
"ase": ["ase>=3.22"],
|
|
13
|
+
},
|
|
14
|
+
classifiers=[
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
18
|
+
],
|
|
19
|
+
)
|