diff --git a/.gitignore b/.gitignore index 274f332..dde7316 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .vscode/settings.json *.pyc *.Rproj +*.zip ~*.xlsx paper/paper.html paper/paper.log diff --git a/blender/LICENSE b/blender/LICENSE new file mode 100644 index 0000000..ded7e3c --- /dev/null +++ b/blender/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 - 2024, Daniel Antonio Negrón (dnanto/remaindeer) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/blender/README.md b/blender/README.md new file mode 100644 index 0000000..d010d2b --- /dev/null +++ b/blender/README.md @@ -0,0 +1,29 @@ +# addon + +[![Blender 3.0.0](https://img.shields.io/badge/blender-3.0.0-%23f4792b.svg)]() + +Calculate meshes for icosahedral virus capsids. + +## Installation + +1. Download [latest version](https://github.com/dnanto/democapsid) of blendocapsid. +2. From Blender: Edit > Preferences +3. Select the 'Add-ons tab' and click 'Install' in the upper right. +4. Navigate to the zip file and click 'Install Add-on from File' + +## Contributing + +Have a bug fix or a new feature you'd like to see? Send it on over! Please make sure you create an issue that addresses your fix/feature so we can discuss the contribution. + +1. Fork this repo! +2. Create your feature branch: `git checkout -b features/fix-bug` +3. Commit your changes: `git commit -m 'Fixes the following...'` +4. Push the branch: `git push origin features/fix-bug` +5. Submit a pull request. +6. Create an [issue](). + +## License + +MIT + +See the [license](./LICENSE) document for the full text. diff --git a/blender/blendocapsid/__init__.py b/blender/blendocapsid/__init__.py new file mode 100644 index 0000000..b904680 --- /dev/null +++ b/blender/blendocapsid/__init__.py @@ -0,0 +1,45 @@ +bl_info = { + 'name': 'blendocapsid', + 'author': 'Daniel Antonio Negrón', + 'version': (0, 0, 1), + 'blender': (3, 0, 0), + 'location': 'Objects > Add Capsid Mesh', + 'description': 'Calculate meshes for icosahedral virus capsids with democapsid.', + 'warning': '', + 'wiki_url': 'https://github.com/dnanto/democapsid', + 'support': 'COMMUNITY', + 'category': 'Add Mesh' +} + +__version__ = '.'.join(map(str, bl_info['version'])) + + +# Handle Reload Scripts + +if 'reload' in locals(): + import importlib as il + il.reload(reload) + reload.all() + +import blendocapsid.reload as reload + + +def register(): + from . import patch + patch.add_local_modules_to_path() + + from blendocapsid import operators, panels + + operators.register() + panels.register() + + +def unregister(): + from blendocapsid import operators, panels + + operators.unregister() + panels.unregister() + + +if __name__ == '__main__': + register() diff --git a/blender/blendocapsid/operators.py b/blender/blendocapsid/operators.py new file mode 100644 index 0000000..edffab1 --- /dev/null +++ b/blender/blendocapsid/operators.py @@ -0,0 +1,72 @@ +import bpy + + +class CapsidMesh(bpy.types.Operator): + """Add icosahedral capsid mesh.""" + bl_idname = "object.add_mesh" + bl_label = "Add Capsid Mesh" + bl_options = {"REGISTER", "UNDO"} + axis_items = [ + ("2", "2", "two-fold axial symmetry", 2), + ("3", "3", "three-fold axial symmetry", 3), + ("5", "5", "five-fold axial symmetry", 5) + ] + tile_items = [ + (key, key, f"{key} lattice", idx) + for idx, key in enumerate((pfx + ele for pfx in ("", "dual") for ele in ("hex", "trihex", "snubhex", "rhombitrihex")), start=1) + ] + mode_items = [ + ("ico", "ico", "render icosahedral mesh", 1), + ("tri", "tri", "render triangular Caspar-Klug meshes", 2) + ] + + h: bpy.props.IntProperty(name="h", description="the h Caspar-Klug parameter", default=1, min=0) + k: bpy.props.IntProperty(name="k", description="the k Caspar-Klug parameter", default=0, min=0) + H: bpy.props.IntProperty(name="H", description="the H Caspar-Klug parameter", default=1, min=0) + K: bpy.props.IntProperty(name="K", description="the K Caspar-Klug parameter", default=0, min=0) + a: bpy.props.EnumProperty(name="a", description="the axial symmetry", items=axis_items, default=axis_items[2][3]) + R: bpy.props.FloatProperty(name="R", description="the hexagonal lattice unit circumradius", default=1, min=0) + t: bpy.props.EnumProperty(name="t", description="the hexagonal lattice unit tile", items=tile_items, default=tile_items[0][3]) + s: bpy.props.FloatProperty(name="s", description="the sphericity value", default=0, min=-1, max=1) + m: bpy.props.EnumProperty(name="mode", description="the render mode", items=mode_items, default=mode_items[0][3]) + iter: bpy.props.IntProperty(name="iter", description="the iteration number for numerical methods", default=100, min=1) + tol: bpy.props.FloatProperty(name="tol", description="the machine epsilon for numerical methods", default=1E-15, min=0) + + def execute(self, context): + from blendocapsid.modules.democapsid.democapsid import (calc_ckm, + calc_ico, + calc_lattice) + + m, a, s = self.m, int(self.a), self.s + ckp = (self.h, self.k, self.H, self.K) + lat = calc_lattice(self.t, self.R) + + if m == "ico": + meshes = calc_ico(ckp, lat, a=a, s=s, iter=self.iter, tol=self.tol) + elif m == "tri": + meshes = calc_ckm(ckp, lat) + + for i, mesh in enumerate(meshes[1:], start=1): + collection = bpy.data.collections.new(f"face-{i}") + bpy.context.scene.collection.children.link(collection) + for j, polygon in enumerate(mesh, start=1): + mesh = bpy.data.meshes.new(name=f"polygon_msh[{i},{j}]") + mesh.from_pydata(*polygon, []) + mesh.validate(verbose=True) + obj = bpy.data.objects.new(f"polygon_obj-[{i},{j}]", mesh) + collection.objects.link(obj) + + return {"FINISHED"} + + +def menu_func(self, context): + self.layout.operator(CapsidMesh.bl_idname) + + +def register(): + bpy.utils.register_class(CapsidMesh) + bpy.types.VIEW3D_MT_object.append(menu_func) + + +def unregister(): + bpy.utils.unregister_class(CapsidMesh) diff --git a/blender/blendocapsid/panels.py b/blender/blendocapsid/panels.py new file mode 100644 index 0000000..188b76a --- /dev/null +++ b/blender/blendocapsid/panels.py @@ -0,0 +1,9 @@ +import bpy + + +def register(): + """Register classes, types, etc.""" + + +def unregister(): + """Unregister classes, types, etc.""" diff --git a/blender/blendocapsid/patch.py b/blender/blendocapsid/patch.py new file mode 100644 index 0000000..23fbcb5 --- /dev/null +++ b/blender/blendocapsid/patch.py @@ -0,0 +1,12 @@ +import os +import sys + + +def add_local_modules_to_path(): + """Add local modules directory to system path. This is done so the + addon can find it's dependencies.""" + modules_dir = os.path.join(os.path.dirname(__file__), 'modules') + modules_dir = os.path.abspath(modules_dir) + + if modules_dir not in sys.path: + sys.path.append(modules_dir) diff --git a/blender/blendocapsid/reload.py b/blender/blendocapsid/reload.py new file mode 100644 index 0000000..4775060 --- /dev/null +++ b/blender/blendocapsid/reload.py @@ -0,0 +1,16 @@ +import blendocapsid + + +def all(): + import importlib as il + + # Reload package + il.reload(blendocapsid) + + # Reload operators subpackage + il.reload(blendocapsid.operators) + + # Reload panels subpackage + il.reload(blendocapsid.panels) + + print('blendocapsid: Reload finished.') diff --git a/blender/makefile b/blender/makefile new file mode 100644 index 0000000..9ffd8a4 --- /dev/null +++ b/blender/makefile @@ -0,0 +1,8 @@ +.PHONY: dist clean + +dist: + python package.py + +clean: + rm -rf ./dist + find . -name "*.pyc" -delete diff --git a/blender/package.py b/blender/package.py new file mode 100644 index 0000000..e5a75be --- /dev/null +++ b/blender/package.py @@ -0,0 +1,88 @@ +"""Packages the blendocapsid and dependency""" +import importlib +import os +import re +import zipfile + +import blendocapsid + +ignored_directories = ( + '__pycache__', + '.DS_Store' +) + + +def gather_files(basedir, arc_prefix=''): + """Walk the given directory and return a sequence of filepath, archive name + pairs. + + Args: + basedir: The directory to start the walk + + arc_prefix: A path to join to the front of the relative (to basedir) + file path + + Returns: + A sequence of (filepath, archive name) pairs + """ + results = [] + + for path, subdirectories, files in os.walk(basedir): + if os.path.basename(path) in ignored_directories: + continue + + for file in files: + relative_path = os.path.join(path, file) + full_path = os.path.abspath(relative_path) + arcname = os.path.relpath(full_path, os.path.dirname(basedir)) + arcname = os.path.join(arc_prefix, arcname) + + results.append((full_path, arcname)) + + return results + + +def get_required_modules(): + """Parse the requirements.txt file to determine dependencies. + + Returns: + A sequence of module names + """ + with open('requirements.txt') as file: + data = file.read() + modules = data.split('\n') + pattern = '([A-Za-z0-9]+)(?:[<=>]+.*\n)?' + + def get_module_name(s): + result = re.search(pattern, s) + + if result: + return result.group() + + modules = [get_module_name(s) for s in modules if s] + + return modules + + +def run(): + try: + os.mkdir('dist') + + except FileExistsError: + pass + + zip_entries = gather_files('blendocapsid') + + for module in get_required_modules(): + module = importlib.import_module(module) + zip_entries += gather_files(os.path.dirname(module.__file__), os.path.join('blendocapsid', 'modules')) + + filename = f'blendocapsid-{blendocapsid.__version__}.zip' + filepath = os.path.abspath(os.path.join('dist', filename)) + with zipfile.ZipFile(filepath, 'w') as dist_zip: + for filename, arcname in zip_entries: + dist_zip.write(filename, arcname) + + +if __name__ == '__main__': + run() diff --git a/blender/requirements.txt b/blender/requirements.txt new file mode 100644 index 0000000..8329752 --- /dev/null +++ b/blender/requirements.txt @@ -0,0 +1 @@ +democapsid @ git+https://github.com/dnanto/democapsid.git#egg=democapsid&subdirectory=py diff --git a/js/LICENSE b/js/LICENSE new file mode 100644 index 0000000..ded7e3c --- /dev/null +++ b/js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 - 2024, Daniel Antonio Negrón (dnanto/remaindeer) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/py/src/democapsid/__main__.py b/py/src/democapsid/__main__.py index 966bb27..5149745 100644 --- a/py/src/democapsid/__main__.py +++ b/py/src/democapsid/__main__.py @@ -3,7 +3,7 @@ import sys from .cli import parse_args -from .democapsid import calc_ckm, calc_ckv, calc_ico, calc_lattice, cylinderize +from .democapsid import calc_ckm, calc_ico, calc_lattice args = parse_args(sys.argv[1:]) h, k, H, K, a, R, t, s, m = (getattr(args, key) for key in "h,k,H,K,a,R,t,s,m".split(",")) diff --git a/py/src/democapsid/cli.py b/py/src/democapsid/cli.py index 45c8b28..d0a131b 100644 --- a/py/src/democapsid/cli.py +++ b/py/src/democapsid/cli.py @@ -8,16 +8,15 @@ def parse_args(argv): description="Calculate meshes for icosahedral virus capsids." ) for ele in "hkHK": - parser.add_argument(ele, default=1, type=int, help=f"the {ele} Caspar-Klug parameter") + parser.add_argument(ele, type=int, help=f"the {ele} Caspar-Klug parameter") choices = (5, 3, 2) - parser.add_argument("-a", "-axis", default=choices[0], choices=choices, type=int, help=f"the axial symmetry: {choices}") + parser.add_argument("-a", "-axis", default=choices[0], choices=choices, type=int, help="the axial symmetry") parser.add_argument("-R", "-radius", default=1, type=int, help="the hexagonal lattice unit circumradius") - choices = ("hex", "trihex", "snubhex", "rhombitrihex") - choices = (*choices, *("dual" + ele for ele in choices)) + choices = tuple(pfx + ele for pfx in ("", "dual") for ele in ("hex", "trihex", "snubhex", "rhombitrihex")) parser.add_argument("-t", "-tile", default=choices[0], choices=choices, help="the hexagonal lattice unit tile") parser.add_argument("-s", "-sphericity", default=0, type=float, help="the sphericity value") choices = ("ico", "tri") parser.add_argument("-m", "-mode", default=choices[0], choices=choices, help="the render mode") - parser.add_argument("-iter", default=100, type=int, help="the number of iterations for numerical methods") - parser.add_argument("-tol", default=1E-15, type=float, help="the machine epsilon") + parser.add_argument("-iter", default=100, type=int, help="the iteration number for numerical methods") + parser.add_argument("-tol", default=1E-15, type=float, help="the machine epsilon for numerical methods") return parser.parse_args(argv) diff --git a/py/src/democapsid/democapsid.py b/py/src/democapsid/democapsid.py index b630d71..d7f54f7 100755 --- a/py/src/democapsid/democapsid.py +++ b/py/src/democapsid/democapsid.py @@ -529,8 +529,8 @@ def ico_coors_2(ckv, iter=100, tol=1E-15): Args: ckv (list): The Casar-Klug vectors. - iter (int, optional): The number of iterations for numerical methods. Defaults to 100. - tol (float, optional): The machine epsilon. Defaults to 1E-15. + iter (int, optional): The iteration number for numerical methods. Defaults to 100. + tol (float, optional): The machine epsilon for numerical methods. Defaults to 1E-15. Returns: np.array: The array of vertex coordinates. @@ -594,8 +594,8 @@ def ico_coors_3(ckv, iter=100, tol=1E-15): Args: ckv (list): The Casar-Klug vectors. - iter (int, optional): The number of iterations for numerical methods. Defaults to 100. - tol (float, optional): The machine epsilon. Defaults to 1E-15. + iter (int, optional): The iteration number for numerical methods. Defaults to 100. + tol (float, optional): The machine epsilon for numerical methods. Defaults to 1E-15. Returns: np.array: The array of vertex coordinates. @@ -758,21 +758,21 @@ def calc_ckm(ckp, lat): for coor in lattice_coordinates: # process tile subunits for calc_tile in lat[1]: - path = list(iter_ring(calc_tile(coor @ lat[0]))) + path = [(np.append(src, 0), np.append(tar, 0)) for src, tar in iter_ring(calc_tile(coor @ lat[0]))] vertices = [] # iterate polygon edges for src, tar in path: # add point if it is within the triangle bounds - in_triangle(src, *triangle) and vertices.append(np.append(src, 1)) + in_triangle(src[:2], *triangle) and vertices.append(src) # iterate triangle edges for edge in iter_ring(triangle): # add point that at the intersetion of the polygon and triangle edges - (x := intersection(src, tar, *edge)).any() and vertices.append(np.append(x, 1)) + (x := intersection(src[:2], tar[:2], *edge)).any() and vertices.append(np.append(x, 0)) # keep edges if they occur on the tile polygon path edges = [ (s1, t1) for s1, t1 in iter_ring(list(range(len(vertices)))) - if any(on_same_line(vertices[s1], vertices[t1], np.append(s2, 1), np.append(t2, 1)) for s2, t2 in path) + if any(on_same_line(vertices[s1], vertices[t1], s2, t2) for s2, t2 in path) ] # if there are only two edges and they point to each other, then only keep one edges = [edges[0]] if len(edges) == 2 and edges[0] == edges[1][::-1] else edges @@ -789,8 +789,8 @@ def calc_ico(ckp, lat, a=5, s=0, iter=100, tol=1E-15): lat (tuple): The lattice tuple (basis, list of unit tiler functions). a (int, optional): The axial symmetry. Defaults to 5. s (int, optional): The sphericity. Defaults to 0. - iter (int, optional): The number of iterations for numerical methods. Defaults to 100. - tol (_type_, optional): The machine epsilon. Defaults to 1E-15. + iter (int, optional): The iteration number for numerical methods. Defaults to 100. + tol (float, optional): The machine epsilon for numerical methods. Defaults to 1E-15. Returns: list: The list of meshes for each face.