diff --git a/docs/source/index.rst b/docs/source/index.rst index e51f7356..c009b663 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -134,6 +134,7 @@ Contents examples api vr + pythreejs Changelog diff --git a/docs/source/pythreejs.ipynb b/docs/source/pythreejs.ipynb new file mode 100644 index 00000000..fc8e9993 --- /dev/null +++ b/docs/source/pythreejs.ipynb @@ -0,0 +1,155 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Integration with pythreejs\n", + "ipyvolume uses parts of pythreejs, giving a lot of flexibility to tweak the visualizations or behaviour.\n", + "## Materials\n", + "The Scatter object has a `material` and `line_material` object, which both are a ShaderMaterial pythreejs object: `https://pythreejs.readthedocs.io/en/stable/api/materials/ShaderMaterial_autogen.html`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets\n", + "import numpy as np\n", + "import ipyvolume as ipv" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# a scatter plot\n", + "x, y, z = np.random.normal(size=(3, 100))\n", + "fig = ipv.figure()\n", + "scatter = ipv.scatter(x, y, z, marker='box')\n", + "scatter.connected = True # draw connecting lines\n", + "ipv.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Using `scatter.material` we can tweak the material setting:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scatter.material.visible = False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or even connect a toggle button to a `line_material` property." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "toggle_lines = widgets.ToggleButton(description=\"Show lines\")\n", + "widgets.jslink((scatter.line_material, 'visible'), (toggle_lines, 'value'))\n", + "toggle_lines" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Controls\n", + "ipyvolume has builtin controls. For more flexibility, a Controls class from https://pythreejs.readthedocs.io/en/stable/api/controls/index.html can be contructed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pythreejs\n", + "import ipyvolume as ipv\n", + "import numpy as np\n", + "fig = ipv.figure()\n", + "scatter = ipv.scatter(x, y, z, marker='box')\n", + "ipv.show()\n", + "\n", + "control = pythreejs.OrbitControls(controlling=fig.camera)\n", + "# assigning to fig.controls will overwrite the builtin controls\n", + "fig.controls = control\n", + "control.autoRotate = True\n", + "# the controls does not update itself, but if we toggle this setting, ipyvolume will update the controls\n", + "fig.render_continuous = True\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "control.autoRotate = True\n", + "toggle_rotate = widgets.ToggleButton(description=\"Rotate\")\n", + "widgets.jslink((control, 'autoRotate'), (toggle_rotate, 'value'))\n", + "toggle_rotate" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Camera\n", + "The camera property of ipyvolume is by default a PerspectiveCamera, but other cameras should also work: https://pythreejs.readthedocs.io/en/stable/api/cameras/index.html\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text = widgets.Text()\n", + "widgets.jslink((fig.camera, 'position'), (text, 'value'))\n", + "text" + ] + } + ], + "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.7.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ipyvolume/widgets.py b/ipyvolume/widgets.py index 39f2e267..ece3dd69 100644 --- a/ipyvolume/widgets.py +++ b/ipyvolume/widgets.py @@ -266,6 +266,10 @@ def _default_camera(self): z = 2 * np.tan(45.0 / 2.0 * np.pi / 180) / np.tan(self.camera_fov / 2.0 * np.pi / 180) return pythreejs.PerspectiveCamera(fov=self.camera_fov, position=(0, 0, z), width=400, height=500) + controls = traitlets.Instance( + pythreejs.Controls, allow_none=True, help='A :any:`pythreejs.Controls` instance to control the camera' + ).tag(sync=True, **widgets.widget_serialization) + scene = traitlets.Instance(pythreejs.Scene, allow_none=True).tag(sync=True, **widgets.widget_serialization) @traitlets.default('scene') diff --git a/js/src/figure.ts b/js/src/figure.ts index fda432aa..39868b8e 100644 --- a/js/src/figure.ts +++ b/js/src/figure.ts @@ -106,6 +106,7 @@ class FigureModel extends widgets.DOMWidgetModel { volumes: { deserialize: widgets.unpack_models }, camera: { deserialize: widgets.unpack_models }, scene: { deserialize: widgets.unpack_models }, + controls: { deserialize: widgets.unpack_models }, }; defaults() { return {...super.defaults(), @@ -237,6 +238,7 @@ class FigureView extends widgets.DOMWidgetView { selector: any; last_tick_selection: d3.Selection; model: FigureModel; + control_external: any = null; // helper methods for testing/debugging debug_readPixel(x, y) { const buffer = new Uint8Array(4); @@ -513,15 +515,17 @@ class FigureView extends widgets.DOMWidgetView { this.model.get("camera").on("change", () => { // the threejs' lookAt ignore the quaternion, and uses the up vector // we manually set it ourselve - const up = new THREE.Vector3(0, 1, 0); - up.applyQuaternion(this.camera.quaternion); - this.camera.up = up; - this.camera.lookAt(0, 0, 0); - // TODO: shouldn't we do the same with the orbit control? - this.control_trackball.position0 = this.camera.position.clone(); - this.control_trackball.up0 = this.camera.up.clone(); - // TODO: if we implement figure.look_at, we should update control's target as well - this.update(); + if (!this.control_external) { + const up = new THREE.Vector3(0, 1, 0); + up.applyQuaternion(this.camera.quaternion); + this.camera.up = up; + this.camera.lookAt(0, 0, 0); + // TODO: shouldn't we do the same with the orbit control? + this.control_trackball.position0 = this.camera.position.clone(); + this.control_trackball.up0 = this.camera.up.clone(); + // TODO: if we implement figure.look_at, we should update control's target as well + this.update(); + } }); } else { this.camera = new THREE.PerspectiveCamera(46, 1, NEAR, FAR); @@ -750,6 +754,9 @@ class FigureView extends widgets.DOMWidgetView { this.mouse_trail = []; // list of x, y positions this.select_overlay = null; // lasso or sth else? + // setup controls, 2 builtin custom controls, or an external + // pythreejs control + this.control_trackball = new THREE.TrackballControls(this.camera, this.renderer.domElement); this.control_orbit = new THREE.OrbitControls(this.camera, this.renderer.domElement); this.control_trackball.dynamicDampingFactor = 1.; @@ -762,6 +769,42 @@ class FigureView extends widgets.DOMWidgetView { this.control_trackball.rotateSpeed = 0.5; this.control_trackball.zoomSpeed = 3.; + const update_angles_bound = this.update_angles.bind(this); + const update_bound = this.update.bind(this); + + this.control_trackball.addEventListener("end", update_angles_bound); + this.control_orbit.addEventListener("end", update_angles_bound); + this.control_trackball.addEventListener("change", update_bound); + this.control_orbit.addEventListener("change", update_bound); + + const sync_controls_external = () => { + const controls = this.model.get("controls"); + const controls_previous = (this.model.previousAttributes as any).controls; + // first remove previous event handlers + if (controls_previous) { + const control_external = controls_previous.obj; + control_external.removeEventListener("end", update_angles_bound); + control_external.removeEventListener("change", update_bound); + control_external.dispose(); + } + // and add new event handlers + if (controls) { + // get the threejs object + this.control_external = controls.obj; + this.control_external.addEventListener("end", update_angles_bound); + this.control_external.addEventListener("change", update_bound); + this.control_external.connectEvents(this.el); // custom pythreejs method + } else { + this.control_external = null; + } + this.update_mouse_mode(); + }; + + sync_controls_external(); + this.model.on("change:controls", () => { + sync_controls_external(); + }); + window.addEventListener("deviceorientation", this.on_orientationchange.bind(this), false); const render_size = this.getRenderSize(); @@ -929,11 +972,6 @@ class FigureView extends widgets.DOMWidgetView { this.model.on("change:tf", this.tf_set, this); this.listenTo(this.model, "msg:custom", this.custom_msg.bind(this)); - this.control_trackball.addEventListener("end", this.update_angles.bind(this)); - this.control_orbit.addEventListener("end", this.update_angles.bind(this)); - this.control_trackball.addEventListener("change", this.update.bind(this)); - this.control_orbit.addEventListener("change", this.update.bind(this)); - this.renderer.domElement.addEventListener("resize", this.on_canvas_resize.bind(this), false); this.update(); @@ -987,8 +1025,13 @@ class FigureView extends widgets.DOMWidgetView { update_mouse_mode() { const normal_mode = this.model.get("mouse_mode") === "normal"; - this.control_trackball.enabled = this.model.get("camera_control") === "trackball" && normal_mode; - this.control_orbit.enabled = this.model.get("camera_control") === "orbit" && normal_mode; + if (this.model.get("controls")) { + this.control_trackball.enabled = false; + this.control_orbit.enabled = false; + } else { + this.control_trackball.enabled = this.model.get("camera_control") === "trackball" && normal_mode; + this.control_orbit.enabled = this.model.get("camera_control") === "orbit" && normal_mode; + } } mousewheel(e) { @@ -1610,10 +1653,18 @@ class FigureView extends widgets.DOMWidgetView { _real_update() { this.control_trackball.handleResize(); + if (this.control_external) { + this.control_external.update(); + // it's very likely the controller will update the camera, so we sync it to the kernel + this.camera.ipymodel.syncToModel(true); + } + this._update_requested = false; // since the threejs animation system can update the camera, - // make sure we keep looking at the center - this.camera.lookAt(0, 0, 0); + // make sure we keep looking at the center (only for ipyvolume's own control) + if (!this.control_external) { + this.camera.lookAt(0, 0, 0); + } this.renderer.setClearColor(this.get_style_color("background-color")); this.x_axis.visible = this.get_style("axes.x.visible axes.visible");