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
+}