From 52415d3d646ece3c3f703020d477caafb97aa09b Mon Sep 17 00:00:00 2001 From: Matthew Horton Date: Thu, 29 Aug 2024 22:16:36 -0700 Subject: [PATCH 1/7] Add `povray_path` to settings --- crystal_toolkit/settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crystal_toolkit/settings.py b/crystal_toolkit/settings.py index 6ed95ef4..f4a4901f 100644 --- a/crystal_toolkit/settings.py +++ b/crystal_toolkit/settings.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Literal, Optional +from shutil import which from pydantic import Field, HttpUrl, RedisDsn @@ -102,6 +103,13 @@ class Settings(BaseSettings): help="Default radius for displaying atoms when uniform radii are chosen.", ) + # Renderer settings. These control settings for any additional renderers like POV-Ray and Asymptote. + + POVRAY_PATH: Optional[str] = Field( + default=which("povray") or "/opt/homebrew/bin/povray", + help="Path to POV-Ray binary. Tested with 3.7.0.10.unofficial via `brew install povray` on macOS." + ) + # Materials Project API settings. # TODO: These should be deprecated in favor of setti API_KEY: Optional[str] = Field(default="", help="Materials Project API key.") From c5e84235a2f76b1c722da528cbe1e4c25047fcc5 Mon Sep 17 00:00:00 2001 From: Matthew Horton Date: Thu, 29 Aug 2024 22:17:47 -0700 Subject: [PATCH 2/7] Add new `POVRayRenderer` class --- crystal_toolkit/helpers/__init__.py | 1 + crystal_toolkit/helpers/povray/__init__.py | 0 crystal_toolkit/helpers/povray/renderer.py | 138 ++++++++++ .../helpers/povray/templates/camera.pov | 10 + .../helpers/povray/templates/cylinder.pov | 7 + .../helpers/povray/templates/header.pov | 27 ++ .../helpers/povray/templates/lights.pov | 46 ++++ .../helpers/povray/templates/line.pov | 11 + .../helpers/povray/templates/render.ini | 22 ++ .../helpers/povray/templates/sphere.pov | 5 + crystal_toolkit/helpers/povray_renderer.py | 246 ------------------ 11 files changed, 267 insertions(+), 246 deletions(-) create mode 100644 crystal_toolkit/helpers/povray/__init__.py create mode 100644 crystal_toolkit/helpers/povray/renderer.py create mode 100644 crystal_toolkit/helpers/povray/templates/camera.pov create mode 100644 crystal_toolkit/helpers/povray/templates/cylinder.pov create mode 100644 crystal_toolkit/helpers/povray/templates/header.pov create mode 100644 crystal_toolkit/helpers/povray/templates/lights.pov create mode 100644 crystal_toolkit/helpers/povray/templates/line.pov create mode 100644 crystal_toolkit/helpers/povray/templates/render.ini create mode 100644 crystal_toolkit/helpers/povray/templates/sphere.pov delete mode 100644 crystal_toolkit/helpers/povray_renderer.py diff --git a/crystal_toolkit/helpers/__init__.py b/crystal_toolkit/helpers/__init__.py index e69de29b..328c8ee3 100644 --- a/crystal_toolkit/helpers/__init__.py +++ b/crystal_toolkit/helpers/__init__.py @@ -0,0 +1 @@ +from crystal_toolkit.helpers.povray.renderer import POVRayRenderer \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/__init__.py b/crystal_toolkit/helpers/povray/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py new file mode 100644 index 00000000..4cbdf7e1 --- /dev/null +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -0,0 +1,138 @@ +"""Export wrapper for POV-Ray. + +For creating publication quality plots. +""" + +from __future__ import annotations + +from warnings import warn +import subprocess + +from jinja2 import Environment # TODO: add to requirements +from matplotlib.colors import to_hex + +from crystal_toolkit.settings import SETTINGS, MODULE_PATH +from crystal_toolkit.core.scene import Scene, Primitive, Spheres, Cylinders, Lines + + +class POVRayRenderer: + """ + A class to interface with the POV-Ray command line tool (ray tracer). + """ + + _TEMPLATES = {path.stem: path.read_text() for path in (MODULE_PATH / "helpers" / "povray" / "templates").glob("*")} + _ENV = Environment() + + @staticmethod + def call_povray(povray_args: tuple[str] = ("render.ini", ), povray_path: str = SETTINGS.POVRAY_PATH): + """ + Run POV-Ray. Prefer `render_scene` method unless advanced user. + """ + + povray_args = [povray_path, *povray_args] + + with subprocess.Popen( + povray_args, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + close_fds=True, + ) as proc: + stdout, stderr = proc.communicate() + if proc.returncode != 0: + raise RuntimeError( + f"{povray_path} exit code: {proc.returncode}, error: {stderr!s}." + f"\nstdout: {stdout!s}. Please check your POV-Ray installation." + ) + + + @staticmethod + def write_povray_input_scene_and_settings( + scene, + scene_filename="crystal_toolkit_scene.pov", + settings_filename="render.ini", + image_filename="crystal_toolkit_scene.png" + ): + """ + Prefer `render_scene` method unless advanced user. + """ + + with open(scene_filename, "w") as f: + + scene_str = POVRayRenderer.scene_to_povray(scene) + + f.write(POVRayRenderer._TEMPLATES["header"]) + f.write(POVRayRenderer._TEMPLATES["camera"]) + f.write(POVRayRenderer._TEMPLATES["lights"]) + f.write(scene_str) + + render_settings = POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["render"]).render( + filename=scene_filename, image_filename=image_filename + ) + with open(settings_filename, "w") as f: + f.write(render_settings) + + + @staticmethod + def scene_to_povray(scene: Scene) -> str: + + povray_str = "" + + for item in scene.contents: + + if isinstance(item, Primitive): + povray_str += POVRayRenderer.primitive_to_povray(obj=item) + + elif isinstance(item, Scene): + povray_str += POVRayRenderer.scene_to_povray(scene=item) + + return povray_str + + @staticmethod + def primitive_to_povray(obj: Primitive) -> str: + + vect = "{:.4f},{:.4f},{:.4f}" + + if isinstance(obj, Spheres): + + positions = obj.positions + positions = [vect.format(*pos) for pos in positions] + color = POVRayRenderer._format_color_to_povray(obj.color) + + return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["sphere"]).render(positions=positions, + radius=obj.radius, + color=color) + + elif isinstance(obj, Cylinders): + + position_pairs = [ + [vect.format(*ipos), vect.format(*fpos)] + for ipos, fpos in obj.positionPairs + ] + color = POVRayRenderer._format_color_to_povray(obj.color) + return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["cylinder"]).render(posPairs=position_pairs, color=color) + + elif isinstance(obj, Lines): + pos1, pos2 = ( + obj.positions[0::2], + obj.positions[1::2], + ) + cylCaps = {tuple(pos) for pos in obj.positions} + cylCaps = [vect.format(*pos) for pos in cylCaps] + position_pairs = [ + [vect.format(*ipos), vect.format(*fpos)] for ipos, fpos in zip(pos1, pos2) + ] + return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["line"]).render(posPairs=position_pairs, cylCaps=cylCaps) + + elif isinstance(obj, Primitive): + warn(f"Skipping {type(obj)}, not yet implemented. Submit PR to add support.") + + @staticmethod + def _format_color_to_povray(color: str) -> str: + vect = "{:.4f},{:.4f},{:.4f}" + color = to_hex(color) + color = color.replace("#", "") + color = tuple(int(color[i: i + 2], 16) / 255.0 for i in (0, 2, 4)) + color = f"rgb<{vect.format(*color)}>" + return color + + diff --git a/crystal_toolkit/helpers/povray/templates/camera.pov b/crystal_toolkit/helpers/povray/templates/camera.pov new file mode 100644 index 00000000..403f9e17 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/camera.pov @@ -0,0 +1,10 @@ +/* +Define the camera and the view of the atoms +*/ + +camera { + orthographic + location + look_at + sky <0, 0, 1> +} \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/cylinder.pov b/crystal_toolkit/helpers/povray/templates/cylinder.pov new file mode 100644 index 00000000..7a10d606 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/cylinder.pov @@ -0,0 +1,7 @@ +// Draw bonds between atoms in the scene + +#declare bond_texture = texture { pigment { {{color}} } finish { plastic_atom_finish } }; + +{% for ipos, fpos in posPairs -%} +cylinder { <{{ipos}}>, <{{fpos}}>, 0.1 texture { bond_texture } no_shadow } +{% endfor %} \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/header.pov b/crystal_toolkit/helpers/povray/templates/header.pov new file mode 100644 index 00000000..0d673384 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/header.pov @@ -0,0 +1,27 @@ +#version 3.7 ; +global_settings { assumed_gamma 1.8 + ambient_light rgb<1, 1, 1> +} +background { colour srgbt <0.0, 0.0, 0.0, 1.0> } // Set the background to transparent + +/* +Create an Atom object along with some textures. +The arguments are: Atom( position, radius, color, finish ) +*/ + +#declare plastic_atom_finish = finish { + specular 0.2 + roughness 0.001 + ambient 0.075 + diffuse 0.55 + brilliance 1.5 + conserve_energy + } + +#macro Atom (P1, R1, C1, F1) + #local T = texture { + pigment { C1 } + finish { F1 } + } + sphere { P1, R1 texture {T} no_shadow } +#end \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/lights.pov b/crystal_toolkit/helpers/povray/templates/lights.pov new file mode 100644 index 00000000..6d624748 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/lights.pov @@ -0,0 +1,46 @@ +/* +Define light sources to illuminate the atoms. For visualizing mediam +media_interaction and media_attenuation are set to "off" so voxel +data is rendered to be transparent. Lights are automatically oriented +with respect to the camera position. +*/ + +// Overhead light source +light_source { + <0, 0, 10> + color rgb <1,1,1>*0.5 + parallel + point_at *0.5 + media_interaction off + media_attenuation off +} + +// Rear (forward-facing) light source +light_source { + < (i-ii), (j-jj), (k-kk)>*4 + color rgb <1,1,1> * 0.5 + parallel + point_at + media_interaction off + media_attenuation off +} + +// Left light source +light_source { + <( (i-ii)*cos(60*pi/180) - (j-jj)*sin(60*pi/180) ), ( (i-ii)*sin(60*pi/180) + (j-jj)*cos(60*pi/180) ), k> + color rgb <1,1,1>*0.5 + parallel + point_at + media_interaction off + media_attenuation off +} + +// Right light source +light_source { + <( (i-ii)*cos(-60*pi/180) - (j-jj)*sin(-60*pi/180) ), ( (i-ii)*sin(-60*pi/180) + (j-jj)*cos(-60*pi/180) ), k> + color rgb <1,1,1>*0.5 + parallel + point_at + media_interaction off + media_attenuation off +} \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/line.pov b/crystal_toolkit/helpers/povray/templates/line.pov new file mode 100644 index 00000000..c80ae8f6 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/line.pov @@ -0,0 +1,11 @@ +// Draw the edges of the supercell in the scene + +#declare bbox = texture { pigment { rgb <1,1,1> } } + +{% for ipos, fpos in posPairs -%} +cylinder {<{{ipos}}>, <{{fpos}}>, 0.02 texture {bbox} no_shadow} +{% endfor %} + +{% for val in cylCaps -%} +sphere {<{{val}}>, 0.02 texture {bbox} no_shadow} +{% endfor %} \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/render.ini b/crystal_toolkit/helpers/povray/templates/render.ini new file mode 100644 index 00000000..237e535b --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/render.ini @@ -0,0 +1,22 @@ +Input_File_Name = {{filename}} +Output_File_Name = {{image_filename}} +Output_Alpha=On +Display = 1 +# -- Option to switch on the density +Declare=render_density=0 # 0 = off, 1 = on +Quality = 9 +Height = 1200 +Width = 1600 +# -- Uncomment below for higher quality rendering +Antialias = On +Antialias_Threshold = 0.01 +Antialias_Depth = 4 +Jitter_Amount = 1.0 +# -- Set the camera position +Declare=i=8 +Declare=j=8 +Declare=k=8 +# -- Set the look_at position +Declare=ii=0 +Declare=jj=0 +Declare=kk=0 \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/templates/sphere.pov b/crystal_toolkit/helpers/povray/templates/sphere.pov new file mode 100644 index 00000000..8b2cfbf3 --- /dev/null +++ b/crystal_toolkit/helpers/povray/templates/sphere.pov @@ -0,0 +1,5 @@ +// Draw Spheres + +{% for val in positions -%} +Atom(<{{val}}>, {{radius}}, {{color}}, plastic_atom_finish) +{% endfor %} diff --git a/crystal_toolkit/helpers/povray_renderer.py b/crystal_toolkit/helpers/povray_renderer.py deleted file mode 100644 index 8eb86dc2..00000000 --- a/crystal_toolkit/helpers/povray_renderer.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Export wrapper for POV-Ray. - -For creating publication quality plots. -""" - -from __future__ import annotations - -from jinja2 import Environment - -HEAD = """ -#version 3.7 ; -global_settings { assumed_gamma 1.8 - ambient_light rgb<1, 1, 1> -} -background { rgb 0. } // Set the background to black - -/* -Create an Atom object along with some textures. -The arguments are: Atom( position, radius, color, finish ) -*/ - -#declare plastic_atom_finish = finish { - specular 0.2 - roughness 0.001 - ambient 0.075 - diffuse 0.55 - brilliance 1.5 - conserve_energy - } - -#macro Atom (P1, R1, C1, F1) - #local T = texture { - pigment { C1 } - finish { F1 } - } - sphere { P1, R1 texture {T} no_shadow } -#end - -""" - -CAMERA = """ -/* -Define the camera and the view of the atoms -*/ - -camera { - orthographic - location - look_at - sky <0, 0, 1> -} - -""" - -LIGHTS = """ -/* -Define light sources to illuminate the atoms. For visualizing mediam -media_interaction and media_attenuation are set to "off" so voxel -data is rendered to be transparent. Lights are automatically oriented -with respect to the camera position. -*/ - -// Overhead light source -light_source { - <0, 0, 10> - color rgb <1,1,1>*0.5 - parallel - point_at *0.5 - media_interaction off - media_attenuation off -} - -// Rear (forward-facing) light source -light_source { - < (i-ii), (j-jj), (k-kk)>*4 - color rgb <1,1,1> * 0.5 - parallel - point_at - media_interaction off - media_attenuation off -} - -// Left light source -light_source { - <( (i-ii)*cos(60*pi/180) - (j-jj)*sin(60*pi/180) ), ( (i-ii)*sin(60*pi/180) + (j-jj)*cos(60*pi/180) ), k> - color rgb <1,1,1>*0.5 - parallel - point_at - media_interaction off - media_attenuation off -} - -// Right light source -light_source { - <( (i-ii)*cos(-60*pi/180) - (j-jj)*sin(-60*pi/180) ), ( (i-ii)*sin(-60*pi/180) + (j-jj)*cos(-60*pi/180) ), k> - color rgb <1,1,1>*0.5 - parallel - point_at - media_interaction off - media_attenuation off -} - -""" - -TEMP_SPHERE = """ -// Draw atoms in the scene - -{% for val in positions -%} -Atom(<{{val}}>, {{radius}}, {{color}}, plastic_atom_finish) -{% endfor %} -""" - -TEMP_CYLINDER = """ -// Draw bonds between atoms in the scene - -#declare bond_texture = texture { pigment { {{color}} } finish { plastic_atom_finish } }; - -{% for ipos, fpos in posPairs -%} -cylinder { <{{ipos}}>, <{{fpos}}>, 0.1 texture { bond_texture } no_shadow } -{% endfor %} -""" - -TEMP_LINE = """ -// Draw the edges of the supercell in the scene - -#declare bbox = texture { pigment { rgb <1,1,1> } } - -{% for ipos, fpos in posPairs -%} -cylinder {<{{ipos}}>, <{{fpos}}>, 0.02 texture {bbox} no_shadow} -{% endfor %} - -{% for val in cylCaps -%} -sphere {<{{val}}>, 0.02 texture {bbox} no_shadow} -{% endfor %} -""" - - -def pov_write_data(input_scene_comp, fstream): - """Parse a primitive display object in crystaltoolkit and print it to POV-Ray - input_scene_comp fstream. - """ - vect = "{:.4f},{:.4f},{:.4f}" - - if input_scene_comp["type"] == "spheres": - # Render atoms - positions = input_scene_comp["positions"] - positions = [vect.format(*pos) for pos in positions] - color = input_scene_comp["color"].replace("#", "") - color = tuple(int(color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) - color = f"rgb<{vect.format(*color)}>" - - fstream.write( - Environment() - .from_string(TEMP_SPHERE) - .render( - positions=positions, - radius=input_scene_comp["radius"], - color=color, - ) - ) - - if input_scene_comp["type"] == "cylinders": - # Render bonds between atoms - posPairs = [ - [vect.format(*ipos), vect.format(*fpos)] - for ipos, fpos in input_scene_comp["positionPairs"] - ] - color = input_scene_comp["color"].replace("#", "") - color = tuple(int(color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) - color = f"rgb<{vect.format(*color)}>" - fstream.write( - Environment() - .from_string(TEMP_CYLINDER) - .render(posPairs=posPairs, color=color) - ) - - if input_scene_comp["type"] == "lines": - # Render the cell - pos1, pos2 = ( - input_scene_comp["positions"][0::2], - input_scene_comp["positions"][1::2], - ) - cylCaps = {tuple(pos) for pos in input_scene_comp["positions"]} - cylCaps = [vect.format(*pos) for pos in cylCaps] - posPairs = [ - [vect.format(*ipos), vect.format(*fpos)] for ipos, fpos in zip(pos1, pos2) - ] - fstream.write( - Environment() - .from_string(TEMP_LINE) - .render(posPairs=posPairs, cylCaps=cylCaps) - ) - - -def filter_data(scene_data, fstream): - """Recursively traverse the scene_data dictionary to find objects to draw.""" - if "type" in scene_data: - pov_write_data(scene_data, fstream) - else: - for itr in scene_data["contents"]: - filter_data(itr, fstream) - - -def write_pov_file(smc, file_name): - """Args: - smc (StructureMoleculeComponent): Object containing the scene data. - file_name (str): name of the file to write to. - """ - with open(file_name, "w") as fstream: - fstream.write(HEAD) - fstream.write(CAMERA) - fstream.write(LIGHTS) - filter_data(smc.initial_scene_data, fstream) - - render_settings = get_render_settings() - with open(file_name, "w") as file: - file.write(render_settings) - - -def get_render_settings(file_name): - """Creates a POV-Ray render.ini file.""" - image_name = f"{file_name[:-4]}.png" - - return f""" -Input_File_Name = {file_name} -Output_File_Name = {image_name} -Display = 1 -# -- Option to switch on the density -Declare=render_density=0 # 0 = off, 1 = on -Quality = 9 -Height = 1200 -Width = 1600 -# -- Uncomment below for higher quality rendering -Antialias = On -Antialias_Threshold = 0.01 -Antialias_Depth = 4 -Jitter_Amount = 1.0 -# -- Set the camera position -Declare=i=8 -Declare=j=5 -Declare=k=4 -# -- Set the look_at position -Declare=ii=0 -Declare=jj=0 -Declare=kk=0 -""" From d8770807cc9287fdfd1aa3c12360b5c384f1d11b Mon Sep 17 00:00:00 2001 From: Matthew Horton Date: Tue, 3 Sep 2024 23:08:13 -0700 Subject: [PATCH 3/7] Add `write_scene_to_file` --- crystal_toolkit/helpers/povray/renderer.py | 80 ++++++++++++++++------ 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py index 4cbdf7e1..8ea24640 100644 --- a/crystal_toolkit/helpers/povray/renderer.py +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -5,9 +5,14 @@ from __future__ import annotations +import os +import shutil +from tempfile import TemporaryDirectory from warnings import warn import subprocess +from pathlib import Path +import numpy as np from jinja2 import Environment # TODO: add to requirements from matplotlib.colors import to_hex @@ -20,11 +25,39 @@ class POVRayRenderer: A class to interface with the POV-Ray command line tool (ray tracer). """ - _TEMPLATES = {path.stem: path.read_text() for path in (MODULE_PATH / "helpers" / "povray" / "templates").glob("*")} + _TEMPLATES = { + path.stem: path.read_text() + for path in (MODULE_PATH / "helpers" / "povray" / "templates").glob("*") + } _ENV = Environment() + def write_scene_to_file(self, scene: Scene, filename: str | Path): + """ + Render a Scene to a PNG file using POV-Ray. + """ + + current_dir = Path.cwd() + + with TemporaryDirectory() as temp_dir: + + os.chdir(temp_dir) + + self.write_povray_input_scene_and_settings( + scene, image_filename="crystal_toolkit_scene.png" + ) + self.call_povray() + + shutil.copy("crystal_toolkit_scene.png", filename) + + os.chdir(current_dir) + + return + @staticmethod - def call_povray(povray_args: tuple[str] = ("render.ini", ), povray_path: str = SETTINGS.POVRAY_PATH): + def call_povray( + povray_args: tuple[str] = ("render.ini",), + povray_path: str = SETTINGS.POVRAY_PATH, + ): """ Run POV-Ray. Prefer `render_scene` method unless advanced user. """ @@ -32,10 +65,10 @@ def call_povray(povray_args: tuple[str] = ("render.ini", ), povray_path: str = S povray_args = [povray_path, *povray_args] with subprocess.Popen( - povray_args, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - close_fds=True, + povray_args, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + close_fds=True, ) as proc: stdout, stderr = proc.communicate() if proc.returncode != 0: @@ -44,13 +77,12 @@ def call_povray(povray_args: tuple[str] = ("render.ini", ), povray_path: str = S f"\nstdout: {stdout!s}. Please check your POV-Ray installation." ) - @staticmethod def write_povray_input_scene_and_settings( scene, scene_filename="crystal_toolkit_scene.pov", settings_filename="render.ini", - image_filename="crystal_toolkit_scene.png" + image_filename="crystal_toolkit_scene.png", ): """ Prefer `render_scene` method unless advanced user. @@ -65,13 +97,12 @@ def write_povray_input_scene_and_settings( f.write(POVRayRenderer._TEMPLATES["lights"]) f.write(scene_str) - render_settings = POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["render"]).render( - filename=scene_filename, image_filename=image_filename - ) + render_settings = POVRayRenderer._ENV.from_string( + POVRayRenderer._TEMPLATES["render"] + ).render(filename=scene_filename, image_filename=image_filename) with open(settings_filename, "w") as f: f.write(render_settings) - @staticmethod def scene_to_povray(scene: Scene) -> str: @@ -98,9 +129,9 @@ def primitive_to_povray(obj: Primitive) -> str: positions = [vect.format(*pos) for pos in positions] color = POVRayRenderer._format_color_to_povray(obj.color) - return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["sphere"]).render(positions=positions, - radius=obj.radius, - color=color) + return POVRayRenderer._ENV.from_string( + POVRayRenderer._TEMPLATES["sphere"] + ).render(positions=positions, radius=obj.radius, color=color) elif isinstance(obj, Cylinders): @@ -109,7 +140,9 @@ def primitive_to_povray(obj: Primitive) -> str: for ipos, fpos in obj.positionPairs ] color = POVRayRenderer._format_color_to_povray(obj.color) - return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["cylinder"]).render(posPairs=position_pairs, color=color) + return POVRayRenderer._ENV.from_string( + POVRayRenderer._TEMPLATES["cylinder"] + ).render(posPairs=position_pairs, color=color) elif isinstance(obj, Lines): pos1, pos2 = ( @@ -119,20 +152,23 @@ def primitive_to_povray(obj: Primitive) -> str: cylCaps = {tuple(pos) for pos in obj.positions} cylCaps = [vect.format(*pos) for pos in cylCaps] position_pairs = [ - [vect.format(*ipos), vect.format(*fpos)] for ipos, fpos in zip(pos1, pos2) + [vect.format(*ipos), vect.format(*fpos)] + for ipos, fpos in zip(pos1, pos2) ] - return POVRayRenderer._ENV.from_string(POVRayRenderer._TEMPLATES["line"]).render(posPairs=position_pairs, cylCaps=cylCaps) + return POVRayRenderer._ENV.from_string( + POVRayRenderer._TEMPLATES["line"] + ).render(posPairs=position_pairs, cylCaps=cylCaps) elif isinstance(obj, Primitive): - warn(f"Skipping {type(obj)}, not yet implemented. Submit PR to add support.") + warn( + f"Skipping {type(obj)}, not yet implemented. Submit PR to add support." + ) @staticmethod def _format_color_to_povray(color: str) -> str: vect = "{:.4f},{:.4f},{:.4f}" color = to_hex(color) color = color.replace("#", "") - color = tuple(int(color[i: i + 2], 16) / 255.0 for i in (0, 2, 4)) + color = tuple(int(color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) color = f"rgb<{vect.format(*color)}>" return color - - From aa661ddb39808a8db93e0542cd52c6e1e4b9ba90 Mon Sep 17 00:00:00 2001 From: Matthew Horton Date: Tue, 3 Sep 2024 23:11:09 -0700 Subject: [PATCH 4/7] Add basic `ctk render` CLI interface --- crystal_toolkit/cli/__init__.py | 0 crystal_toolkit/cli/cli.py | 61 ++++++++++++++++++++++ crystal_toolkit/helpers/povray/renderer.py | 2 +- pyproject.toml | 3 ++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 crystal_toolkit/cli/__init__.py create mode 100644 crystal_toolkit/cli/cli.py diff --git a/crystal_toolkit/cli/__init__.py b/crystal_toolkit/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crystal_toolkit/cli/cli.py b/crystal_toolkit/cli/cli.py new file mode 100644 index 00000000..a16c4784 --- /dev/null +++ b/crystal_toolkit/cli/cli.py @@ -0,0 +1,61 @@ +from fileinput import filename +from pathlib import Path + +import rich_click as click +from pygments.lexer import default + +from tqdm import tqdm + +from crystal_toolkit.apps.examples.utils import load_and_store_matbench_dataset + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.argument("input", required=True) +def render(input): + + import crystal_toolkit + + from pymatgen.core.structure import Structure + from pymatgen.analysis.local_env import CrystalNN + + from crystal_toolkit.core.scene import Scene + from crystal_toolkit.helpers.povray.renderer import POVRayRenderer + + input_path = Path(input) + if input_path.is_file(): + paths = [input_path] # load CIF + else: + paths = list(input_path.glob("*.cif")) + + r = POVRayRenderer() + + structures = {} + for path in tqdm(paths, desc="Reading structures"): + try: + structures[path] = Structure.from_file(path) + except Exception as exc: + print(f"Failed to parse {path}: {exc}") + + def _get_scene(struct: Structure) -> Scene: + # opinionated defaults, would be better to be customizable + nn = CrystalNN() + sg = nn.get_bonded_structure(struct) + return sg.get_scene(explicitly_calculate_polyhedra_hull=True) + + scenes = {} + for path, structure in tqdm(structures.items(), desc="Preparing scenes"): + try: + scenes[path] = _get_scene(structure) + except Exception as exc: + print(f"Failed to parse {path}: {exc}") + + for path, scene in tqdm(scenes.items(), desc="Rendering scenes"): + r.write_scene_to_file(scene, filename=f"{path.stem}.png") + +if __name__ == '__main__': + cli() \ No newline at end of file diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py index 8ea24640..d5a890a5 100644 --- a/crystal_toolkit/helpers/povray/renderer.py +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -47,7 +47,7 @@ def write_scene_to_file(self, scene: Scene, filename: str | Path): ) self.call_povray() - shutil.copy("crystal_toolkit_scene.png", filename) + shutil.copy("crystal_toolkit_scene.png", current_dir / filename) os.chdir(current_dir) diff --git a/pyproject.toml b/pyproject.toml index e7b1609d..cc71c86a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,9 @@ test = ["playwright", "pytest", "pytest-playwright"] repo = "https://github.com/materialsproject/crystaltoolkit" docs = "https://docs.crystaltoolkit.org" +[project.scripts] +ctk = "crystal_toolkit.cli.cli:cli" + [tool.setuptools.packages.find] exclude = ["docs_rst"] From 1b6745918654a8b71417c50f4c43a59b6e41be20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:51:46 +0000 Subject: [PATCH 5/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- crystal_toolkit/cli/cli.py | 15 ++++----------- crystal_toolkit/helpers/__init__.py | 2 +- crystal_toolkit/helpers/povray/renderer.py | 16 ++++------------ .../helpers/povray/templates/camera.pov | 2 +- .../helpers/povray/templates/cylinder.pov | 2 +- .../helpers/povray/templates/header.pov | 2 +- .../helpers/povray/templates/lights.pov | 2 +- .../helpers/povray/templates/line.pov | 2 +- .../helpers/povray/templates/render.ini | 2 +- crystal_toolkit/settings.py | 4 ++-- 10 files changed, 17 insertions(+), 32 deletions(-) diff --git a/crystal_toolkit/cli/cli.py b/crystal_toolkit/cli/cli.py index a16c4784..b273c9f4 100644 --- a/crystal_toolkit/cli/cli.py +++ b/crystal_toolkit/cli/cli.py @@ -1,13 +1,8 @@ -from fileinput import filename from pathlib import Path import rich_click as click -from pygments.lexer import default - from tqdm import tqdm -from crystal_toolkit.apps.examples.utils import load_and_store_matbench_dataset - @click.group() def cli(): @@ -17,11 +12,8 @@ def cli(): @cli.command() @click.argument("input", required=True) def render(input): - - import crystal_toolkit - - from pymatgen.core.structure import Structure from pymatgen.analysis.local_env import CrystalNN + from pymatgen.core.structure import Structure from crystal_toolkit.core.scene import Scene from crystal_toolkit.helpers.povray.renderer import POVRayRenderer @@ -57,5 +49,6 @@ def _get_scene(struct: Structure) -> Scene: for path, scene in tqdm(scenes.items(), desc="Rendering scenes"): r.write_scene_to_file(scene, filename=f"{path.stem}.png") -if __name__ == '__main__': - cli() \ No newline at end of file + +if __name__ == "__main__": + cli() diff --git a/crystal_toolkit/helpers/__init__.py b/crystal_toolkit/helpers/__init__.py index 328c8ee3..17d4efcb 100644 --- a/crystal_toolkit/helpers/__init__.py +++ b/crystal_toolkit/helpers/__init__.py @@ -1 +1 @@ -from crystal_toolkit.helpers.povray.renderer import POVRayRenderer \ No newline at end of file +from crystal_toolkit.helpers.povray.renderer import POVRayRenderer diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py index d5a890a5..fa81ec42 100644 --- a/crystal_toolkit/helpers/povray/renderer.py +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -7,17 +7,16 @@ import os import shutil -from tempfile import TemporaryDirectory -from warnings import warn import subprocess from pathlib import Path +from tempfile import TemporaryDirectory +from warnings import warn -import numpy as np from jinja2 import Environment # TODO: add to requirements from matplotlib.colors import to_hex -from crystal_toolkit.settings import SETTINGS, MODULE_PATH -from crystal_toolkit.core.scene import Scene, Primitive, Spheres, Cylinders, Lines +from crystal_toolkit.core.scene import Cylinders, Lines, Primitive, Scene, Spheres +from crystal_toolkit.settings import MODULE_PATH, SETTINGS class POVRayRenderer: @@ -39,7 +38,6 @@ def write_scene_to_file(self, scene: Scene, filename: str | Path): current_dir = Path.cwd() with TemporaryDirectory() as temp_dir: - os.chdir(temp_dir) self.write_povray_input_scene_and_settings( @@ -89,7 +87,6 @@ def write_povray_input_scene_and_settings( """ with open(scene_filename, "w") as f: - scene_str = POVRayRenderer.scene_to_povray(scene) f.write(POVRayRenderer._TEMPLATES["header"]) @@ -105,11 +102,9 @@ def write_povray_input_scene_and_settings( @staticmethod def scene_to_povray(scene: Scene) -> str: - povray_str = "" for item in scene.contents: - if isinstance(item, Primitive): povray_str += POVRayRenderer.primitive_to_povray(obj=item) @@ -120,11 +115,9 @@ def scene_to_povray(scene: Scene) -> str: @staticmethod def primitive_to_povray(obj: Primitive) -> str: - vect = "{:.4f},{:.4f},{:.4f}" if isinstance(obj, Spheres): - positions = obj.positions positions = [vect.format(*pos) for pos in positions] color = POVRayRenderer._format_color_to_povray(obj.color) @@ -134,7 +127,6 @@ def primitive_to_povray(obj: Primitive) -> str: ).render(positions=positions, radius=obj.radius, color=color) elif isinstance(obj, Cylinders): - position_pairs = [ [vect.format(*ipos), vect.format(*fpos)] for ipos, fpos in obj.positionPairs diff --git a/crystal_toolkit/helpers/povray/templates/camera.pov b/crystal_toolkit/helpers/povray/templates/camera.pov index 403f9e17..74e762e6 100644 --- a/crystal_toolkit/helpers/povray/templates/camera.pov +++ b/crystal_toolkit/helpers/povray/templates/camera.pov @@ -7,4 +7,4 @@ camera { location look_at sky <0, 0, 1> -} \ No newline at end of file +} diff --git a/crystal_toolkit/helpers/povray/templates/cylinder.pov b/crystal_toolkit/helpers/povray/templates/cylinder.pov index 7a10d606..dff4f1ce 100644 --- a/crystal_toolkit/helpers/povray/templates/cylinder.pov +++ b/crystal_toolkit/helpers/povray/templates/cylinder.pov @@ -4,4 +4,4 @@ {% for ipos, fpos in posPairs -%} cylinder { <{{ipos}}>, <{{fpos}}>, 0.1 texture { bond_texture } no_shadow } -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/crystal_toolkit/helpers/povray/templates/header.pov b/crystal_toolkit/helpers/povray/templates/header.pov index 0d673384..78afb5b4 100644 --- a/crystal_toolkit/helpers/povray/templates/header.pov +++ b/crystal_toolkit/helpers/povray/templates/header.pov @@ -24,4 +24,4 @@ The arguments are: Atom( position, radius, color, finish ) finish { F1 } } sphere { P1, R1 texture {T} no_shadow } -#end \ No newline at end of file +#end diff --git a/crystal_toolkit/helpers/povray/templates/lights.pov b/crystal_toolkit/helpers/povray/templates/lights.pov index 6d624748..77b2b8cf 100644 --- a/crystal_toolkit/helpers/povray/templates/lights.pov +++ b/crystal_toolkit/helpers/povray/templates/lights.pov @@ -43,4 +43,4 @@ light_source { point_at media_interaction off media_attenuation off -} \ No newline at end of file +} diff --git a/crystal_toolkit/helpers/povray/templates/line.pov b/crystal_toolkit/helpers/povray/templates/line.pov index c80ae8f6..34fa30ad 100644 --- a/crystal_toolkit/helpers/povray/templates/line.pov +++ b/crystal_toolkit/helpers/povray/templates/line.pov @@ -8,4 +8,4 @@ cylinder {<{{ipos}}>, <{{fpos}}>, 0.02 texture {bbox} no_shadow} {% for val in cylCaps -%} sphere {<{{val}}>, 0.02 texture {bbox} no_shadow} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/crystal_toolkit/helpers/povray/templates/render.ini b/crystal_toolkit/helpers/povray/templates/render.ini index 237e535b..c4155537 100644 --- a/crystal_toolkit/helpers/povray/templates/render.ini +++ b/crystal_toolkit/helpers/povray/templates/render.ini @@ -19,4 +19,4 @@ Declare=k=8 # -- Set the look_at position Declare=ii=0 Declare=jj=0 -Declare=kk=0 \ No newline at end of file +Declare=kk=0 diff --git a/crystal_toolkit/settings.py b/crystal_toolkit/settings.py index f4a4901f..50aa346d 100644 --- a/crystal_toolkit/settings.py +++ b/crystal_toolkit/settings.py @@ -1,8 +1,8 @@ from __future__ import annotations from pathlib import Path -from typing import Literal, Optional from shutil import which +from typing import Literal, Optional from pydantic import Field, HttpUrl, RedisDsn @@ -107,7 +107,7 @@ class Settings(BaseSettings): POVRAY_PATH: Optional[str] = Field( default=which("povray") or "/opt/homebrew/bin/povray", - help="Path to POV-Ray binary. Tested with 3.7.0.10.unofficial via `brew install povray` on macOS." + help="Path to POV-Ray binary. Tested with 3.7.0.10.unofficial via `brew install povray` on macOS.", ) # Materials Project API settings. From aee82b5defe02c1c712d064ffe05aee3a813992f Mon Sep 17 00:00:00 2001 From: Matthew Horton Date: Mon, 9 Sep 2024 23:47:04 -0700 Subject: [PATCH 6/7] Add automatic camera scaling --- crystal_toolkit/helpers/povray/renderer.py | 70 +++++++++++-------- .../helpers/povray/templates/camera.pov | 10 --- 2 files changed, 41 insertions(+), 39 deletions(-) delete mode 100644 crystal_toolkit/helpers/povray/templates/camera.pov diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py index fa81ec42..eca7a621 100644 --- a/crystal_toolkit/helpers/povray/renderer.py +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -10,47 +10,43 @@ import subprocess from pathlib import Path from tempfile import TemporaryDirectory +from typing import ClassVar from warnings import warn from jinja2 import Environment # TODO: add to requirements from matplotlib.colors import to_hex +import numpy as np from crystal_toolkit.core.scene import Cylinders, Lines, Primitive, Scene, Spheres from crystal_toolkit.settings import MODULE_PATH, SETTINGS class POVRayRenderer: - """ - A class to interface with the POV-Ray command line tool (ray tracer). - """ + """A class to interface with the POV-Ray command line tool (ray tracer).""" - _TEMPLATES = { + _TEMPLATES: ClassVar[dict[str, str]] = { path.stem: path.read_text() for path in (MODULE_PATH / "helpers" / "povray" / "templates").glob("*") } - _ENV = Environment() - - def write_scene_to_file(self, scene: Scene, filename: str | Path): - """ - Render a Scene to a PNG file using POV-Ray. - """ + _ENV: ClassVar[Environment] = Environment() + @staticmethod + def write_scene_to_file(scene: Scene, filename: str | Path): + """Render a Scene to a PNG file using POV-Ray.""" current_dir = Path.cwd() with TemporaryDirectory() as temp_dir: os.chdir(temp_dir) - self.write_povray_input_scene_and_settings( + POVRayRenderer.write_povray_input_scene_and_settings( scene, image_filename="crystal_toolkit_scene.png" ) - self.call_povray() + POVRayRenderer.call_povray() shutil.copy("crystal_toolkit_scene.png", current_dir / filename) os.chdir(current_dir) - return - @staticmethod def call_povray( povray_args: tuple[str] = ("render.ini",), @@ -61,19 +57,14 @@ def call_povray( """ povray_args = [povray_path, *povray_args] + result = subprocess.run(povray_args, capture_output=True, text=True) - with subprocess.Popen( - povray_args, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - close_fds=True, - ) as proc: - stdout, stderr = proc.communicate() - if proc.returncode != 0: - raise RuntimeError( - f"{povray_path} exit code: {proc.returncode}, error: {stderr!s}." - f"\nstdout: {stdout!s}. Please check your POV-Ray installation." - ) + if result.returncode != 0: + raise RuntimeError( + f"{povray_path} exit code: {result.returncode}." + f"Please check your POV-Ray installation." + f"\nStdout:\n\n{result.stdout}\n\nStderr:\n\n{result.stderr}" + ) @staticmethod def write_povray_input_scene_and_settings( @@ -90,7 +81,7 @@ def write_povray_input_scene_and_settings( scene_str = POVRayRenderer.scene_to_povray(scene) f.write(POVRayRenderer._TEMPLATES["header"]) - f.write(POVRayRenderer._TEMPLATES["camera"]) + f.write(POVRayRenderer._get_camera_for_scene(scene)) f.write(POVRayRenderer._TEMPLATES["lights"]) f.write(scene_str) @@ -115,6 +106,7 @@ def scene_to_povray(scene: Scene) -> str: @staticmethod def primitive_to_povray(obj: Primitive) -> str: + vect = "{:.4f},{:.4f},{:.4f}" if isinstance(obj, Spheres): @@ -156,11 +148,31 @@ def primitive_to_povray(obj: Primitive) -> str: f"Skipping {type(obj)}, not yet implemented. Submit PR to add support." ) + return "" + @staticmethod def _format_color_to_povray(color: str) -> str: + """Convert a matplotlib-compatible color string to a POV-Ray color string.""" vect = "{:.4f},{:.4f},{:.4f}" color = to_hex(color) color = color.replace("#", "") color = tuple(int(color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) - color = f"rgb<{vect.format(*color)}>" - return color + return f"rgb<{vect.format(*color)}>" + + @staticmethod + def _get_camera_for_scene(scene: Scene) -> str: + """Creates a camera in POV-Ray format for a given scene with respect to its bounding box.""" + + bounding_box = scene.bounding_box # format is [min_corner, max_corner] + center = (np.array(bounding_box[0]) + bounding_box[1]) / 2 + size = np.array(bounding_box[1]) - bounding_box[0] + camera_pos = center + np.array([0, 0, 1.2 * size[2]]) + + return f""" +camera {{ + orthographic + location <{camera_pos[0]:.4f}, {camera_pos[1]:.4f}, {camera_pos[2]:.4f}> + look_at <{center[0]:.4f}, {center[1]:.4f}, {center[2]:.4f}> + sky <0, 0, 1> +}} +""" diff --git a/crystal_toolkit/helpers/povray/templates/camera.pov b/crystal_toolkit/helpers/povray/templates/camera.pov deleted file mode 100644 index 74e762e6..00000000 --- a/crystal_toolkit/helpers/povray/templates/camera.pov +++ /dev/null @@ -1,10 +0,0 @@ -/* -Define the camera and the view of the atoms -*/ - -camera { - orthographic - location - look_at - sky <0, 0, 1> -} From 3c3edc87189074109885ee99f7a5c30a11a33ed7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 06:49:58 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- crystal_toolkit/helpers/povray/renderer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crystal_toolkit/helpers/povray/renderer.py b/crystal_toolkit/helpers/povray/renderer.py index eca7a621..983cd313 100644 --- a/crystal_toolkit/helpers/povray/renderer.py +++ b/crystal_toolkit/helpers/povray/renderer.py @@ -13,9 +13,9 @@ from typing import ClassVar from warnings import warn +import numpy as np from jinja2 import Environment # TODO: add to requirements from matplotlib.colors import to_hex -import numpy as np from crystal_toolkit.core.scene import Cylinders, Lines, Primitive, Scene, Spheres from crystal_toolkit.settings import MODULE_PATH, SETTINGS @@ -57,7 +57,9 @@ def call_povray( """ povray_args = [povray_path, *povray_args] - result = subprocess.run(povray_args, capture_output=True, text=True) + result = subprocess.run( + povray_args, capture_output=True, text=True, check=False + ) if result.returncode != 0: raise RuntimeError( @@ -106,7 +108,6 @@ def scene_to_povray(scene: Scene) -> str: @staticmethod def primitive_to_povray(obj: Primitive) -> str: - vect = "{:.4f},{:.4f},{:.4f}" if isinstance(obj, Spheres):