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.
This commit is contained in:
parent
fc5971ad3d
commit
804bf22f61
3 changed files with 556 additions and 0 deletions
28
src/otko/views/canvas_plotly/bridge.py
Normal file
28
src/otko/views/canvas_plotly/bridge.py
Normal 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))
|
||||||
175
src/otko/views/canvas_plotly/html.py
Normal file
175
src/otko/views/canvas_plotly/html.py
Normal 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()
|
||||||
353
src/otko/views/canvas_plotly/plotly_canvas.py
Normal file
353
src/otko/views/canvas_plotly/plotly_canvas.py
Normal 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"
|
||||||
Loading…
Reference in a new issue