Skip to content

VTK 3D Widgets with VTK.wasm and trame

VTK 3D widgets let you interact with the 3D view in a way that you can drive a processing filter interactively.

In the example below we cover how you can create such widget in plain VTK and how to enable it on the client within the WASM context.

Clip Plane Widget

The code below highlight some important region and logic to understand.

py
# Widget setup
rep = vtk.vtkImplicitPlaneRepresentation(
    place_factor=1.25,
    outline_translation=False,
)
rep.DrawPlaneOff()
rep.PlaceWidget(bounds)
rep.normal = plane.normal
rep.origin = plane.origin

plane_widget = vtk.vtkImplicitPlaneWidget2(
    interactor=renderWindowInteractor, representation=rep
)
plane_widget.On()
py
with vtklocal.LocalView(
    self.render_window,
    throttle_rate=20,
    ctx_name="wasm_view",
) as view:
    self.ctrl.view_update = view.update_throttle
    self.ctrl.view_reset_camera = view.reset_camera

    # ---------------------------------------------------------
    # Widget handling
    # ---------------------------------------------------------

    # => push widget to client
    wasm_id = view.register_vtk_object(self.widget)

    # => attach listener to widget and bind data to state
    view.listeners = (
        "listeners",
        {
            wasm_id: {
                "InteractionEvent": {
                    "plane_widget": {
                        "origin": (
                            wasm_id,
                            "WidgetRepresentation",
                            "Origin",
                        ),
                        "normal": (
                            wasm_id,
                            "WidgetRepresentation",
                            "Normal",
                        ),
                    },
                },
            },
        },
    )

    # => reserve state variable for widget update
    self.state.plane_widget = None
py
@change("plane_widget")
def on_plane_widget(self, plane_widget, **_):
    if plane_widget is None:
        return

    origin = plane_widget.get("origin")
    normal = plane_widget.get("normal")

    self.plane.SetOrigin(origin)
    self.plane.SetNormal(normal)

    self.ctrl.view_update()
py
# Should import classes from vtkmodules
# but as an example we use vtk for simplicity
import vtk

from pathlib import Path

from trame.app import TrameApp
from trame.decorators import change
from trame.ui.vuetify3 import SinglePageLayout
from trame.widgets import vuetify3, vtklocal

DATA_PATH = str(Path(__file__).with_name("star-fighter.vtp").resolve())


