Skip to content

Commit

Permalink
Merge pull request #22 from edsaac/custom_vtkjs
Browse files Browse the repository at this point in the history
Custom vtkjs for interactive view
  • Loading branch information
edsaac authored Apr 11, 2024
2 parents 40f9489 + 751699f commit 0d23daa
Show file tree
Hide file tree
Showing 15 changed files with 269 additions and 19 deletions.
4 changes: 4 additions & 0 deletions stpyvista/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [v 0.0.17] - 2024-04-11
- Rewrite buffer using context manager
- Introduce and experimental viewer based on trame and vanilla vtk-js

## [v 0.0.16] - 2024-03-29
- Add controls help description
- Remove network utility - it should be a different component
Expand Down
2 changes: 1 addition & 1 deletion stpyvista/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "stpyvista"
version = "0.0.16"
version = "0.0.17"
authors = [
{ name="Edwin Saavedra C.", email="[email protected]" },
]
Expand Down
2 changes: 1 addition & 1 deletion stpyvista/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
streamlit
pyvista
bokeh
panel
panel<1.4.0
65 changes: 48 additions & 17 deletions stpyvista/src/stpyvista/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
# __init__.py

from io import BytesIO
from io import StringIO
from pathlib import Path
from typing import Optional, Literal
import base64

import streamlit.components.v1 as components
import pyvista as pv
from pyvista.plotting import Plotter

import panel as pn

from bokeh.resources import CDN, INLINE

BOKEH_RESOURCES = {"CDN": CDN, "INLINE": INLINE}

pn.extension("vtk", sizing_mode="stretch_both")

# Tell streamlit that there is a component called stpyvista,
# and that the code to display that component is in the "frontend" folder
frontend_dir = (Path(__file__).parent / "frontend").absolute()
_component_func = components.declare_component("stpyvista", path=str(frontend_dir))
BOKEH_RESOURCES = {"CDN": CDN, "INLINE": INLINE}


class stpyvistaTypeError(TypeError):
Expand All @@ -27,19 +24,54 @@ class stpyvistaValueError(ValueError):
pass


# Create the python function that will be called from the front end
# Tell streamlit that there is a component called `experimental_vtkjs`,
# and that the code to display that component is in the "vanilla_vtkjs" folder
experimental_frontend_dir = (Path(__file__).parent / "vanilla_vtkjs").absolute()
_exp_component_func = components.declare_component(
"experimental_vtkjs", path=str(experimental_frontend_dir)
)


def experimental_vtkjs(vtksz_data: bytes, key: Optional[str] = None):
"""
Renders an interactive Pyvista Plotter in streamlit.
Parameters
----------
vtksz_data: bytes
Data from a vtksz in zip format.
Returns
-------
str
A stringified JSON with camera view properties.
"""

base64_str = base64.b64encode(vtksz_data).decode().replace("\n", "")

component_value = _exp_component_func(
plotter_data=base64_str,
key=key,
default=0,
)

return component_value


frontend_dir = (Path(__file__).parent / "panel_based").absolute()
_component_func = components.declare_component("stpyvista", path=str(frontend_dir))


def stpyvista(
plotter: pv.Plotter,
plotter: Plotter,
use_container_width: bool = True,
horizontal_align: Literal["center", "left", "right"] = "center",
panel_kwargs: Optional[dict] = None,
bokeh_resources: Literal["CDN", "INLINE"] = "INLINE",
key: Optional[str] = None,
) -> None:
"""
Renders an interactive pyvisya Plotter in streamlit.
Renders an interactive Pyvista Plotter in streamlit.
Parameters
----------
Expand Down Expand Up @@ -79,7 +111,7 @@ def stpyvista(
"""

if isinstance(plotter, pv.Plotter):
if isinstance(plotter, Plotter):
if panel_kwargs is None:
panel_kwargs = dict()

Expand All @@ -95,10 +127,9 @@ def stpyvista(
)

# Create HTML file
model_bytes = BytesIO()
geo_pan_pv.save(model_bytes, resources=BOKEH_RESOURCES[bokeh_resources])
panel_html = model_bytes.getvalue().decode("utf-8")
model_bytes.close()
with StringIO() as model_bytes:
geo_pan_pv.save(model_bytes, resources=BOKEH_RESOURCES[bokeh_resources])
panel_html = model_bytes.getvalue()

