feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
3 changed files with 556 additions and 0 deletions
Showing only changes of commit 804bf22f61 - Show all commits

feat: plotly.js canvas widget (WebEngine + QWebChannel)

PlotlyCanvas hosts plotly.js in a QWebEngineView driven over
QWebChannel: figures update with Plotly.react (the camera survives
unless a view preset asks for it), and clicks round-trip as
node/element picks or grid-snap clicks. Assets are written to a temp
dir and loaded from file:// because the ~5 MB bundle is past
setHtml's data-URL limit. render() forwards QWidget's overload so
grab() and painting keep working.
smillmorel 2026-09-16 18:43:59 -04:00

View file

@ -0,0 +1,28 @@
"""Qt ↔ plotly.js bridge exposed through ``QWebChannel``.
The page calls :meth:`_Bridge.picked` / :meth:`_Bridge.snapClicked`; every
call is re-emitted here and wired to the canvas' own Qt signals.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, Signal, Slot
class _Bridge(QObject):
"""Slot surface the JavaScript side addresses as ``otkoBridge``."""
#: (kind, entity id, additive modifier held) where kind is node|element.
picked = Signal(str, int, bool)
#: World-space coordinates of a clicked grid intersection.
snapClicked = Signal(float, float, float)
@Slot(str, int, bool)
def onPicked(self, kind: str, entity_id: int, additive: bool) -> None:
"""JS entry point: a model entity (or snap target) was clicked."""
self.picked.emit(kind, int(entity_id), bool(additive))
@Slot(float, float, float)
def onSnapClicked(self, x: float, y: float, z: float) -> None:
"""JS entry point: a grid-intersection target was clicked."""
self.snapClicked.emit(float(x), float(y), float(z))

View file

@ -0,0 +1,175 @@
"""HTML/JS runtime for the Plotly canvas.
The page is materialised once per process into a temp directory and loaded
from ``file://``: the plotly.js bundle is ~5 MB, which is past
``QWebEngineView.setHtml``'s data-URL limit, and writing it to disk also lets
the browser cache it across figure updates.
The JS side exposes three entry points to Python (called via
``QWebEnginePage.runJavaScript``):
- ``otkoUpdate(payloadJson)`` — replace data + layout with ``Plotly.react``,
which diffs client-side and leaves the interactive camera untouched.
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
parallel-projection toggle).
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
"""
from __future__ import annotations
import atexit
import shutil
import tempfile
from pathlib import Path
#: Materialised runtime directory (plotly.min.js + index.html + qwebchannel.js).
_ASSETS_DIR: Path | None = None
# ``qrc:///qtwebchannel/qwebchannel.js`` is served by QtWebEngine's internal
# resource scheme; it is not reachable through QFile.
_PAGE = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
#plot { width: 100%; height: 100%; }
.modebar { display: none !important; }
</style>
</head>
<body>
<div id="plot"></div>
<script src="plotly.min.js"></script>
<script>
(function () {
var bridge = null;
var snapEnabled = false;
var handlersReady = false;
var hoverIndex = -1;
var config = {
responsive: true,
displayModeBar: false,
scrollZoom: true,
doubleClick: false,
displaylogo: false
};
new QWebChannel(qt.webChannelTransport, function (channel) {
bridge = channel.objects.otkoBridge;
window.otkoBridge = bridge;
});
function kindOf(point) {
var data = point && point.data;
return data && data.meta ? data.meta.kind : null;
}
function findHover() {
var gd = document.getElementById('plot');
hoverIndex = -1;
if (!gd || !gd.data) return;
for (var i = 0; i < gd.data.length; i++) {
var meta = gd.data[i] && gd.data[i].meta;
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
}
}
function setHover(x, y, z) {
if (hoverIndex < 0) return;
Plotly.restyle('plot', { x: [[x]], y: [[y]], z: [[z]] }, [hoverIndex]);
}
function clearHover() {
if (hoverIndex < 0) return;
Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]);
}
function installHandlers() {
if (handlersReady) return;
var gd = document.getElementById('plot');
if (!gd || !gd.on) return;
handlersReady = true;
gd.on('plotly_click', function (ev) {
var pts = ev.points || [];
if (!pts.length || !bridge) return;
var p = pts[0];
var kind = kindOf(p);
var additive = !!(ev.event && (ev.event.shiftKey || ev.event.ctrlKey || ev.event.metaKey));
if (kind === 'node' || kind === 'element') {
bridge.onPicked(kind, p.customdata, additive);
} else if (kind === 'snap') {
var c = p.customdata;
bridge.onSnapClicked(c[0], c[1], c[2]);
}
});
gd.on('plotly_hover', function (ev) {
if (!snapEnabled) return;
var pts = ev.points || [];
if (!pts.length) return;
var p = pts[0];
if (kindOf(p) !== 'snap') { clearHover(); return; }
var c = p.customdata;
setHover(c[0], c[1], c[2]);
});
gd.on('plotly_unhover', function () { clearHover(); });
}
window.otkoUpdate = function (payloadJson) {
var fig = JSON.parse(payloadJson);
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers();
});
};
window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
};
window.otkoSetSnapEnabled = function (on) {
snapEnabled = !!on;
if (!snapEnabled) clearHover();
};
})();
</script>
</body>
</html>
"""
def runtime_url() -> str:
"""Ensure the JS runtime is on disk and return the page's file path.
Called once per :class:`~otko.views.canvas_plotly.PlotlyCanvas`; the
directory is reused and removed at interpreter exit.
"""
global _ASSETS_DIR
directory = _ensure_dir()
index = directory / "index.html"
if not index.exists():
(directory / "index.html").write_text(_PAGE, encoding="utf-8")
(directory / "plotly.min.js").write_text(_plotly_js(), encoding="utf-8")
return str(index)
def _ensure_dir() -> Path:
global _ASSETS_DIR
if _ASSETS_DIR is None:
_ASSETS_DIR = Path(tempfile.mkdtemp(prefix="otko-plotly-"))
atexit.register(shutil.rmtree, _ASSETS_DIR, True)
return _ASSETS_DIR
def _plotly_js() -> str:
"""The offline plotly.js bundle shipped inside the ``plotly`` package."""
from plotly.offline import get_plotlyjs
return get_plotlyjs()

