diff --git a/src/otko/views/canvas_plotly/bridge.py b/src/otko/views/canvas_plotly/bridge.py new file mode 100644 index 0000000..9dafbce --- /dev/null +++ b/src/otko/views/canvas_plotly/bridge.py @@ -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)) diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py new file mode 100644 index 0000000..278abba --- /dev/null +++ b/src/otko/views/canvas_plotly/html.py @@ -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 = """ + +
+ + + + + + + + + + +""" + + +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() diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py new file mode 100644 index 0000000..e5af025 --- /dev/null +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -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"