component_value = _component_func(
panel_html=panel_html,
Expand Down
53 changes: 53 additions & 0 deletions stpyvista/src/stpyvista/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from trame.app import get_server
from trame.widgets import (
vuetify as vuetify,
)

from trame.widgets.vtk import VtkLocalView
from trame.ui.vuetify import SinglePageLayout

from pyvista.plotting import Plotter

SERVER_NAME = "stpyvista_server"


async def export_vtksz(plotter: Plotter):
"""Export this plotter as a VTK.js OfflineLocalView file.
Parameters
----------
plotter : Plotter
PyVista Plotter object.
Returns
-------
bytes
The exported plotter view.
"""

# Get a trame server and launch it
server = get_server(name=SERVER_NAME, client_type="vue2")
_, ctrl = server.state, server.controller

with SinglePageLayout(server) as layout:
with layout.content:
view = VtkLocalView(plotter.ren_win)
ctrl.view_update = view.update

server.start(
exec_mode="task",
host="127.0.0.1",
port="0",
open_browser=False,
show_connection_info=False,
disable_logging=True,
timeout=0,
backend="tornado",
)

content = view.export(format=format)
view.release_resources()

return content


File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions stpyvista/src/stpyvista/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ def start_xvfb():
f"{is_xvfb_running.stdout}"
f"{is_xvfb_running.stderr}"
)

20 changes: 20 additions & 0 deletions stpyvista/src/stpyvista/vanilla_vtkjs/index.html

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions stpyvista/src/stpyvista/vanilla_vtkjs/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
function post_camera_state() {
const camera = renderWindow.getRenderers()[1].getActiveCamera();

var cameraProperties = {
position: camera.getPosition(),
focal_point: camera.getFocalPoint(),
up: camera.getViewUp(),
view_angle: camera.getViewAngle(),
clipping_range: camera.getClippingRange(),
parallel_projection: camera.getParallelProjection()
};

Streamlit.setComponentValue(JSON.stringify(cameraProperties));
}

function onRender(event) {

// Only run the render code the first time the component is loaded.
if (!window.rendered) {

// You most likely want to get the data passed in like this
const { plotter_data, key } = event.detail.args;

var container = document.querySelector('.content');
var base64Str = plotter_data;
OfflineLocalView.load(container, { base64Str });

const interactor = renderWindow.getInteractor();

interactor.onLeftButtonRelease((event) => {
post_camera_state()
});

interactor.onMouseWheel((event) => {
post_camera_state()
});
window.rendered = true;
Streamlit.setFrameHeight('300');
}
}

Streamlit.events.addEventListener(Streamlit.RENDER_EVENT, onRender);
Streamlit.setComponentReady();
35 changes: 35 additions & 0 deletions stpyvista/src/stpyvista/vanilla_vtkjs/streamlit-component-lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

// Borrowed minimalistic Streamlit API from Thiago
// https://discuss.streamlit.io/t/code-snippet-create-components-without-any-frontend-tooling-no-react-babel-webpack-etc/13064
function sendMessageToStreamlitClient(type, data) {
console.log(type, data)
const outData = Object.assign({
isStreamlitMessage: true,
type: type,
}, data);
window.parent.postMessage(outData, "*");
}

const Streamlit = {
setComponentReady: function() {
sendMessageToStreamlitClient("streamlit:componentReady", {apiVersion: 1});
},
setFrameHeight: function(height) {
sendMessageToStreamlitClient("streamlit:setFrameHeight", {height: height});
},
setComponentValue: function(value) {
sendMessageToStreamlitClient("streamlit:setComponentValue", {value: value});
// sendMessageToStreamlitClient("streamlit:setComponentValue", {value});
},
RENDER_EVENT: "streamlit:render",
events: {
addEventListener: function(type, callback) {
window.addEventListener("message", function(event) {
if (event.data.type === type) {
event.detail = event.data
callback(event);
}
});
}
}
}
63 changes: 63 additions & 0 deletions stpyvista/test/sphere.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import streamlit as st
import pyvista as pv
from stpyvista import experimental_vtkjs, stpyvista
from stpyvista.export import export_vtksz
import asyncio

@st.cache_resource
def create_plotter(dummy:str = "sphere"):

# Initialize a plotter object
plotter = pv.Plotter(window_size=[400, 400])
mesh = pv.Sphere(radius=1.0, center=(0, 0, 0))
x, y, z = mesh.cell_centers().points.T
mesh["My scalar"] = z

# Scalar bar configuration
# scalar_bar_kwargs = dict(
# font_family='arial',
# interactive=True,
# position_x = 0.05,
# position_y = 0.05,
# vertical=False
# )

## Add mesh to the plotter
plotter.add_mesh(
mesh,
scalars="My scalar",
cmap="prism",
show_edges=True,
edge_color="#001100",
ambient=0.2,
show_scalar_bar = False
)

## Some final touches
plotter.background_color = "pink"
plotter.view_isometric()

return plotter

async def main():

st.set_page_config(page_icon="🧊", layout="wide")
st.title("🧊 `stpyvista`")
st.write("*Show PyVista 3D visualizations in Streamlit*")

plotter = create_plotter()

if "data" not in st.session_state:
st.session_state.data = await export_vtksz(plotter)

lcol, rcol = st.columns(2)
with rcol:
"🌎 3D Model"
camera = experimental_vtkjs(st.session_state.data, key="experimental-stpv")

with lcol:
"🎥 Camera"
st.json(camera)

if __name__ == "__main__":
asyncio.run(main())

0 comments on commit 0d23daa

Please sign in to comment.