View file

@ -0,0 +1,353 @@
"""Plotly-backed 3D canvas.
A ``QWebEngineView`` hosting plotly.js, driven over ``QWebChannel``. It
implements the same public surface as :class:`otko.views.canvas3d.ModelCanvas`
(signals, selection, working plane, view presets, display toggles) so
``MainWindow`` can swap the two at runtime.
Update strategy: ``Plotly.react`` diffs client-side, and the layout only
carries ``scene.camera`` when a view preset or the projection toggle asks for
it — so re-rendering on a model edit or selection change never yanks the
camera the user is orbiting.
"""
from __future__ import annotations
import json
import math
from dataclasses import replace
from typing import Any
from PySide6.QtCore import QUrl, Signal
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QVBoxLayout, QWidget
from otko.views.canvas3d.model_renderer import RendererMode
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
from otko.views.canvas_base import CanvasCapabilities
from otko.views.canvas_plotly import html as _html
from otko.views.canvas_plotly.bridge import _Bridge
from otko.views.canvas_plotly.trace_builder import (
PlotlyTraceBuilder,
Scene,
SceneOptions,
)
#: View preset directions (unit-ish vectors from the scene centre to the eye).
_VIEW_DIRECTIONS = {
"iso": (1.0, 1.0, 0.8),
"xy": (0.0, 0.0, 1.0),
"xz": (0.0, -1.0, 0.0),
"yz": (1.0, 0.0, 0.0),
}
class _CameraShim:
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
def __init__(self, canvas: PlotlyCanvas) -> None:
self._canvas = canvas
@property
def parallel_projection(self) -> bool:
return self._canvas._parallel
@parallel_projection.setter
def parallel_projection(self, value: bool) -> None:
self._canvas.set_parallel_projection(bool(value))
class _PlotlyRendererFacade:
"""Stand-in for ``ModelRenderer`` covering the calls made on ``_renderer``.
``RenderControls`` and ``DockManager`` reach into ``canvas._renderer`` for
``render`` / ``set_mode`` / ``_mode``; this keeps those code paths
backend-agnostic.
"""
def __init__(self, canvas: PlotlyCanvas) -> None:
self._canvas = canvas
@property
def _mode(self) -> RendererMode:
return self._canvas._mode
@property
def _project(self) -> Any:
return self._canvas._project
@property
def _working_plane(self) -> tuple[str, float] | None:
return self._canvas._working_plane
def render(self, project: Any) -> None:
self._canvas.set_project(project)
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
self._canvas.set_mode(mode, deformation)
def set_working_plane(self, plane: tuple[str, float] | None) -> None:
if plane is None:
self._canvas.set_working_plane(None, 0.0)
else:
self._canvas.set_working_plane(plane[0], plane[1])
def set_show_section_extrusions(self, on: bool) -> None:
self._canvas.set_show_section_extrusions(on)
def set_show_local_axes(self, on: bool) -> None:
self._canvas.set_show_local_axes(on)
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._canvas.set_display_options(
show_node_labels=show_node_labels,
show_element_labels=show_element_labels,
)
class PlotlyCanvas(QWidget):
"""The central 3D viewport, rendered by plotly.js in a web view."""
nodePicked = Signal(int)
elementPicked = Signal(int)
emptyClicked = Signal(float, float, float)
#: Force diagrams and off-screen video capture are not implemented on
#: this backend yet (both are PyVista-specific today).
capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
def __init__(
self,
parent: QWidget | None = None,
style: RenderStyle | None = None,
selection: SelectionState | None = None,
) -> None:
super().__init__(parent)
self._style = style or RenderStyle()
self.selection = selection or SelectionState(self)
self._builder = PlotlyTraceBuilder(self._style)
self._project: Any = None
self._scene = Scene(data=[], layout={})
self._options = SceneOptions()
self._mode = RendererMode.MODEL
self._parallel = False
self._view_preset = "iso"
self._snap_enabled = False
self._default_selection_enabled = True
self._working_plane: tuple[str, float] | None = None
self._camera = _CameraShim(self)
self._camera_dirty = True
self._renderer = _PlotlyRendererFacade(self)
self._ready = False
self._build_ui()
self.selection.selectionChanged.connect(self._on_selection_changed)
# ── construction ─────────────────────────────────────────────────
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._web = QWebEngineView(self)
self._channel = QWebChannel(self._web.page())
self._bridge = _Bridge(self)
self._channel.registerObject("otkoBridge", self._bridge)
self._web.page().setWebChannel(self._channel)
self._web.loadFinished.connect(self._on_loaded)
self._bridge.picked.connect(self._on_picked)
self._bridge.snapClicked.connect(self._on_snap_clicked)
layout.addWidget(self._web)
self._web.load(QUrl.fromLocalFile(_html.runtime_url()))
def _on_loaded(self, ok: bool) -> None:
if not ok:
return
self._ready = True
self._push_scene()
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
# ── public API (mirrors ModelCanvas) ─────────────────────────────
def show_project(self, project: Any) -> None:
"""Render (or clear) a project; frame the camera when nodes exist."""
self.set_project(project)
if project is not None and project.nodes:
self._view_preset = "iso"
self._camera_dirty = True
self.render()
def clear_model(self) -> None:
"""Remove the model but keep the view/selection machinery."""
self.selection.clear()
self.set_project(None)
self.render()
def render(self, *args: Any, **kwargs: Any) -> None:
"""Rebuild the figure and hand it to plotly.js.
``QWidget.render`` is overloaded for painting into a target; those
calls are forwarded untouched so the widget stays well-behaved, and
only the no-argument canvas idiom (shared with ``ModelCanvas``)
triggers a figure push.
"""
if args or kwargs:
super().render(*args, **kwargs)
return
self._push_scene()
def set_project(self, project: Any) -> None:
"""Bind a project without rendering (used by the ``_renderer`` facade)."""
self._project = project
def set_mode(self, mode: RendererMode, deformation: Any = None) -> None:
"""Set MODEL / DEFORMED / MODAL plus the displacement source to apply."""
self._mode = mode
self._options = replace(self._options, deformation=deformation)
def set_parallel_projection(self, on: bool) -> None:
self._parallel = bool(on)
self._camera_dirty = True
def reset_camera(self) -> None:
self._view_preset = "iso"
self._camera_dirty = True
self.render()
def view_isometric(self) -> None:
self._view_preset = "iso"
self._camera_dirty = True
self.render()
def view_xy(self) -> None:
"""Top view: the eye sits on +Z looking down."""
self._view_preset = "xy"
self._camera_dirty = True
self.render()
def view_xz(self) -> None:
"""Front view: the eye sits on -Y."""
self._view_preset = "xz"
self._camera_dirty = True
self.render()
def view_yz(self) -> None:
"""Right view: the eye sits on +X."""
self._view_preset = "yz"
self._camera_dirty = True
self.render()
# ── working plane ────────────────────────────────────────────────
def set_working_plane(self, plane: str | None, offset: float) -> None:
"""Filter the grid overlay to the active plan / elevation level."""
if plane is None:
self._working_plane = None
else:
if plane not in ("XY", "XZ", "YZ"):
raise ValueError(f"Unsupported working plane: {plane!r}")
self._working_plane = (plane, float(offset))
self._options = replace(self._options, working_plane=self._working_plane)
self.render()
def clear_working_plane(self) -> None:
self.set_working_plane(None, 0.0)
def working_plane_type(self) -> str | None:
return self._working_plane[0] if self._working_plane is not None else None
def working_plane_offset(self) -> float | None:
return self._working_plane[1] if self._working_plane is not None else None
# ── display toggles ──────────────────────────────────────────────
def set_snap_preview_enabled(self, enabled: bool) -> None:
"""Toggle the hover snap-target preview (draw tools turn it on)."""
self._snap_enabled = bool(enabled)
self._eval(f"window.otkoSetSnapEnabled({_js_bool(self._snap_enabled)})")
def set_show_section_extrusions(self, enabled: bool) -> None:
self._options = replace(self._options, show_extrusions=bool(enabled))
self.render()
def set_show_local_axes(self, enabled: bool) -> None:
self._options = replace(self._options, show_local_axes=bool(enabled))
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
self._options = replace(
self._options,
show_node_labels=bool(show_node_labels),
show_element_labels=bool(show_element_labels),
)
self.render()
def set_default_selection_enabled(self, enabled: bool) -> None:
"""When False, picks fire signals but do not touch :attr:`selection`."""
self._default_selection_enabled = bool(enabled)
# ── internals ───────────────────────────────────────────────────
def _push_scene(self) -> None:
self._scene = self._builder.build(self._project, self._options)
layout = dict(self._scene.layout)
if self._camera_dirty:
layout["scene"] = {
**layout["scene"],
"camera": self._camera_dict(self._scene),
}
self._camera_dirty = False
if not self._ready:
return
payload = json.dumps({"data": self._scene.data, "layout": layout})
self._web.page().runJavaScript(f"window.otkoUpdate({json.dumps(payload)})")
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
cx, cy, cz = scene.center
distance = max(scene.diagonal, 1e-6) * 1.6
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
eye = (
cx + direction[0] / norm * distance,
cy + direction[1] / norm * distance,
cz + direction[2] / norm * distance,
)
# Looking straight down the Z axis needs a non-degenerate up vector.
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
return {
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
"center": {"x": cx, "y": cy, "z": cz},
"up": {"x": up[0], "y": up[1], "z": up[2]},
"projection": {"type": "orthographic" if self._parallel else "perspective"},
}
def _eval(self, js: str) -> None:
if self._ready:
self._web.page().runJavaScript(js)
def _on_selection_changed(self, nodes: frozenset[int], elements: frozenset[int]) -> None:
self._options = replace(
self._options,
selection_nodes=frozenset(nodes),
selection_elements=frozenset(elements),
)
self.render()
def _on_picked(self, kind: str, entity_id: int, additive: bool) -> None:
if kind == "node":
if self._default_selection_enabled:
if additive:
self.selection.toggle_node(entity_id)
else:
self.selection.select_node(entity_id)
self.nodePicked.emit(entity_id)
elif kind == "element":
if self._default_selection_enabled:
if additive:
self.selection.toggle_element(entity_id)
else:
self.selection.select_element(entity_id)
self.elementPicked.emit(entity_id)
def _on_snap_clicked(self, x: float, y: float, z: float) -> None:
self.emptyClicked.emit(float(x), float(y), float(z))
def _js_bool(value: bool) -> str:
return "true" if value else "false"