diff --git a/README.md b/README.md index 786c67d..e27bdcb 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,20 @@ viewer +### Lattice plane +Draw a plane that is defined by the miller indices and distance from the origin or by selecting the atoms. + +```python +viewer.avr.lp.add_plane_from_indices(name = "111", + indices = [1, 1, 1], + distance = 4, + scale = 1.0, + color = [0, 1, 1, 0.5]) +viewer.avr.lp.build_plane() +``` + + + ## Test diff --git a/docs/source/_static/images/lattice_plane.png b/docs/source/_static/images/lattice_plane.png new file mode 100644 index 0000000..97afdd9 Binary files /dev/null and b/docs/source/_static/images/lattice_plane.png differ diff --git a/docs/source/gallery.rst b/docs/source/gallery.rst index 29a66f7..ccc4444 100644 --- a/docs/source/gallery.rst +++ b/docs/source/gallery.rst @@ -48,6 +48,11 @@ Color by attribute is a powerful tool to visualize the data. Here we show how to :width: 10cm +Lattice plane +================= + +.. figure:: _static/images/lattice_plane.png + :align: center Animation diff --git a/docs/source/index.rst b/docs/source/index.rst index 520a88a..1396ab5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,6 +29,7 @@ A widget to visualize and interact with atomic structures in Jupyter Notebook. I measurement isosurface vector_field + lattice_plane mesh_primitive search_operator selection diff --git a/docs/source/lattice_plane.rst b/docs/source/lattice_plane.rst new file mode 100644 index 0000000..80ad670 --- /dev/null +++ b/docs/source/lattice_plane.rst @@ -0,0 +1,49 @@ +Lattice plane +================= + +The lattice plane is a plane that intersects the lattice. It is useful to visualize the lattice plane in the crystal structure. + +Plane form miller indices +-------------------------- +The lattice plane can be defined by the miller indices and distance from the origin or by selecting the atoms. + +Here is an example of how to visualize lattice planes (111): + +.. code-block:: python + + from ase.build import bulk + from weas_widget import WeasWidget + import numpy as np + atoms = bulk("Au", cubic=True) + viewer = WeasWidget() + viewer.from_ase(atoms) + viewer.avr.model_style = 1 + viewer.camera.setting = {"direction": [0, -0.2, 1], "zoom": 0.8} + viewer + +In another cell: + +.. code-block:: python + + # color is defined by RGBA, where R is red, G is green, B is blue, and A is the transparency + viewer.avr.lp.add_plane_from_indices(name = "111", + indices = [1, 1, 1], + distance = 4, + scale = 1.0, + color = [0, 1, 1, 0.5]) + viewer.avr.lp.build_plane() + +.. figure:: _static/images/lattice_plane.png + :align: center + + +Plane from selected atoms +-------------------------- +One can also draw a plane from the selected atoms. Here is an example: + + +.. code-block:: python + + viewer.avr.lp.add_plane_from_selected_atoms(name = "plane1", + color = [1, 0, 0, 0.5]) + viewer.avr.lp.build_plane() diff --git a/js/widget.js b/js/widget.js index f56d963..d5bfbd1 100644 --- a/js/widget.js +++ b/js/widget.js @@ -167,12 +167,20 @@ function render({ model, el }) { editor.avr.VFManager.fromSettings(data); editor.avr.VFManager.drawVectorFields(); }); - // mesh primitives + // instanced mesh primitives model.on("change:instancedMeshPrimitive", () => { const data = model.get("instancedMeshPrimitive"); console.log("instancedMeshPrimitive: ", data); editor.instancedMeshPrimitive.fromSettings(data); - editor.avr.meshPrimitive.drawMesh(); + editor.instancedMeshPrimitive.drawMesh(); + }); + + // any mesh + model.on("change:anyMesh", () => { + const data = model.get("anyMesh"); + console.log("anyMesh: ", data); + editor.anyMesh.fromSettings(data); + editor.anyMesh.drawMesh(); }); // camera settings diff --git a/package-lock.json b/package-lock.json index c895bdc..e23251c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "dat.gui": "^0.7.9", "three": "^0.161.0", - "weas": "^0.1.3" + "weas": "^0.1.4" }, "devDependencies": { "esbuild": "^0.20.0" @@ -430,9 +430,9 @@ "integrity": "sha512-LC28VFtjbOyEu5b93K0bNRLw1rQlMJ85lilKsYj6dgTu+7i17W+JCCEbvrpmNHF1F3NAUqDSWq50UD7w9H2xQw==" }, "node_modules/weas": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/weas/-/weas-0.1.3.tgz", - "integrity": "sha512-efgaM+7C9iBsu+YJrlJuSzVYEVXntZoImW19Vkwxe9aURHe6zej0PoWYm2dSR1TGUVLQMJVNRLoZV8E7TBIZag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/weas/-/weas-0.1.4.tgz", + "integrity": "sha512-dnmOOOLRYv85r4Pf0Qtg5gCcthNqcJ9tUB9go1UGbsZ+kQ4nGCLRDHL/pQb1LqRhRet5S9eBsqa8nHDK51K6sQ==", "dependencies": { "dat.gui": "^0.7.9", "three": "^0.160.1" diff --git a/package.json b/package.json index 6a117f6..462a656 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dependencies": { "dat.gui": "^0.7.9", "three": "^0.161.0", - "weas": "^0.1.3" + "weas": "^0.1.6" }, "devDependencies": { "esbuild": "^0.20.0" diff --git a/pyproject.toml b/pyproject.toml index 57f7be0..9999ba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "weas_widget" -version = "0.1.1" +version = "0.1.2" description = "A widget to visualize and interact with atomistic structures in Jupyter Notebook." authors = [{name = "Xing Wang", email = "xingwang1991@gmail.com"}] readme = "README.md" diff --git a/src/weas_widget/atoms_viewer.py b/src/weas_widget/atoms_viewer.py index 3dd1ad5..dbf5ee8 100644 --- a/src/weas_widget/atoms_viewer.py +++ b/src/weas_widget/atoms_viewer.py @@ -1,6 +1,7 @@ from .base_class import WidgetWrapper from .plugins.vector_field import VectorField from .plugins.isosurface import Isosurface +from .plugins.lattice_plane import LatticePlane class AtomsViewer(WidgetWrapper): @@ -31,6 +32,7 @@ def __init__(self, _widget): # Initialize plugins object.__setattr__(self, "vf", VectorField(_widget)) object.__setattr__(self, "iso", Isosurface(_widget)) + object.__setattr__(self, "lp", LatticePlane(_widget)) @property def atoms(self): diff --git a/src/weas_widget/base_widget.py b/src/weas_widget/base_widget.py index 2faa074..d2fd012 100644 --- a/src/weas_widget/base_widget.py +++ b/src/weas_widget/base_widget.py @@ -38,8 +38,10 @@ class BaseWidget(anywidget.AnyWidget): vectorField = tl.List().tag(sync=True) showVectorField = tl.Bool(True).tag(sync=True) guiConfig = tl.Dict({}).tag(sync=True) - # mesh primitives + # instanced mesh primitives instancedMeshPrimitive = tl.List(tl.Dict({})).tag(sync=True) + # any mesh + anyMesh = tl.List(tl.Dict({})).tag(sync=True) # viewer viewerStyle = tl.Dict({}).tag(sync=True) # camera diff --git a/src/weas_widget/plugins/lattice_plane.py b/src/weas_widget/plugins/lattice_plane.py new file mode 100644 index 0000000..cd76598 --- /dev/null +++ b/src/weas_widget/plugins/lattice_plane.py @@ -0,0 +1,241 @@ +from ..base_class import WidgetWrapper +import numpy as np + + +class LatticePlane(WidgetWrapper): + + catalog = "lattice_plane" + + _attribute_map = { + "planes": "anyMesh", + } + + _extra_allowed_attrs = ["settings"] + + def __init__(self, _widget): + super().__init__(_widget) + self.settings = {} + + @property + def atoms(self): + return self._widget.atoms + + @property + def cell(self): + return np.array(self.atoms["cell"]).reshape(3, 3) + + @property + def cell_edges(self): + """Edges of the cell""" + edge_indices = [ + [0, 3], + [0, 1], + [4, 2], + [4, 1], + [3, 5], + [2, 6], + [7, 5], + [7, 6], + [0, 2], + [3, 6], + [1, 5], + [4, 7], + ] + basis = np.array( + [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [1, 1, 0], + [1, 0, 1], + [0, 1, 1], + [1, 1, 1], + ] + ) + positions = np.dot(basis, self.cell) + edges = [] + for indices in edge_indices: + edges.append(positions[indices]) + return edges + + @property + def cell_volume(self): + return np.dot(self.cell[0], np.cross(self.cell[1], self.cell[2])) + + @property + def cell_reciprocal(self): + from math import pi + + b1 = 2 * pi / self.cell_volume * np.cross(self.cell[1], self.cell[2]) + b2 = 2 * pi / self.cell_volume * np.cross(self.cell[2], self.cell[0]) + b3 = 2 * pi / self.cell_volume * np.cross(self.cell[0], self.cell[1]) + return np.array([b1, b2, b3]) + + def add_plane_from_indices(self, name, indices, distance=1.0, **kwargs): + """Add a plane setting from indices""" + cell_reciprocal = self.cell_reciprocal + normal = np.dot(indices, cell_reciprocal) + normal = normal / np.linalg.norm(normal) + point = distance * normal + self.settings[name] = { + "normal": normal, + "point": point, + } + self.settings[name].update(kwargs) + + def add_plane_from_selected_atoms(self, name, **kwargs): + """ + Add a plane setting from selected atoms + """ + indices = self._widget.selectedAtomsIndices + if len(indices) != 3: + raise ValueError("Please select three atoms.") + positions = np.array(self.atoms["positions"]) + center = np.mean(positions[indices], axis=0) + normal = np.cross( + positions[indices[1]] - positions[indices[0]], + positions[indices[2]] - positions[indices[0]], + ) + self.settings[name] = { + "normal": normal, + "point": center, + } + self.settings[name].update(kwargs) + + def draw(self, no=None): + """Draw plane + no: int + spacegroup of structure, if None, no will be determined by + get_spacegroup_number() + include_center: bool + include center of plane in the mesh + """ + + # TODO delete old plane + if no is not None: + self.no = no + planes = self.build_plane(self.cell) + for name, plane in planes.items(): + self.send_js_task( + { + "name": "drawPlane", + "kwargs": {"plane": plane}, + } + ) + + def build_plane(self): + """ + Build vertices, edges and faces of plane. + """ + cellEdges = self.cell_edges + planes = {} + for name, data in self.settings.items(): + intersect_points = [] + # get intersection point + for line in cellEdges: + intersect_point = linePlaneIntersection( + line, data["normal"], data["point"] + ) + if intersect_point is not None: + intersect_points.append(intersect_point) + # get verts, edges, faces by Hull + if len(intersect_points) < 3: + continue + vertices, edges, faces = faces_from_vertices( + intersect_points, data["normal"], scale=data.get("scale", 1) + ) + planes[name] = self.get_plane_data(vertices, edges, faces, data) + self.planes = list(planes.values()) + + def get_plane_data(self, vertices, edges, faces, plane): + """ + build edge + """ + if len(faces) > 0: + plane.update( + { + "vertices": vertices.reshape(-1).tolist(), + "edges": edges, + "faces": np.array(faces).reshape(-1).tolist(), + "edges_cylinder": { + "lengths": [], + "centers": [], + "normals": [], + "vertices": 6, + "color": (0.0, 0.0, 0.0, 1.0), + "width": plane.pop("width", 0.1), + "battr_inputs": {}, + }, + } + ) + for edge in edges: + center = (vertices[edge[0]] + vertices[edge[1]]) / 2.0 + vec = vertices[edge[0]] - vertices[edge[1]] + length = np.linalg.norm(vec) + nvec = vec / length + plane["edges_cylinder"]["lengths"].append(length) + plane["edges_cylinder"]["centers"].append(center) + plane["edges_cylinder"]["normals"].append(nvec) + return plane + + +def faces_from_vertices(vertices, normal, scale=[1, 1, 1]): + """ + get faces from vertices + """ + # remove duplicative point + vertices = np.unique(vertices, axis=0) + n = len(vertices) + if n < 3: + return vertices, [], [] + center = np.mean(vertices, axis=0) + v1 = vertices[0] - center + angles = [[0, 0]] + normal = normal / (np.linalg.norm(normal) + 1e-6) + for i in range(1, n): + v2 = vertices[i] - center + x = np.cross(v1, v2) + c = np.sign(np.dot(x, normal)) + angle = np.arctan2(c, np.dot(v1, v2)) + angles.append([i, angle]) + # scale + vec = vertices - center + # length = np.linalg.norm(vec, axis = 1) + # nvec = vec/length[:, None] + vertices = center + np.array([scale]) * vec + # search convex polyhedra + angles = sorted(angles, key=lambda x: x[1]) + faces = [] + # change faces to triangle + for i in range(1, n - 1): + faces.append([angles[0][0], angles[i][0], angles[i + 1][0]]) + # get edges + edges = [] + for i in range(n - 1): + edges.append([angles[i][0], angles[i + 1][0]]) + return vertices, edges, faces + + +def linePlaneIntersection(line, normal, point): + """ + 3D Line Segment and Plane Intersection + - Point + - Line contained in plane + - No intersection + """ + d = np.dot(point, normal) + normalLine = line[0] - line[1] + a = np.dot(normalLine, normal) + # No intersection or Line contained in plane + if np.isclose(a, 0): + return None + # in same side + b = np.dot(line, normal) - d + if b[0] * b[1] > 0: + return None + # Point + v = point - line[0] + d = np.dot(v, normal) / a + point = np.round(line[0] + normalLine * d, 6) + return point diff --git a/tests/notebooks/.yarn/install-state.gz b/tests/notebooks/.yarn/install-state.gz index 4b51a60..9a0c105 100644 Binary files a/tests/notebooks/.yarn/install-state.gz and b/tests/notebooks/.yarn/install-state.gz differ diff --git a/tests/notebooks/tests/lattice_plane.test.ts b/tests/notebooks/tests/lattice_plane.test.ts new file mode 100644 index 0000000..eae3678 --- /dev/null +++ b/tests/notebooks/tests/lattice_plane.test.ts @@ -0,0 +1,65 @@ +// Modified from ipywidgets unit tests. + + +import { test } from '@jupyterlab/galata'; + +import { expect } from '@playwright/test'; + +import * as path from 'path'; + +test.describe('Widget Visual Regression', () => { + test.beforeEach(async ({ page, tmpPath }) => { + await page.contents.uploadDirectory( + path.resolve(__dirname, './notebooks'), + tmpPath + ); + await page.filebrowser.openDirectory(tmpPath); + }); + + test('Run notebook lattice_plane.ipynb and capture cell outputs', async ({ + page, + tmpPath, + }) => { + const notebook = 'lattice_plane.ipynb'; + await page.notebook.openByPath(`${tmpPath}/${notebook}`); + await page.notebook.activate(notebook); + + const captures = new Array(); + const cellCount = await page.notebook.getCellCount(); + + await page.notebook.runCellByCell({ + onAfterCellRun: async (cellIndex: number) => { + let cell = await page.notebook.getCellOutput(0); + const startTime = Date.now(); + const timeout = 60000; // Timeout in milliseconds, adjust as necessary + + // Polling for cell output to be not null + while (!cell && Date.now() - startTime < timeout) { + await page.waitForTimeout(1000); // Wait for 1 second before retrying + console.log("waiting for cell output to be not null"); + // always use the first cell + cell = await page.notebook.getCellOutput(0); + } + + if (cell) { + captures.push(await cell.screenshot()); + } else { + console.log("Cell output is not available for cell:", cellIndex); + } + }, + }); + + await page.notebook.save(); + + console.log("Cell count:", cellCount); + console.log("Captures array length:", captures.length); + captures.forEach((capture, index) => { + console.log(`Capture[${index}]:`, capture); + }); + + for (let i = 0; i < cellCount; i++) { + const image = `lattice_plane-cell-${i}.png`; + expect.soft(captures[i]).toMatchSnapshot(image); + } + }); +}); diff --git a/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-0-linux.png b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-0-linux.png new file mode 100644 index 0000000..0b030a2 Binary files /dev/null and b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-0-linux.png differ diff --git a/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-1-linux.png b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-1-linux.png new file mode 100644 index 0000000..97afdd9 Binary files /dev/null and b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-1-linux.png differ diff --git a/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-2-linux.png b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-2-linux.png new file mode 100644 index 0000000..6ec6507 Binary files /dev/null and b/tests/notebooks/tests/lattice_plane.test.ts-snapshots/lattice-plane-cell-2-linux.png differ diff --git a/tests/notebooks/tests/notebooks/lattice_plane.ipynb b/tests/notebooks/tests/notebooks/lattice_plane.ipynb new file mode 100644 index 0000000..0535e5f --- /dev/null +++ b/tests/notebooks/tests/notebooks/lattice_plane.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# guiConfig and viewerStyle\n", + "# Disable the GUI entirely.\n", + "import warnings\n", + "warnings.filterwarnings('ignore')\n", + "# Camera settings\n", + "from weas_widget import WeasWidget\n", + "from ase.build import bulk\n", + "\n", + "atoms = bulk('Au', 'fcc', a=4.08, cubic=True)\n", + "viewer = WeasWidget()\n", + "viewer.from_ase(atoms)\n", + "viewer.avr.model_style = 1\n", + "viewer.camera.setting = {\"direction\": [0.2, 0.5, 1], \"zoom\": 0.8}\n", + "viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "time.sleep(1)\n", + "viewer.avr.lp.add_plane_from_indices(name = \"111\", indices = [1, 1, 1], distance = 4, scale = 1.0, width = 0.1, color = [0, 1, 1, 0.5])\n", + "viewer.avr.lp.build_plane()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "viewer.avr.lp.settings = {}\n", + "viewer.avr.selected_atoms_indices = [1, 2, 3]\n", + "viewer.avr.lp.add_plane_from_selected_atoms(name = \"plane1\", width = 0.1, color = [1, 0, 0, 0.5])\n", + "viewer.avr.lp.build_plane()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}