feat/plotly-canvas #5
3 changed files with 556 additions and 0 deletions
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.
commit
804bf22f61
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