class Clip(TrameApp):
    def __init__(self, server=None):
        super().__init__(server)
        self._setup_vtk()
        self._build_ui()

    def _setup_vtk(self):
        renderer = vtk.vtkRenderer()
        renderWindow = vtk.vtkRenderWindow()
        renderWindow.AddRenderer(renderer)
        renderWindow.OffScreenRenderingOn()

        renderWindowInteractor = vtk.vtkRenderWindowInteractor()
        renderWindowInteractor.SetRenderWindow(renderWindow)
        renderWindowInteractor.GetInteractorStyle().SetCurrentStyleToTrackballCamera()

        reader = vtk.vtkXMLPolyDataReader(file_name=DATA_PATH)
        bounds = reader().GetBounds()

        plane = vtk.vtkPlane(
            origin=(
                0.5 * (bounds[0] + bounds[1]),
                0.5 * (bounds[2] + bounds[3]),
                0.5 * (bounds[4] + bounds[5]),
            )
        )

        clipper = vtk.vtkClipDataSet(clip_function=plane)
        geometry = vtk.vtkDataSetSurfaceFilter()

        ctx_mapper = vtk.vtkPolyDataMapper()
        reader >> ctx_mapper

        clip_mapper = vtk.vtkPolyDataMapper()
        reader >> clipper >> geometry >> clip_mapper

        clip_actor = vtk.vtkActor(mapper=clip_mapper)
        renderer.AddActor(clip_actor)

        ctx_actor = vtk.vtkActor(mapper=ctx_mapper)
        renderer.AddActor(ctx_actor)
        ctx_actor.property.opacity = 0.1

        renderer.ResetCamera()
        renderWindow.Render()

        # region widget
        # Widget setup
        rep = vtk.vtkImplicitPlaneRepresentation(
            place_factor=1.25,
            outline_translation=False,
        )
        rep.DrawPlaneOff()
        rep.PlaceWidget(bounds)
        rep.normal = plane.normal
        rep.origin = plane.origin

        plane_widget = vtk.vtkImplicitPlaneWidget2(
            interactor=renderWindowInteractor, representation=rep
        )
        plane_widget.On()
        # endregion widget

        self.plane = plane
        self.render_window = renderWindow
        self.actor = clip_actor
        self.ctx_actor = ctx_actor
        self.widget = plane_widget

    def _build_ui(self):
        with SinglePageLayout(self.server) as layout:
            self.ui = layout

            layout.title.set_text("WASM Widget")
            layout.icon.click = self.ctrl.view_reset_camera

            with layout.toolbar as toolbar:
                toolbar.density = "compact"
                vuetify3.VSpacer()
                vuetify3.VCheckbox(
                    true_icon="mdi-eye-outline",
                    false_icon="mdi-eye-off-outline",
                    v_model=("show_ctx", True),
                    hide_details=True,
                    density="compact",
                )
                vuetify3.VSlider(
                    prepend_icon="mdi-opacity",
                    v_model=("opacity", 1),
                    min=0,
                    max=1,
                    step=0.01,
                    density="compact",
                    hide_details=True,
                    classes="mr-6",
                )

            with layout.content:
                # region trameWidget
                with vtklocal.LocalView(
                    self.render_window,
                    throttle_rate=20,
                    ctx_name="wasm_view",
                ) as view:
                    self.ctrl.view_update = view.update_throttle
                    self.ctrl.view_reset_camera = view.reset_camera

                    # ---------------------------------------------------------
                    # Widget handling
                    # ---------------------------------------------------------

                    # => push widget to client
                    wasm_id = view.register_vtk_object(self.widget)

                    # => attach listener to widget and bind data to state
                    view.listeners = (
                        "listeners",
                        {
                            wasm_id: {
                                "InteractionEvent": {
                                    "plane_widget": {
                                        "origin": (
                                            wasm_id,
                                            "WidgetRepresentation",
                                            "Origin",
                                        ),
                                        "normal": (
                                            wasm_id,
                                            "WidgetRepresentation",
                                            "Normal",
                                        ),
                                    },
                                },
                            },
                        },
                    )

                    # => reserve state variable for widget update
                    self.state.plane_widget = None

    # endregion trameWidget

    @change("show_ctx")
    def on_show_ctx(self, show_ctx, **_):
        self.ctx_actor.visibility = show_ctx
        self.ctrl.view_update()

    @change("opacity")
    def on_opacity(self, opacity, **_):
        self.actor.property.opacity = opacity
        self.ctrl.view_update()

    # region trameChange
    @change("plane_widget")
    def on_plane_widget(self, plane_widget, **_):
        if plane_widget is None:
            return

        origin = plane_widget.get("origin")
        normal = plane_widget.get("normal")

        self.plane.SetOrigin(origin)
        self.plane.SetNormal(normal)

        self.ctrl.view_update()

    # endregion trameChange


def main():
    # region export
    import sys

    app = Clip()
    if "--export" in sys.argv:
        app.ctx.wasm_view.save("star-fighter.wazex")

    app.server.start()
    # endregion export


if __name__ == "__main__":
    main()
txt
--extra-index-url https://wheels.vtk.org

trame>=3.9
trame-vuetify
trame-vtklocal>=0.12.3

vtk==9.5.20250531.dev0

Explanation

If we just focus on the trame integration of a 3D widget for WASM, we realize that there is 3 steps that needs to be followed.

  1. First you need to register the widget to vtklocal.LocalView instance so the class get instantiated on the client side as well with all its behavior. This is done line 126 (view.register_vtk_object(self.widget)).
  2. Then we need to attach a listener to that widget and bind a trame state to some internal of the vtk objects that is living on the WASM side. For that we rely on the listener property. The listener structure is a set of nested dictionaries where the root keys are the various VTK objects (WASM id of such object) on which you want to bind observers. The second layer is the name of the VTK event you want the system to listen to. The thrid layer is the trame state variable that will be update. Each trame state variable will be a JavaScript object itself where each key will be resolved via some property lookup on the WASM instance. For the final data lookup piece, you need to provide a path to the data you try to extract like the following examples (wasm_id_of_vtk_object, PropertyName...). If you just put the wasm_id, you will get the full state of the corresponding vtkObject.
  3. Finally, on the trame state side, you need to listen to the variable so you can map to the filter and ask the widget to synchronize its data with the server.

The full working code is also available.