diff --git a/.github/workflows/test-and-publish-release.yml b/.github/workflows/test-and-publish-release.yml
index f1db878..1cb348b 100644
--- a/.github/workflows/test-and-publish-release.yml
+++ b/.github/workflows/test-and-publish-release.yml
@@ -25,8 +25,8 @@ jobs:
- name: Install dependencies
run: |
- pip install numpy pytest coverage
- pip install -e .
+ pip install numpy pytest coverage pytest-mock
+ pip install -e .[vis]
- name: Run tests with pytest
run: coverage run --source pathfinding3d -m pytest
diff --git a/.github/workflows/test-main.yml b/.github/workflows/test-main.yml
index 3474de4..0f420dd 100644
--- a/.github/workflows/test-main.yml
+++ b/.github/workflows/test-main.yml
@@ -29,8 +29,8 @@ jobs:
- name: Install dependencies
run: |
- pip install numpy pytest coverage
- pip install -e .
+ pip install numpy pytest coverage pytest-mock
+ pip install -e .[vis]
- name: Run tests with pytest
run: coverage run --source pathfinding3d -m pytest
diff --git a/README.md b/README.md
index d36a0d4..4d89cb3 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,34 @@ For a quick start, here's a basic example:
For usage examples with detailed descriptions take a look at the [examples](examples/) folder or at the [documentation](https://harisankar95.github.io/pathfinding3D/USAGE.html).
+## Visualization of the path
+
+You can visualize the grid along with the path by calling the `visualize` method of the `Grid` class. This method can take path as an optional argument and generate a plotly figure. You can install pathfinding3d with the `plotly` to use this feature with the following command:
+
+ ```bash
+ pip install pathfinding3d[vis]
+ ```
+
+The path produced in the previous example can be visualized by adding the following code to the end of the example:
+
+ ```python
+ grid.visualize(
+ path=path, # optionally visualize the path
+ start=start,
+ end=end,
+ visualize_weight=True, # weights above 1 (default) will be visualized
+ save_html=True, # save visualization to html file
+ save_to="path_visualization.html", # specify the path to save the html file
+ always_show=True, # always show the visualization in the browser
+ )
+ ```
+
+This will generate a visualization of the grid and the path and save it to the file `path_visualization.html` and also open it in your default browser.
+
+
+
+
+
## Rerun the Algorithm
When rerunning the algorithm, remember to clean the grid first using `Grid.cleanup`. This will reset the grid to its original state.
diff --git a/assets/path_visualization.html b/assets/path_visualization.html
new file mode 100644
index 0000000..14b92e2
--- /dev/null
+++ b/assets/path_visualization.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/path_visualization.png b/assets/path_visualization.png
new file mode 100644
index 0000000..75b98e8
Binary files /dev/null and b/assets/path_visualization.png differ
diff --git a/pathfinding3d/core/grid.py b/pathfinding3d/core/grid.py
index a482cb2..06701ae 100644
--- a/pathfinding3d/core/grid.py
+++ b/pathfinding3d/core/grid.py
@@ -1,12 +1,21 @@
import math
+import warnings
from functools import lru_cache
-from typing import List, Optional, Union
+from typing import List, Optional, Tuple, Union
import numpy as np
from .diagonal_movement import DiagonalMovement
from .node import GridNode
+try:
+ import plotly.graph_objects as go
+
+ USE_PLOTLY = True
+except ImportError:
+ USE_PLOTLY = False
+
+
MatrixType = Optional[Union[List[List[List[int]]], np.ndarray]]
@@ -487,3 +496,184 @@ def cleanup(self):
for y_nodes in x_nodes:
for z_node in y_nodes:
z_node.cleanup()
+
+ def visualize(
+ self,
+ path: Optional[List[Union[GridNode, Tuple]]] = None,
+ start: Optional[Union[GridNode, Tuple]] = None,
+ end: Optional[Union[GridNode, Tuple]] = None,
+ visualize_weight: bool = True,
+ save_html: bool = False,
+ save_to: str = "./pathfinding3d_visualization.html",
+ always_show: bool = False,
+ ):
+ """
+ Creates a 3D visualization of the grid, including optional path, start/end points,
+ and node weights using plotly. This method is designed to help visualize the
+ spatial layout, obstacles, and pathfinding results in three dimensions.
+
+ Parameters
+ ----------
+ path : Optional[List[Union[GridNode, Tuple]]], optional
+ The path to visualize, specified as a list of GridNode instances or coordinate tuples (x, y, z).
+ If omitted, the grid is visualized without a path. Defaults to None.
+ start : Optional[Union[GridNode, Tuple]], optional
+ The start node for the path, as either a GridNode or a tuple of coordinates.
+ If not provided and a path is given, the first node of the path is used. Defaults to None.
+ end : Optional[Union[GridNode, Tuple]], optional
+ The end node for the path, as either a GridNode or a tuple of coordinates.
+ If not provided and a path is given, the last node of the path is used. Defaults to None.
+ visualize_weight : bool, optional
+ Whether to visualize the weights of the nodes, enhancing the representation of node costs.
+ Defaults to True.
+ save_html : bool, optional
+ If True, the visualization is saved to an HTML file specified by `save_to` instead of being displayed.
+ Defaults to False.
+ save_to : str, optional
+ File path where the HTML visualization is saved if `save_html` is True.
+ Defaults to "./pathfinding3d_visualization.html".
+ always_show : bool, optional
+ If True, displays the visualization in the browser even when `save_html` is True.
+ Useful for immediate feedback while also saving the result. Defaults to False.
+
+ Raises
+ ------
+ Warning
+ If plotly is not installed, a warning is logged and the visualization is skipped.
+
+ Notes
+ -----
+ - Requires plotly for visualization. Install with `pip install plotly` if not already installed.
+ """
+ if not USE_PLOTLY:
+ warnings.warn("Plotly is not installed. Please install it to use this feature.")
+ return
+
+ # Extract obstacle and weight information directly from the grid
+ X, Y, Z, obstacle_values, weight_values = [], [], [], [], []
+ for x in range(self.width):
+ for y in range(self.height):
+ for z in range(self.depth):
+ node = self.node(x, y, z)
+ X.append(x)
+ Y.append(y)
+ Z.append(z)
+ obstacle_values.append(0 if node.walkable else 1)
+ weight_values.append(node.weight if node.walkable else 0)
+
+ # Create obstacle volume visualization
+ obstacle_vol = go.Volume(
+ x=np.array(X),
+ y=np.array(Y),
+ z=np.array(Z),
+ value=np.array(obstacle_values),
+ isomin=0.1,
+ isomax=1.0,
+ opacity=0.1,
+ surface_count=25, # Increase for better visibility
+ colorscale="Greys",
+ showscale=False,
+ name="Obstacles",
+ )
+
+ # List of items to visualize
+ visualizations = [obstacle_vol]
+
+ # Create weight volume visualization
+ if visualize_weight:
+ weight_vol = go.Volume(
+ x=np.array(X),
+ y=np.array(Y),
+ z=np.array(Z),
+ value=np.array(weight_values),
+ isomin=1.01, # Assuming default weight is 1, adjust as needed
+ isomax=max(weight_values) * 1.01,
+ opacity=0.5, # Adjust for better visibility
+ surface_count=25,
+ colorscale="Viridis", # A different colorscale for distinction
+ showscale=True,
+ colorbar=dict(title="Weight", ticks="outside"),
+ )
+ visualizations.append(weight_vol)
+
+ # Add path visualization if path is provided
+ if path:
+ # Convert path to coordinate tuples
+ path = [p.identifier if isinstance(p, GridNode) else p for p in path]
+
+ # Create path visualization
+ path_x, path_y, path_z = zip(*path)
+ path_trace = go.Scatter3d(
+ x=path_x,
+ y=path_y,
+ z=path_z,
+ mode="markers+lines",
+ marker=dict(size=6, color="red", opacity=0.9),
+ line=dict(color="red", width=3),
+ name="Path",
+ hovertext=[f"Step {i}: ({x}, {y}, {z})" for i, (x, y, z) in enumerate(path)],
+ hoverinfo="text",
+ )
+ visualizations.append(path_trace)
+
+ # Set start and end nodes if not provided
+ start = start or path[0]
+ end = end or path[-1]
+
+ # Add start and end node visualizations if available
+ if start:
+ start = start.identifier if isinstance(start, GridNode) else start
+ start_trace = go.Scatter3d(
+ x=[start[0]],
+ y=[start[1]],
+ z=[start[2]],
+ mode="markers",
+ marker=dict(size=8, color="green", symbol="diamond"),
+ name="Start",
+ hovertext=f"Start: {start}",
+ hoverinfo="text",
+ )
+ visualizations.append(start_trace)
+
+ if end:
+ end = end.identifier if isinstance(end, GridNode) else end
+ end_trace = go.Scatter3d(
+ x=[end[0]],
+ y=[end[1]],
+ z=[end[2]],
+ mode="markers",
+ marker=dict(size=8, color="blue", symbol="diamond"),
+ name="End",
+ hovertext=f"End: {end}",
+ hoverinfo="text",
+ )
+ visualizations.append(end_trace)
+
+ # Camera settings
+ # Set camera perpendicular to the z-axis
+ camera = dict(eye=dict(x=0.0, y=0.0, z=self.depth / 4))
+
+ # Specify layout
+ layout = go.Layout(
+ title="3D Pathfinding Visualization",
+ scene=dict(
+ xaxis=dict(title="X-axis", showbackground=True),
+ yaxis=dict(title="Y-axis", showbackground=True),
+ zaxis=dict(title="Z-axis", showbackground=True),
+ aspectmode="auto",
+ ),
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
+ autosize=True,
+ scene_camera=camera,
+ )
+
+ # Create figure
+ fig = go.Figure(data=visualizations, layout=layout)
+
+ # Save visualization to HTML file if specified
+ if save_html:
+ fig.write_html(save_to, auto_open=False)
+ print(f"Visualization saved to: {save_to}")
+
+ if always_show or not save_html:
+ fig.show()
diff --git a/setup.py b/setup.py
index 5bf5511..c3a6309 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@
"dev": [
"black",
"pytest",
+ "pytest-mock",
"coverage",
"sphinx<=7.2.6",
"sphinx_rtd_theme",
@@ -38,7 +39,8 @@
"sphinx-prompt",
"sphinx-notfound-page",
"sphinx-autodoc-annotation",
- ]
+ ],
+ "vis": ["plotly"],
},
tests_require=[
"pytest",
diff --git a/test/test_grid_vis.py b/test/test_grid_vis.py
new file mode 100644
index 0000000..f63abff
--- /dev/null
+++ b/test/test_grid_vis.py
@@ -0,0 +1,56 @@
+import warnings
+
+import plotly
+import pytest
+
+from pathfinding3d.core.grid import Grid
+
+
+def test_visualize_without_plotly(mocker):
+ """
+ Test visualize method when plotly is not installed
+ """
+ mocker.patch("pathfinding3d.core.grid.USE_PLOTLY", False)
+ mocker.patch("warnings.warn")
+
+ grid = Grid(width=3, height=3, depth=3)
+ grid.visualize()
+
+ warnings.warn.assert_called_once_with("Plotly is not installed. Please install it to use this feature.")
+
+
+def test_visualize_with_plotly(mocker):
+ """
+ Test visualize method when plotly is installed
+ """
+ mocker.patch("pathfinding3d.core.grid.USE_PLOTLY", True)
+ mocker.patch("plotly.graph_objects.Volume")
+ mocker.patch("plotly.graph_objects.Scatter3d")
+ mocker.patch("plotly.graph_objects.Layout")
+ mocker.patch("plotly.graph_objects.Figure")
+ mocker.patch("plotly.offline.plot")
+
+ grid = Grid(width=3, height=3, depth=3)
+ grid.visualize()
+
+ plotly.graph_objects.Volume.assert_called()
+ plotly.graph_objects.Figure.assert_called()
+
+
+def test_visualize_with_path(mocker):
+ """
+ Test visualize method with a path
+ """
+ mocker.patch("pathfinding3d.core.grid.USE_PLOTLY", True)
+ mocker.patch("plotly.graph_objects.Volume")
+ mocker.patch("plotly.graph_objects.Scatter3d")
+ mocker.patch("plotly.graph_objects.Layout")
+ mocker.patch("plotly.graph_objects.Figure")
+ mocker.patch("plotly.offline.plot")
+
+ grid = Grid(width=3, height=3, depth=3)
+ path = [grid.node(0, 0, 0), grid.node(1, 1, 1), grid.node(2, 2, 2)]
+ grid.visualize(path=path)
+
+ plotly.graph_objects.Scatter3d.assert_called()
+ plotly.graph_objects.Figure.assert_called()