diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff1932..cb65bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,52 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -### Changed - -### Removed - - -## [0.2.4] 2024-01-27 - -### Added - -### Changed - -### Removed - - -## [0.2.3] 2024-01-27 - -### Added - -### Changed - -### Removed - - -## [0.2.2] 2024-01-27 - -### Added - -### Changed - -### Removed - - -## [0.2.1] 2024-01-27 - -### Added - -### Changed - -### Removed - - -## [0.2.0] 2024-01-26 - -### Added +* `compas_notebook.conversions.color_to_threejs`. +* `compas_notebook.conversions.box_to_threejs`. +* `compas_notebook.conversions.cone_to_threejs`. +* `compas_notebook.conversions.cylinder_to_threejs`. +* `compas_notebook.conversions.polyhedron_to_threejs`. +* `compas_notebook.conversions.sphere_to_threejs`. +* `compas_notebook.conversions.torus_to_threejs`. +* `compas_notebook.scene.BoxObject`. +* `compas_notebook.scene.ConeObject`. +* `compas_notebook.scene.CylinderObject`. +* `compas_notebook.scene.MeshObject`. +* `compas_notebook.scene.PolyhedronObject`. +* `compas_notebook.scene.SphereObject`. +* `compas_notebook.scene.TorusObject`. +* `compas_notebook.viewer.Viewer`. ### Changed ### Removed - diff --git a/docs/_images/compas_notebook.png b/docs/_images/compas_notebook.png new file mode 100644 index 0000000..74a81b7 Binary files /dev/null and b/docs/_images/compas_notebook.png differ diff --git a/docs/conf.py b/docs/conf.py index 65b0db8..02e7d9e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,6 +32,7 @@ extensions = sphinx_compas2_theme.default_extensions extensions.remove("sphinx.ext.linkcode") +extensions.append("nbsphinx") # numpydoc options diff --git a/docs/examples.rst b/docs/examples.rst index 49e8715..5f116bd 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -2,9 +2,19 @@ Examples ******************************************************************************** -.. toctree:: - :maxdepth: 1 - :titlesonly: - :glob: +There are example notebooks in the ``notebooks`` directory of the repo. +To run the notebooks you need to clone the repo and install the package requirements. - examples/* +.. code-block:: bash + + $ git clone https://github.com/compas-dev/compas_notebook.git + $ cd compas_notebook + $ pip install -r requirements-dev.txt + + +Notebooks can be used directly in VS Code, or in the browser using Jupyter. +To start the Jupyter server, run the following command in a terminal + +.. code-block:: bash + + $ jupyter notebook diff --git a/docs/index.rst b/docs/index.rst index d764a8c..7e72c2a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,24 +6,24 @@ compas_notebook Notebook visualization backend for COMPAS using pythreejs -.. .. figure:: /_images/ - :figclass: figure - :class: figure-img img-fluid +.. figure:: /_images/compas_notebook.png + :figclass: figure + :class: figure-img img-fluid Table of Contents ================= .. toctree:: - :maxdepth: 3 - :titlesonly: - - Introduction - installation - tutorial - examples - api - license + :maxdepth: 3 + :titlesonly: + + Introduction + installation + tutorial + examples + api + license Indices and tables diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 2fc9ddc..a9e4ce0 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -4,8 +4,18 @@ Tutorial The main purpose of :mod:`compas_notebook` is to visualise COMPAS objects in a Jupyter notebook. -Basics -====== + +Basic Usage +=========== + +Visualization is handled by creating a viewer inside a Jupyter notebook and adding objects to its scene. +The scene is an instance of :class:`compas.scene.Scene` that is preconfigured for ``context="Notebook"``, +and works the same way as any other COMPAS scene. + +.. note:: + + For more information on visualisation with scenes, see ... + .. code-block:: python @@ -18,3 +28,104 @@ Basics viewer = Viewer() viewer.scene.add(mesh) viewer.show() + + +Object Colors +============= + +To change the color of an object, specify the color when adding the object to the scene. +You can use a COMPAS color object, or any of the following color specifications: hex, rgb1, rgb255. + +.. code-block:: python + + viewer.scene.add(mesh, color=Color.red()) + viewer.scene.add(mesh, color='#ff0000') + viewer.scene.add(mesh, color=(1.0, 0.0, 0.0)) + viewer.scene.add(mesh, color=(255, 0, 0)) + + +Configuration +============= + +The viewer can be configured to have a certain size and to have a specific background colour. + +.. code-block:: python + + viewer = Viewer(width=600, height=400, background='#eeeeee') + +The grid can be turned on or off. + +.. code-block:: python + + viewer = Viewer(show_grid=False) + + +Viewports +========= + +The viewer supports two viewports: "top" and "perspective". +The default is "perspective". +Currently, you can only set the viewport when creating the viewer. +It cannot be changed afterwards. + +.. code-block:: python + + viewer = Viewer(viewport='top') + +Note that in the top viewport, rotation controls are disabled. + + +Toolbar +======= + +By default, the viewer has a toolbar with minimal functionality: zoom extents, zoom in, zoom out. +The toolbar is on by default, but can be turned off. + +.. code-block:: python + + viewer = Viewer(show_toolbar=False) + + +Scene Export +============ + +Because the scene is an instance of :class:`compas.scene.Scene`, it can be exported to JSON. +This can be done manually or using the ``save`` button in the viewer. + +The scene can be exported by itself + +.. code-block:: python + + viewer.scene.to_json("scene.json") + compas.json_dump(viewer.scene, "scene.json") + + +or as part of a larger session object. + +.. code-block:: python + + compas.json_dump({"scene": viewer.scene, "...": "..."}, "session.json") + + +An exported scene can be loaded into a diferent notebook or visualised in a different visualisation context such as Rhino or Blender. + +.. code-block:: python + + # different notebook + + import compas + from compas_notebook.viewer import Viewer + + scene = compas.json_load("scene.json") + viewer = Viewer(scene=scene) + viewer.show() + + +.. code-block:: python + + # Rhino + + import compas + + scene = compas.json_load("scene.json") + scene.draw() diff --git a/notebooks/00_basics.ipynb b/notebooks/00_basics.ipynb new file mode 100644 index 0000000..1ed0812 --- /dev/null +++ b/notebooks/00_basics.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Basics" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import compas\n", + "from compas.datastructures import Mesh\n", + "from compas_notebook.viewer import Viewer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load a mesh" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "mesh = Mesh.from_obj(compas.get('tubemesh.obj'))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualize the mesh" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PyThreeJS SceneObjects registered.\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "487a84d762054fd68467db3649b37981", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(HBox(children=(Button(icon='square', layout=Layout(height='32px', width='32px'), style=ButtonSt…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "viewer = Viewer()\n", + "viewer.scene.add(mesh, color='#cccccc')\n", + "viewer.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.9.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/10_config.ipynb b/notebooks/10_config.ipynb new file mode 100644 index 0000000..c2e956d --- /dev/null +++ b/notebooks/10_config.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from compas_notebook.viewer import Viewer" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "d8f412ce7a3b4927add4a862e7f9c2db", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(Box(children=(Renderer(camera=OrthographicCamera(bottom=-200.0, far=10000.0, left=-400.0, posit…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "viewer = Viewer(width=800, height=400, background='#eeeeee', show_toolbar=False, show_grid=True, show_axes=False, viewport=\"top\")\n", + "viewer.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "compas-dev", + "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.9.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/compas_fd.ipynb b/notebooks/compas_fd.ipynb new file mode 100644 index 0000000..5d60454 --- /dev/null +++ b/notebooks/compas_fd.ipynb @@ -0,0 +1,200 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bc8b5e0f-b59b-4c52-aed1-da52d1b7a402", + "metadata": {}, + "source": [ + "# Constrained Form Finding with Force Density - Example 1" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c1e98dcf-d92b-4aff-89a3-c8a62635a622", + "metadata": {}, + "outputs": [], + "source": [ + "from compas.colors import Color\n", + "from compas.datastructures import Mesh\n", + "from compas.geometry import Line, Point, Sphere, Vector\n", + "from compas_fd.solvers import fd_constrained_numpy\n", + "from compas_fd.constraints import Constraint\n", + "from compas_notebook.viewer import Viewer" + ] + }, + { + "cell_type": "markdown", + "id": "5e5dbdae-92e1-42af-9463-e4ce53eef5af", + "metadata": {}, + "source": [ + "## Base mesh" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3a549d02-4134-438a-b0c9-a828d93a7b3c", + "metadata": {}, + "outputs": [], + "source": [ + "mesh = Mesh.from_meshgrid(dx=10, nx=10)" + ] + }, + { + "cell_type": "markdown", + "id": "eca45edc-320a-4518-96fa-77ed02b52a30", + "metadata": {}, + "source": [ + "## FD Inputs" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2234b5de-cd03-44d6-8807-9cc9252b74c2", + "metadata": {}, + "outputs": [], + "source": [ + "vertices = mesh.vertices_attributes(\"xyz\")\n", + "fixed = list(mesh.vertices_where(vertex_degree=2))\n", + "edges = list(mesh.edges())\n", + "loads = [[0, 0, 0] for _ in range(len(vertices))]\n", + "\n", + "# increase the force densities on the boundary edges\n", + "\n", + "forcedensities = []\n", + "for edge in edges:\n", + " q = 10.0 if mesh.is_edge_on_boundary(edge) else 1.0\n", + " forcedensities.append(q)\n", + "\n", + "# constraints\n", + "\n", + "vertex_guid = {}\n", + "guid_constraint = {}\n", + "\n", + "line = Line([0, 2, 0], [2, 10, 0])\n", + "constraint = Constraint(line)\n", + "guid = str(constraint.guid)\n", + "guid_constraint[guid] = constraint\n", + "\n", + "vertex = list(mesh.vertices_where(x=0, y=10))[0]\n", + "vertex_guid[vertex] = guid" + ] + }, + { + "cell_type": "markdown", + "id": "71591832-e150-41ca-a4fe-a82f271f8a07", + "metadata": {}, + "source": [ + "## Solve" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7549f299-21c7-400f-a787-2c62b87c586d", + "metadata": {}, + "outputs": [], + "source": [ + "constraints = [None] * len(vertices)\n", + "for vertex in vertex_guid:\n", + " constraints[vertex] = guid_constraint[vertex_guid[vertex]]\n", + "\n", + "result = fd_constrained_numpy(\n", + " vertices=vertices,\n", + " fixed=fixed,\n", + " edges=edges,\n", + " forcedensities=forcedensities,\n", + " loads=loads,\n", + " constraints=constraints,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f9c05f39-bb6c-4266-8a27-41088aa4d077", + "metadata": {}, + "source": [ + "## Update" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d481ef13-5b8f-44ba-aff2-e70fb550688b", + "metadata": {}, + "outputs": [], + "source": [ + "for vertex, attr in mesh.vertices(data=True):\n", + " attr[\"x\"] = result.vertices[vertex, 0]\n", + " attr[\"y\"] = result.vertices[vertex, 1]\n", + " attr[\"z\"] = result.vertices[vertex, 2]" + ] + }, + { + "cell_type": "markdown", + "id": "abd60165-342d-4582-9874-49fea8d05c2a", + "metadata": {}, + "source": [ + "## Visualize" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "28a3c58a-973a-44ce-b67b-a6923c5d3290", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PyThreeJS SceneObjects registered.\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "4eb6bdea4979423ea8c131b84d87b4eb", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(HBox(children=(Button(icon='square', layout=Layout(height='32px', width='32px'), style=ButtonSt…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "viewer = Viewer()\n", + "viewer.scene.clear()\n", + "viewer.scene.add(mesh, color=Color.grey())\n", + "viewer.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.9.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/compas_notebook/conversions/__init__.py b/src/compas_notebook/conversions/__init__.py index 8643b34..5b076b4 100644 --- a/src/compas_notebook/conversions/__init__.py +++ b/src/compas_notebook/conversions/__init__.py @@ -1,4 +1,5 @@ from .colors import color_to_threejs + from .geometry import box_to_threejs from .geometry import cone_to_threejs from .geometry import cylinder_to_threejs @@ -6,7 +7,6 @@ from .geometry import sphere_to_threejs from .geometry import torus_to_threejs -# from .meshes import mesh_to_threejs from .meshes import vertices_and_edges_to_threejs from .meshes import vertices_and_faces_to_threejs from .meshes import vertices_to_threejs @@ -17,7 +17,6 @@ "box_to_threejs", "cone_to_threejs", "cylinder_to_threejs", - # "mesh_to_threejs", "polyhedron_to_threejs", "sphere_to_threejs", "torus_to_threejs", diff --git a/src/compas_notebook/conversions/geometry.py b/src/compas_notebook/conversions/geometry.py index 1b2e661..21fd97b 100644 --- a/src/compas_notebook/conversions/geometry.py +++ b/src/compas_notebook/conversions/geometry.py @@ -2,13 +2,102 @@ from compas.geometry import Box from compas.geometry import Cone from compas.geometry import Cylinder +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyhedron +from compas.geometry import Polyline from compas.geometry import Sphere from compas.geometry import Torus -from compas.geometry import Polyhedron + + +def point_to_threejs(point: Point) -> three.SphereGeometry: + """Convert a COMPAS point to PyThreeJS. + + Parameters + ---------- + point : :class:`compas.geometry.Point` + The point to convert. + + Returns + ------- + :class:`three.SphereGeometry` + + Examples + -------- + >>> from compas.geometry import Point + >>> point = Point(1, 2, 3) + >>> point_to_threejs(point) + SphereGeometry() + + """ + return three.SphereGeometry(radius=0.05, widthSegments=32, heightSegments=32) + + +def line_to_threejs(line: Point) -> three.BufferGeometry: + """Convert a COMPAS line to PyThreeJS. + + Parameters + ---------- + line : :class:`compas.geometry.Line` + The line to convert. + + Returns + ------- + :class:`three.BufferGeometry` + + """ + geometry = three.BufferGeometry( + attributes={ + "position": three.BufferAttribute([line.start, line.end], normalized=False), + } + ) + return geometry + + +def polyline_to_threejs(polyline: Polyline) -> three.BufferGeometry: + """Convert a COMPAS polyline to PyThreeJS. + + Parameters + ---------- + polyline : :class:`compas.geometry.Polyline` + The polyline to convert. + + Returns + ------- + :class:`three.BufferGeometry` + + """ + geometry = three.BufferGeometry( + attributes={ + "position": three.BufferAttribute(polyline.points, normalized=False), + } + ) + return geometry + + +def polygon_to_threejs(polygon: Polygon) -> three.BufferGeometry: + """Convert a COMPAS polygon to PyThreeJS. + + Parameters + ---------- + polygon : :class:`compas.geometry.Polygon` + The polygon to convert. + + Returns + ------- + :class:`three.BufferGeometry` + + """ + geometry = three.BufferGeometry( + attributes={ + "position": three.BufferAttribute(polygon.points, normalized=False), + } + ) + return geometry def box_to_threejs(box: Box) -> three.BoxGeometry: - """Convert a COMPAS box to a PyThreeJS box geometry. + """Convert a COMPAS box to PyThreeJS. Parameters ---------- @@ -18,7 +107,6 @@ def box_to_threejs(box: Box) -> three.BoxGeometry: Returns ------- :class:`three.BoxGeometry` - The PyThreeJS box geometry. Examples -------- @@ -32,7 +120,7 @@ def box_to_threejs(box: Box) -> three.BoxGeometry: def cone_to_threejs(cone: Cone) -> three.CylinderGeometry: - """Convert a COMPAS cone to a PyThreeJS cone geometry. + """Convert a COMPAS cone to PyThreeJS. Parameters ---------- @@ -42,7 +130,6 @@ def cone_to_threejs(cone: Cone) -> three.CylinderGeometry: Returns ------- :class:`three.CylinderGeometry` - The PyThreeJS cone geometry. Examples -------- @@ -52,11 +139,11 @@ def cone_to_threejs(cone: Cone) -> three.CylinderGeometry: CylinderGeometry(height=2.0, radiusTop=0.0) """ - return three.CylinderGeometry(radiusTop=0, radiusBottom=cone.radius, height=cone.height) + return three.CylinderGeometry(radiusTop=0, radiusBottom=cone.radius, height=cone.height, radialSegments=32) def cylinder_to_threejs(cylinder: Cylinder) -> three.CylinderGeometry: - """Convert a COMPAS cylinder to a PyThreeJS cylinder geometry. + """Convert a COMPAS cylinder to PyThreeJS. Parameters ---------- @@ -66,7 +153,6 @@ def cylinder_to_threejs(cylinder: Cylinder) -> three.CylinderGeometry: Returns ------- :class:`three.CylinderGeometry` - The PyThreeJS cylinder geometry. Examples -------- @@ -76,11 +162,11 @@ def cylinder_to_threejs(cylinder: Cylinder) -> three.CylinderGeometry: CylinderGeometry(height=2.0) """ - return three.CylinderGeometry(radiusTop=cylinder.radius, radiusBottom=cylinder.radius, height=cylinder.height) + return three.CylinderGeometry(radiusTop=cylinder.radius, radiusBottom=cylinder.radius, height=cylinder.height, radialSegments=32) def sphere_to_threejs(sphere: Sphere) -> three.SphereGeometry: - """Convert a COMPAS sphere to a PyThreeJS sphere geometry. + """Convert a COMPAS sphere to PyThreeJS. Parameters ---------- @@ -90,7 +176,6 @@ def sphere_to_threejs(sphere: Sphere) -> three.SphereGeometry: Returns ------- :class:`three.SphereGeometry` - The PyThreeJS sphere geometry. Examples -------- @@ -100,7 +185,7 @@ def sphere_to_threejs(sphere: Sphere) -> three.SphereGeometry: SphereGeometry() """ - return three.SphereGeometry(radius=sphere.radius) + return three.SphereGeometry(radius=sphere.radius, widthSegments=32, heightSegments=32) def torus_to_threejs(torus: Torus) -> three.TorusGeometry: @@ -124,7 +209,7 @@ def torus_to_threejs(torus: Torus) -> three.TorusGeometry: TorusGeometry(tube=0.2) """ - return three.TorusGeometry(radius=torus.radius_axis, tube=torus.radius_pipe) + return three.TorusGeometry(radius=torus.radius_axis, tube=torus.radius_pipe, radialSegments=64, tubularSegments=32) def polyhedron_to_threejs(polyhedron: Polyhedron) -> three.BufferGeometry: diff --git a/src/compas_notebook/conversions/meshes.py b/src/compas_notebook/conversions/meshes.py index 5cf5853..2ba96d4 100644 --- a/src/compas_notebook/conversions/meshes.py +++ b/src/compas_notebook/conversions/meshes.py @@ -1,8 +1,6 @@ import pythreejs as three import numpy -# from compas.datastructures import Mesh - def vertices_and_faces_to_threejs(vertices, faces) -> three.BufferGeometry: """Convert vertices and faces to a PyThreeJS geometry. @@ -96,26 +94,3 @@ def vertices_to_threejs(vertices) -> three.BufferGeometry: vertices = numpy.array(vertices, dtype=numpy.float32) geometry = three.BufferGeometry(attributes={"position": three.BufferAttribute(vertices, normalized=False)}) return geometry - - -# if show_points: -# vertices = [] -# for v in vertex: -# xyz = vertex_attributes(v, 'xyz') -# vertices.append(xyz) - -# vertices = THREE.BufferAttribute( -# array=np.array(vertices, dtype=np.float32), normalized=False) - -# geometry = THREE.BufferGeometry(attributes={'position': vertices}) -# three_mesh.add(THREE.Points(geometry, THREE.PointsMaterial(color=pointcolor.hex, size=pointsize))) - -# if show_lines: -# vertices = [] -# for edge in mesh.edges(): -# start = vertex_attributes(edge[0], 'xyz') -# end = vertex_attributes(edge[1], 'xyz') -# vertices.append([start, end]) - -# geometry = THREE.LineSegmentsGeometry(positions=vertices) -# three_mesh.add(THREE.LineSegments2(geometry, THREE.LineMaterial(color=linecolor.hex, linewidth=linewidth))) diff --git a/src/compas_notebook/scene/boxobject.py b/src/compas_notebook/scene/boxobject.py index 0ad3267..64f3b80 100644 --- a/src/compas_notebook/scene/boxobject.py +++ b/src/compas_notebook/scene/boxobject.py @@ -1,5 +1,3 @@ -import pythreejs as three - from compas.scene import GeometryObject from compas.colors import Color from compas_notebook.conversions import box_to_threejs @@ -27,10 +25,13 @@ def draw(self, color=None): contrastcolor: Color = color.darkened(50) if color.is_light else color.lightened(50) geometry = box_to_threejs(self.geometry) + transformation = self.y_to_z(self.geometry.transformation) - edges = three.EdgesGeometry(geometry) - mesh = three.Mesh(geometry, three.MeshBasicMaterial(color=color.hex)) - line = three.LineSegments(edges, three.LineBasicMaterial(color=contrastcolor.hex)) + self._guids = self.geometry_to_objects( + geometry, + color, + contrastcolor, + transformation=transformation, + ) - self._guids = [mesh, line] return self.guids diff --git a/src/compas_notebook/scene/coneobject.py b/src/compas_notebook/scene/coneobject.py index 87ea575..3ae32b9 100644 --- a/src/compas_notebook/scene/coneobject.py +++ b/src/compas_notebook/scene/coneobject.py @@ -24,11 +24,18 @@ def draw(self, color: Color = None): color: Color = Color.coerce(color) or self.color contrastcolor: Color = color.darkened(50) if color.is_light else color.lightened(50) - cone = three.CylinderGeometry( + geometry = three.CylinderGeometry( radiusTop=0, radiusBottom=self.geometry.radius, height=self.geometry.height, + radialSegments=32, ) + transformation = self.y_to_z(self.geometry.transformation) - self._guids = self.geometry_to_objects(cone, color, contrastcolor) + self._guids = self.geometry_to_objects( + geometry, + color, + contrastcolor, + transformation=transformation, + ) return self.guids diff --git a/src/compas_notebook/scene/cylinderobject.py b/src/compas_notebook/scene/cylinderobject.py index ebe62ca..6ea4fb7 100644 --- a/src/compas_notebook/scene/cylinderobject.py +++ b/src/compas_notebook/scene/cylinderobject.py @@ -24,15 +24,19 @@ def draw(self, color: Color = None): color: Color = Color.coerce(color) or self.color contrastcolor: Color = color.darkened(50) if color.is_light else color.lightened(50) - cylinder = three.CylinderGeometry( + geometry = three.CylinderGeometry( radiusTop=self.geometry.radius, radiusBottom=self.geometry.radius, height=self.geometry.height, + radialSegments=32, ) - edges = three.EdgesGeometry(cylinder) - mesh = three.Mesh(cylinder, three.MeshBasicMaterial(color=color.hex)) - line = three.LineSegments(edges, three.LineBasicMaterial(color=contrastcolor.hex)) + transformation = self.y_to_z(self.geometry.transformation) - self._guids = [mesh, line] + self._guids = self.geometry_to_objects( + geometry, + color, + contrastcolor, + transformation=transformation, + ) return self.guids diff --git a/src/compas_notebook/scene/sceneobject.py b/src/compas_notebook/scene/sceneobject.py index fc146bc..f482be6 100644 --- a/src/compas_notebook/scene/sceneobject.py +++ b/src/compas_notebook/scene/sceneobject.py @@ -1,11 +1,31 @@ import pythreejs as three +import numpy +from compas.geometry import Transformation, Rotation from compas.scene import SceneObject +Rx = Rotation.from_axis_and_angle([1, 0, 0], 3.14159 / 2) + class ThreeSceneObject(SceneObject): """Base class for all PyThreeJS scene objects.""" - def geometry_to_objects(self, geometry, color, contrastcolor): + def y_to_z(self, transformation: Transformation) -> Transformation: + """Convert a transformation from COMPAS to the ThreeJS coordinate system. + + Parameters + ---------- + transformation : :class:`compas.geometry.Transformation` + The transformation to convert. + + Returns + ------- + :class:`compas.geometry.Transformation` + The converted transformation. + + """ + return transformation * Rx + + def geometry_to_objects(self, geometry, color, contrastcolor, transformation=None): """Convert a PyThreeJS geometry to a list of PyThreeJS objects. Parameters @@ -16,6 +36,8 @@ def geometry_to_objects(self, geometry, color, contrastcolor): The RGB color of the geometry. contrastcolor : rgb1 | rgb255 | :class:`compas.colors.Color` The RGB color of the edges. + transformation : :class:`compas.geometry.Transformation`, optional + The transformation to apply to the geometry. Returns ------- @@ -26,4 +48,12 @@ def geometry_to_objects(self, geometry, color, contrastcolor): edges = three.EdgesGeometry(geometry) mesh = three.Mesh(geometry, three.MeshBasicMaterial(color=color.hex, side="DoubleSide")) line = three.LineSegments(edges, three.LineBasicMaterial(color=contrastcolor.hex)) + + if transformation: + matrix = numpy.array(transformation.matrix, dtype=numpy.float32).transpose().ravel().tolist() + mesh.matrix = matrix + line.matrix = matrix + mesh.matrixAutoUpdate = False + line.matrixAutoUpdate = False + return [mesh, line] diff --git a/src/compas_notebook/scene/sphereobject.py b/src/compas_notebook/scene/sphereobject.py index abb9ba6..12e5d45 100644 --- a/src/compas_notebook/scene/sphereobject.py +++ b/src/compas_notebook/scene/sphereobject.py @@ -25,6 +25,12 @@ def draw(self, color=None): contrastcolor: Color = color.darkened(50) if color.is_light else color.lightened(50) geometry = sphere_to_threejs(self.geometry) - - self._guids = self.geometry_to_objects(geometry, color, contrastcolor) + transformation = self.y_to_z(self.geometry.transformation) + + self._guids = self.geometry_to_objects( + geometry, + color, + contrastcolor, + transformation=transformation, + ) return self.guids diff --git a/src/compas_notebook/scene/torusobject.py b/src/compas_notebook/scene/torusobject.py index d6ee2a6..4ac0b55 100644 --- a/src/compas_notebook/scene/torusobject.py +++ b/src/compas_notebook/scene/torusobject.py @@ -24,10 +24,16 @@ def draw(self, color: Color = None): color: Color = Color.coerce(color) or self.color contrastcolor: Color = color.darkened(50) if color.is_light else color.lightened(50) - torus = three.TorusGeometry( + geometry = three.TorusGeometry( radius=self.geometry.radius_axis, tube=self.geometry.radius_pipe, ) + transformation = self.y_to_z(self.geometry.transformation) - self._guids = self.geometry_to_objects(torus, color, contrastcolor) + self._guids = self.geometry_to_objects( + geometry, + color, + contrastcolor, + transformation=transformation, + ) return self.guids diff --git a/src/compas_notebook/viewer/grid.py b/src/compas_notebook/viewer/grid.py deleted file mode 100644 index aa2e529..0000000 --- a/src/compas_notebook/viewer/grid.py +++ /dev/null @@ -1,10 +0,0 @@ -from compas.colors import Color - - -class Grid: - def __init__(self, xsize=10, ysize=10, xstep=1, ystep=1, color=None): - self.xsize = xsize - self.ysize = ysize - self.xstep = xstep - self.ystep = ystep - self.color = color or Color.from_hex("#cccccc") diff --git a/src/compas_notebook/viewer/viewer.py b/src/compas_notebook/viewer/viewer.py index 82af4b9..441ce66 100644 --- a/src/compas_notebook/viewer/viewer.py +++ b/src/compas_notebook/viewer/viewer.py @@ -1,3 +1,4 @@ +from typing import Literal import pythreejs as three import ipywidgets as widgets from IPython.display import display as ipydisplay @@ -17,13 +18,19 @@ class Viewer: A dictionary of camera parameters. Valid keys are ``position``, ``up``, ``near``, ``far``, ``fov``. width : int, optional - Width of the viewer. + Width of the viewer scene. height : int, optional - Height of the viewer. + Height of the viewer scene. background : :class:`compas.colors.Color`, optional The background color of the scene. show_grid : bool, optional Show a grid in the scene. + show_axes : bool, optional + Show axes in the scene. + show_toolbar : bool, optional + Show the toolbar. + viewport : {'top', 'left', 'front', 'perspective'}, optional + The viewport of the viewer. Examples -------- @@ -35,7 +42,7 @@ class Viewer: >>> mesh = Mesh.from_obj(compas.get('tubemesh.obj')) >>> viewer = Viewer() >>> viewer.scene.add(mesh) # doctest: +SKIP - >>> viewer.display() # doctest: +SKIP + >>> viewer.show() # doctest: +SKIP """ @@ -43,28 +50,66 @@ def __init__( self, scene: Scene = None, camera: dict = None, - width: int = 1118, - height: int = 600, + width: int = 1100, + height: int = 580, background: Color = None, - show_grid: bool = False, + show_grid: bool = True, + show_axes: bool = True, + show_toolbar: bool = True, + viewport: Literal["top", "left", "front", "perspective"] = "perspective", ): aspect = width / height - background = background or Color.from_hex("#eeeeee") + background = Color.coerce(background) or Color.from_hex("#eeeeee") camera = camera or {} + self.width = width + self.height = height + self.viewport = viewport + + self.show_grid = show_grid + self.show_axes = show_axes + self.show_toolbar = show_toolbar + self.scene = scene or Scene(context="Notebook") - self.camera3 = three.PerspectiveCamera() - self.camera3.position = camera.get("position", [0, -10, 0]) - self.camera3.up = camera.get("up", [0, 0, 1]) - self.camera3.aspect = aspect - self.camera3.near = camera.get("near", 0.1) - self.camera3.far = camera.get("far", 10000) - self.camera3.fov = camera.get("fov", 50) - self.camera3.lookAt([0, 0, 0]) + # scene - self.controls3 = three.OrbitControls(controlling=self.camera3) self.scene3 = three.Scene(background=background.hex) + + if self.show_grid: + grid = three.GridHelper(size=20, divisions=20, colorCenterLine=Color.grey().hex, colorGrid=Color.grey().lightened(50).hex) + grid.rotateX(3.14159 / 2) + self.scene3.add(grid) + + if self.show_axes: + self.axes = three.AxesHelper(size=0.5) + self.scene3.add(self.axes) + + # camera and controls + + if self.viewport == "top": + self.camera3 = three.OrthographicCamera(width / -2, width / 2, height / 2, height / -2, 0.1, 10000) + self.camera3.position = camera.get("position", [0, 0, 1]) + self.camera3.zoom = 1 + self.controls3 = three.OrbitControls(controlling=self.camera3) + self.controls3.enableRotate = False + + elif self.viewport == "perspective": + self.camera3 = three.PerspectiveCamera() + self.camera3.position = camera.get("position", [0, -10, 5]) + self.camera3.up = camera.get("up", [0, 0, 1]) + self.camera3.aspect = aspect + self.camera3.near = camera.get("near", 0.1) + self.camera3.far = camera.get("far", 10000) + self.camera3.fov = camera.get("fov", 50) + self.camera3.lookAt(camera.get("target", [0, 0, 0])) + self.controls3 = three.OrbitControls(controlling=self.camera3) + + else: + raise NotImplementedError + + # renderer + self.renderer3 = three.Renderer( scene=self.scene3, camera=self.camera3, @@ -73,6 +118,31 @@ def __init__( height=height, ) + # ui + + self.init_ui() + + def init_ui(self): + """Initialize the user interface.""" + self.ui = widgets.VBox() + self.ui.layout.width = "auto" + self.ui.layout.height = f"{self.height + 4 + 48}px" if self.show_toolbar else f"{self.height + 4}px" + + self.main = widgets.Box() + self.main.layout.width = "auto" + self.main.layout.height = f"{self.height + 4}px" + self.main.children = [self.renderer3] + + self.toolbar = None + if self.show_toolbar: + self.toolbar = self.make_toolbar() + self.ui.children = [self.toolbar, self.main] + else: + self.toolbar = None + self.ui.children = [self.main] + + def make_toolbar(self): + """Initialize the toolbar.""" zoom_extents_button = widgets.Button(icon="square", tooltip="Zoom extents", layout=widgets.Layout(width="32px", height="32px")) zoom_extents_button.on_click(lambda x: self.zoom_extents()) @@ -82,25 +152,17 @@ def __init__( zoom_out_button = widgets.Button(icon="search-minus", tooltip="Zoom out", layout=widgets.Layout(width="32px", height="32px")) zoom_out_button.on_click(lambda x: self.zoom_out()) - self.toolbar = widgets.HBox() - self.toolbar.layout.display = "flex" - self.toolbar.layout.flex_flow = "row" - self.toolbar.layout.align_items = "center" - self.toolbar.layout.width = "100%" - self.toolbar.layout.height = "48px" - self.toolbar.children = [zoom_extents_button, zoom_in_button, zoom_out_button] - - self.main = widgets.Box() - self.main.layout.width = "100%" - self.main.layout.height = f"{height + 4}px" - self.main.children = [self.renderer3] + toolbar = widgets.HBox() + toolbar.layout.display = "flex" + toolbar.layout.flex_flow = "row" + toolbar.layout.align_items = "center" + toolbar.layout.width = "auto" + toolbar.layout.height = "48px" + toolbar.children = [zoom_extents_button, zoom_in_button, zoom_out_button] - self.ui = widgets.VBox() - self.ui.layout.width = "100%" - self.ui.layout.height = f"{height + 4 + 48}px" - self.ui.children = [self.toolbar, self.main] + return toolbar - def display(self): + def show(self): """Display the viewer in the notebook.""" self.scene.draw() for o in self.scene.objects: @@ -113,13 +175,16 @@ def zoom_extents(self): xmin = ymin = zmin = +1e12 xmax = ymax = zmax = -1e12 for obj in self.scene.objects: - box = Box.from_bounding_box(obj.mesh.aabb()) - xmin = min(xmin, box.xmin) - ymin = min(ymin, box.ymin) - zmin = min(zmin, box.zmin) - xmax = max(xmax, box.xmax) - ymax = max(ymax, box.ymax) - zmax = max(zmax, box.zmax) + if hasattr(obj, "mesh"): + box = Box.from_bounding_box(obj.mesh.aabb()) + xmin = min(xmin, box.xmin) + ymin = min(ymin, box.ymin) + zmin = min(zmin, box.zmin) + xmax = max(xmax, box.xmax) + ymax = max(ymax, box.ymax) + zmax = max(zmax, box.zmax) + elif hasattr(obj, "geometry"): + pass dx = xmax - xmin dy = ymax - ymin dz = zmax - zmin @@ -127,10 +192,20 @@ def zoom_extents(self): cy = (ymax + ymin) / 2 cz = (zmax + zmin) / 2 d = max(dx, dy, dz) - self.camera3.position = [cx, cy - d, cz] - self.camera3.lookAt([cx, cy, cz]) - self.controls3.target = [cx, cy, cz] - self.camera3.zoom = 1 + + if self.viewport == "perspective": + self.camera3.position = [cx, cy - d, cz + 0.5 * d] + self.camera3.lookAt([cx, cy, cz]) + self.camera3.zoom = 1 + self.controls3.target = [cx, cy, cz] + + elif self.viewport == "top": + self.camera3.position = [cx, cy, cz + d] + self.camera3.zoom = min(0.75 * self.width / d, 0.75 * self.height / d) + self.controls3.target = [cx, cy, cz] + + else: + raise NotImplementedError def zoom_in(self): """Zoom in."""