From f99b3a3efd07a80aecdb692a17fb1ef1e5453716 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 18:43:53 -0400 Subject: [PATCH 1/7] build: add plotly to the GUI extra Supplies the offline plotly.js bundle for the new canvas backend. The GUI CI job now installs the gui extra so the Qt/WebEngine tests run instead of being import-skipped. --- .github/workflows/ci.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0a7165..2c834e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 \ libxcb-cursor0 libdbus-1-3 libgl1 libxkbcommon0 xvfb - - run: pip install -e ".[dev]" + - run: pip install -e ".[gui,dev]" - run: xvfb-run -a pytest tests/gui -m "not slow" test-integration: diff --git a/pyproject.toml b/pyproject.toml index ffab17d..d67c45f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ gui = [ "vtk>=9.3", "pyqtgraph>=0.13", "imageio[ffmpeg]>=2.34", + "plotly>=5.18", ] dev = [ "pytest>=7.4", From fc5971ad3dd02d60149ea4f562d0a940dc1b7310 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 18:43:53 -0400 Subject: [PATCH 2/7] feat: pure Plotly trace builder for the 3D canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project → plotly.js figure dicts with no Qt/pyvista/plotly import, so it is unit-tested in the headless job. Mirrors the PyVista renderer's geometry (grid, nodes, frames, supports, loads, extrusions, local axes, labels, deformation) while respecting plotly.js's medium: pixel-sized markers, None-separated line segments, selection as a second trace (per-segment line colours are impossible), cone traces for arrows. Pickable traces carry meta.kind + customdata. --- src/otko/views/canvas_plotly/__init__.py | 26 + src/otko/views/canvas_plotly/trace_builder.py | 930 ++++++++++++++++++ tests/unit/test_plotly_trace_builder.py | 176 ++++ 3 files changed, 1132 insertions(+) create mode 100644 src/otko/views/canvas_plotly/__init__.py create mode 100644 src/otko/views/canvas_plotly/trace_builder.py create mode 100644 tests/unit/test_plotly_trace_builder.py diff --git a/src/otko/views/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py new file mode 100644 index 0000000..7fabb6d --- /dev/null +++ b/src/otko/views/canvas_plotly/__init__.py @@ -0,0 +1,26 @@ +"""Plotly-backed canvas package (optional GUI backend). + +``PlotlyCanvas`` is exported lazily (PEP 562): the pure +:mod:`~otko.views.canvas_plotly.trace_builder` must stay importable in the +headless test job, which has no Qt at all. +""" + +from __future__ import annotations + +from typing import Any + +from otko.views.canvas_plotly.trace_builder import ( + PlotlyTraceBuilder, + Scene, + SceneOptions, +) + +__all__ = ["PlotlyCanvas", "PlotlyTraceBuilder", "Scene", "SceneOptions"] + + +def __getattr__(name: str) -> Any: + if name == "PlotlyCanvas": + from otko.views.canvas_plotly.plotly_canvas import PlotlyCanvas + + return PlotlyCanvas + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py new file mode 100644 index 0000000..7c6d782 --- /dev/null +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -0,0 +1,930 @@ +"""Project → Plotly figure dictionaries. + +Pure data transformation: no Qt, no pyvista, no plotly import. The +:class:`PlotlyCanvas` serialises the returned :class:`Scene` to JSON and +hands it to plotly.js; the unit tests exercise it headless. + +Geometry conventions mirror :mod:`otko.views.canvas3d.model_renderer` so the +two backends draw the same model, but the output medium differs: + +- Nodes / supports / labels are ``scatter3d`` markers, whose size is in + *pixels* (plotly.js does not scale markers with world units). +- Frames are ``scatter3d`` line traces. Plotly cannot colour individual + segments of one line trace, so selection is expressed as two traces + (normal + selected) whose segment lists are regrouped on every update. +- Loads and local axes are ``cone`` traces (plotly.js has no 3D arrow glyph). +- Section extrusions are ``mesh3d`` box sweeps from + :func:`otko.services.section_bbox.bbox_for_section` — the same bbox + fallback the PyVista backend uses when a shape hint is not an exact fit. + +Picking rides on ``customdata`` + trace ``meta``: every pickable trace is +tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads +``point.data.meta.kind`` to decide what the click meant. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from otko.core import ( + BeamWithHingesElement, + CorotTrussElement, + DispBeamColumn, + ElasticBeamColumn, + ForceBeamColumn, + NodalLoad, + PlainLoadPattern, + Project, + QuadElement, + TrussElement, + UniformElementLoad, + ZeroLengthElement, + ZeroLengthSectionElement, +) +from otko.views.canvas3d.style import RenderStyle + +_FRAME_CLASSES = ( + ElasticBeamColumn, + DispBeamColumn, + ForceBeamColumn, + BeamWithHingesElement, + TrussElement, + CorotTrussElement, + ZeroLengthElement, +) + +#: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`. +_BOX_TRIS = ( + (0, 1, 2), + (0, 2, 3), + (4, 5, 6), + (4, 6, 7), + (0, 1, 5), + (0, 5, 4), + (3, 2, 6), + (3, 6, 7), + (0, 3, 7), + (0, 7, 4), + (1, 2, 6), + (1, 6, 5), +) + +#: Support kind → plotly 3D marker symbol. +_SUPPORT_SYMBOLS = { + "fix": "square", + "pin": "triangle-up", + "roller": "circle", + "custom": "diamond", +} + +_NODE_MARKER_SIZE = 7.0 +_SUPPORT_MARKER_SIZE = 11.0 +_SNAP_MARKER_SIZE = 8.0 +_LABEL_FONT_SIZE = 11 + + +@dataclass(frozen=True) +class SceneOptions: + """Everything the builder needs beyond the project itself.""" + + selection_nodes: frozenset[int] = frozenset() + selection_elements: frozenset[int] = frozenset() + #: Anything exposing ``shifted(points, node_ids)`` (e.g. ``DeformationSource``). + deformation: Any = None + working_plane: tuple[str, float] | None = None + show_node_labels: bool = False + show_element_labels: bool = False + show_extrusions: bool = False + show_local_axes: bool = False + + +@dataclass +class Scene: + """A transport-ready plotly figure plus framing metadata.""" + + data: list[dict[str, Any]] + layout: dict[str, Any] + center: tuple[float, float, float] = (0.0, 0.0, 0.0) + diagonal: float = 1.0 + #: Trace index (in ``data``) of the empty hover-snap marker, or -1. + hover_trace: int = -1 + + def to_payload(self) -> dict[str, Any]: + """Figure dict without the camera — camera is owned by the widget.""" + return {"data": self.data, "layout": self.layout} + + +@dataclass +class _Mesh: + """Accumulates a triangle soup for a single ``mesh3d`` trace.""" + + x: list[float] = field(default_factory=list) + y: list[float] = field(default_factory=list) + z: list[float] = field(default_factory=list) + i: list[int] = field(default_factory=list) + j: list[int] = field(default_factory=list) + k: list[int] = field(default_factory=list) + + def add_box(self, corners: np.ndarray) -> None: + base = len(self.x) + for cx, cy, cz in corners: + self.x.append(float(cx)) + self.y.append(float(cy)) + self.z.append(float(cz)) + for a, b, c in _BOX_TRIS: + self.i.append(base + a) + self.j.append(base + b) + self.k.append(base + c) + + @property + def is_empty(self) -> bool: + return not self.x + + def as_trace(self, *, color: str, opacity: float, name: str) -> dict[str, Any]: + return { + "type": "mesh3d", + "x": self.x, + "y": self.y, + "z": self.z, + "i": self.i, + "j": self.j, + "k": self.k, + "color": color, + "opacity": opacity, + "flatshading": True, + "hoverinfo": "skip", + "name": name, + "showscale": False, + } + + +# ── small geometry helpers ──────────────────────────────────────────────── +def _diag_of_points(pts: np.ndarray | None) -> float: + if pts is None or len(pts) == 0: + return 1.0 + mn, mx = pts.min(axis=0), pts.max(axis=0) + d = float(np.linalg.norm(mx - mn)) + return d if d > 0 else 1.0 + + +def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``.""" + x = x_local / float(np.linalg.norm(x_local)) + vecxz = getattr(el, "vecxz", None) + if vecxz is not None: + try: + v = np.asarray(vecxz, dtype=float) + z_local = v - float(np.dot(v, x)) * x + n = float(np.linalg.norm(z_local)) + if n > 1e-9: + z_local /= n + y_local = np.cross(z_local, x) + m = float(np.linalg.norm(y_local)) + if m > 1e-9: + return y_local / m, z_local + except (TypeError, ValueError): + pass + z_global = np.array([0.0, 0.0, 1.0]) + y_local = np.cross(z_global, x) + if float(np.linalg.norm(y_local)) < 1e-6: + y_local = np.cross(np.array([1.0, 0.0, 0.0]), x) + y_local /= float(np.linalg.norm(y_local)) + z_local = np.cross(x, y_local) + return y_local, z_local + + +def _dof_indices(ndf: int) -> tuple[int, ...]: + if ndf == 6: + return (0, 1, 2, 3, 4, 5) + if ndf == 3: + return (0, 1, 5) + if ndf == 2: + return (0, 1) + return tuple(range(ndf)) + + +def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> str: + flags = [restraint[i] for i in dof_idx] + if all(flags): + return "fix" + trans_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx < 3] + rot_flags = [flags[k] for k, idx in enumerate(dof_idx) if idx >= 3] + if trans_flags and all(trans_flags) and not any(rot_flags): + return "pin" + if sum(flags) == 1: + return "roller" + return "custom" + + +def _line_trace( + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + *, + color: str, + width: float, + name: str, + opacity: float = 1.0, + meta: dict[str, Any] | None = None, + customdata: list[Any] | None = None, +) -> dict[str, Any]: + """One ``scatter3d`` line trace from None-separated segment endpoints.""" + x: list[float | None] = [] + y: list[float | None] = [] + z: list[float | None] = [] + for a, b in segments: + x.extend([float(a[0]), float(b[0]), None]) + y.extend([float(a[1]), float(b[1]), None]) + z.extend([float(a[2]), float(b[2]), None]) + trace: dict[str, Any] = { + "type": "scatter3d", + "mode": "lines", + "x": x, + "y": y, + "z": z, + "line": {"color": color, "width": width}, + "opacity": opacity, + "hoverinfo": "skip", + "name": name, + "showlegend": False, + } + if meta is not None: + trace["meta"] = meta + if customdata is not None: + trace["customdata"] = customdata + return trace + + +class PlotlyTraceBuilder: + """Builds the full figure for a project.""" + + def __init__(self, style: RenderStyle | None = None) -> None: + self._style = style or RenderStyle() + + # ── public ─────────────────────────────────────────────────────── + def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: + opts = options or SceneOptions() + if project is None: + return Scene(data=[], layout=self._layout()) + + nodes = list(project.nodes) + node_ids = [n.id for n in nodes] + points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3)) + if opts.deformation is not None and len(points): + points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float) + node_row = {nid: i for i, nid in enumerate(node_ids)} + + data: list[dict[str, Any]] = [] + grid_pts = self._build_grid(project, data, opts) + self._build_extrusions(project, data, opts) + self._build_local_axes(project, data, opts) + self._build_loads(project, data, opts) + self._build_supports(project, data, opts) + self._build_frames(project, data, opts, points, node_row) + self._build_nodes(data, opts, points, node_ids) + self._build_labels(project, data, opts, points, node_row) + hover_trace = self._build_hover_marker(data) + + candidates = [points] if len(points) else [] + if grid_pts is not None: + candidates.append(grid_pts) + all_pts = np.vstack(candidates) if candidates else np.empty((0, 3)) + center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0) + return Scene( + data=data, + layout=self._layout(), + center=(float(center[0]), float(center[1]), float(center[2])), + diagonal=_diag_of_points(all_pts), + hover_trace=hover_trace, + ) + + # ── layout ─────────────────────────────────────────────────────── + def _layout(self) -> dict[str, Any]: + style = self._style + + def axis(color: str, title: str) -> dict[str, Any]: + return { + "title": {"text": title, "font": {"color": color, "size": 12}}, + "showgrid": False, + "showbackground": False, + "zeroline": False, + "showticklabels": False, + "showspikes": False, + "visible": True, + "linecolor": color, + "linewidth": 2, + } + + return { + "paper_bgcolor": style.background_bottom, + "plot_bgcolor": style.background_bottom, + "showlegend": False, + "margin": {"l": 0, "r": 0, "t": 0, "b": 0}, + "uirevision": "otko", + "scene": { + "bgcolor": style.background_bottom, + "aspectmode": "data", + "dragmode": "orbit", + "xaxis": axis(style.fix_color, "X"), + "yaxis": axis(style.load_color, "Y"), + "zaxis": axis(style.truss_color, "Z"), + }, + } + + # ── grid ───────────────────────────────────────────────────────── + def _build_grid( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> np.ndarray | None: + coord_systems = getattr(project, "coord_systems", None) or [] + palette = [ + ((0.08, 0.08, 0.08), (0.85, 0.55, 0.00)), + ((0.20, 0.35, 0.55), (0.85, 0.55, 0.00)), + ((0.20, 0.55, 0.30), (0.85, 0.55, 0.00)), + ((0.55, 0.20, 0.40), (0.85, 0.55, 0.00)), + ((0.35, 0.20, 0.55), (0.85, 0.55, 0.00)), + ] + all_dots: list[np.ndarray] = [] + + for idx, cs in enumerate(coord_systems): + grid = cs.grid + if not grid.visible or getattr(grid, "hide_all", False): + continue + xs = list(grid.x_lines) + ys = list(grid.y_lines) + zs = list(grid.z_lines) + if not (xs or ys or zs): + continue + palette_idx = 0 if cs.is_global() else (idx % (len(palette) - 1)) + 1 + grid_color, dot_color = palette[palette_idx] + grid_hex = _rgb_to_hex(grid_color) + dot_hex = _rgb_to_hex(dot_color) + + xmin, xmax = (min(xs), max(xs)) if xs else (-1.0, 1.0) + ymin, ymax = (min(ys), max(ys)) if ys else (-1.0, 1.0) + if xmin == xmax: + xmin, xmax = xmin - 1.0, xmax + 1.0 + if ymin == ymax: + ymin, ymax = ymin - 1.0, ymax + 1.0 + + plane_axis: int | None = None + plane_offset: float | None = None + if opts.working_plane is not None: + name, off = opts.working_plane + axis_idx = {"XY": 2, "XZ": 1, "YZ": 0}[name] + plane_axis = axis_idx + plane_offset = off - cs.coord.origin[axis_idx] + + def on_plane( + local: tuple[float, float, float], + _axis: int | None = plane_axis, + _offset: float | None = plane_offset, + ) -> bool: + if _axis is None or _offset is None: + return True + return abs(local[_axis] - _offset) < 1e-6 + + active: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + dim: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + + def add_seg( + p1: tuple[float, float, float], + p2: tuple[float, float, float], + _active: list = active, + _dim: list = dim, + _on_plane: Any = on_plane, + _cs: Any = cs, + ) -> None: + on = _on_plane(p1) and _on_plane(p2) + bucket = _active if on else _dim + bucket.append((_cs.coord.local_to_world(p1), _cs.coord.local_to_world(p2))) + + z_planes = zs if zs else [0.0] + for z in z_planes: + for x in xs: + add_seg((x, ymin, z), (x, ymax, z)) + for y in ys: + add_seg((xmin, y, z), (xmax, y, z)) + if opts.working_plane is None and zs and xs and ys: + for x in xs: + for y in ys: + add_seg((x, y, zs[0]), (x, y, zs[-1])) + + if dim: + data.append(_line_trace(dim, color=grid_hex, width=1, name="grid", opacity=0.18)) + if active: + data.append(_line_trace(active, color=grid_hex, width=2, name="grid-active")) + + active_dots: list[tuple[float, float, float]] = [] + dim_dots: list[tuple[float, float, float]] = [] + for z in z_planes: + for x in xs or [0.0]: + for y in ys or [0.0]: + pt = cs.coord.local_to_world((x, y, z)) + (active_dots if on_plane((x, y, z)) else dim_dots).append(pt) + if dim_dots: + data.append(self._dot_trace(dim_dots, color="#999999", opacity=0.35)) + if active_dots: + data.append( + self._dot_trace( + active_dots, + color=dot_hex, + opacity=1.0, + meta={"kind": "snap"}, + ) + ) + all_dots.extend(np.asarray(active_dots, dtype=float)) + + return np.vstack(all_dots) if all_dots else None + + @staticmethod + def _dot_trace( + points: list[tuple[float, float, float]], + *, + color: str, + opacity: float, + meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + customdata = ( + [[float(p[0]), float(p[1]), float(p[2])] for p in points] if meta is not None else None + ) + trace: dict[str, Any] = { + "type": "scatter3d", + "mode": "markers", + "x": [float(p[0]) for p in points], + "y": [float(p[1]) for p in points], + "z": [float(p[2]) for p in points], + "marker": {"color": color, "size": _SNAP_MARKER_SIZE, "line": {"width": 0}}, + "opacity": opacity, + "hoverinfo": "skip", + "name": "snap", + "showlegend": False, + } + if meta is not None: + trace["meta"] = meta + trace["customdata"] = customdata + return trace + + # ── model entities ─────────────────────────────────────────────── + def _build_nodes( + self, + data: list[dict[str, Any]], + opts: SceneOptions, + points: np.ndarray, + node_ids: list[int], + ) -> None: + if not len(points): + return + colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids] + data.append( + { + "type": "scatter3d", + "mode": "markers", + "x": [float(p[0]) for p in points], + "y": [float(p[1]) for p in points], + "z": [float(p[2]) for p in points], + "marker": { + "color": colors, + "size": _NODE_MARKER_SIZE, + "line": {"color": "#4d4d4d", "width": 1}, + }, + "customdata": list(node_ids), + "meta": {"kind": "node"}, + "hoverinfo": "skip", + "name": "nodes", + "showlegend": False, + } + ) + + def _build_frames( + self, + project: Project, + data: list[dict[str, Any]], + opts: SceneOptions, + points: np.ndarray, + node_row: dict[int, int], + ) -> None: + if not len(points): + return + normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + normal_ids: list[Any] = [] + selected_ids: list[Any] = [] + for el in project.elements: + if not isinstance(el, _FRAME_CLASSES): + continue + i = node_row.get(el.nodes[0]) + j = node_row.get(el.nodes[1]) + if i is None or j is None: + continue + seg = (tuple(points[i]), tuple(points[j])) + if el.id in opts.selection_elements: + selected.append(seg) + selected_ids.extend([el.id, el.id, None]) + else: + normal.append(seg) + normal_ids.extend([el.id, el.id, None]) + if not normal and not selected: + return + if normal: + data.append( + _line_trace( + normal, + color="#338cd9", + width=4, + name="elements", + meta={"kind": "element"}, + customdata=normal_ids, + ) + ) + if selected: + data.append( + _line_trace( + selected, + color="#00ffff", + width=6, + name="elements-selected", + meta={"kind": "element"}, + customdata=selected_ids, + ) + ) + + def _build_supports( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> None: + if not project.nodes: + return + dof_idx = _dof_indices(project.ndf) + groups: dict[str, list[Any]] = {} + for node in project.nodes: + if not any(node.restraint[i] for i in dof_idx): + continue + kind = _classify_support(node.restraint, dof_idx) + groups.setdefault(kind, []).append(node) + for kind, nodes in groups.items(): + data.append( + { + "type": "scatter3d", + "mode": "markers", + "x": [float(n.coords[0]) for n in nodes], + "y": [float(n.coords[1]) for n in nodes], + "z": [float(n.coords[2]) for n in nodes], + "marker": { + "color": "#ff8019", + "size": _SUPPORT_MARKER_SIZE, + "symbol": _SUPPORT_SYMBOLS[kind], + "line": {"color": "#7f3f00", "width": 1}, + }, + "hoverinfo": "skip", + "name": f"support-{kind}", + "showlegend": False, + } + ) + + # ── loads / axes / extrusions ──────────────────────────────────── + def _build_loads( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> None: + if not project.load_patterns or not project.nodes: + return + node_by_id = {n.id: n for n in project.nodes} + elem_by_id = {e.id: e for e in project.elements} + pts = np.array([n.coords for n in project.nodes], dtype=float) + scale = max(_diag_of_points(pts) * 0.05, 1e-6) + + nodal_x: list[float] = [] + nodal_y: list[float] = [] + nodal_z: list[float] = [] + nodal_u: list[float] = [] + nodal_v: list[float] = [] + nodal_w: list[float] = [] + dist_x: list[float] = [] + dist_y: list[float] = [] + dist_z: list[float] = [] + dist_u: list[float] = [] + dist_v: list[float] = [] + dist_w: list[float] = [] + + for pattern in project.load_patterns: + if not isinstance(pattern, PlainLoadPattern): + continue + for nload in pattern.nodal_loads: + if not isinstance(nload, NodalLoad): + continue + node = node_by_id.get(nload.node_id) + if node is None: + continue + f = np.asarray(nload.forces[:3], dtype=float) + mag = float(np.linalg.norm(f)) + if mag < 1e-12: + continue + direction = f / mag + tail = np.asarray(node.coords, dtype=float) - direction * scale + nodal_x.append(float(tail[0])) + nodal_y.append(float(tail[1])) + nodal_z.append(float(tail[2])) + nodal_u.append(float(direction[0])) + nodal_v.append(float(direction[1])) + nodal_w.append(float(direction[2])) + + for eload in pattern.element_loads: + if not isinstance(eload, UniformElementLoad): + continue + elem = elem_by_id.get(eload.element_id) + if elem is None: + continue + node_i = node_by_id.get(elem.nodes[0]) + node_j = node_by_id.get(elem.nodes[1]) + if node_i is None or node_j is None: + continue + pi = np.asarray(node_i.coords, dtype=float) + pj = np.asarray(node_j.coords, dtype=float) + axis = pj - pi + length = float(np.linalg.norm(axis)) + if length < 1e-9: + continue + x_local = axis / length + z_global = np.array([0.0, 0.0, 1.0]) + y_local = np.cross(z_global, x_local) + if float(np.linalg.norm(y_local)) < 1e-6: + y_local = np.cross(np.array([0.0, 1.0, 0.0]), x_local) + y_local /= float(np.linalg.norm(y_local)) + z_local = np.cross(x_local, y_local) + load_vec = eload.wx * x_local + eload.wy * y_local + eload.wz * z_local + mag = float(np.linalg.norm(load_vec)) + if mag < 1e-12: + continue + direction = load_vec / mag + n_arrows = 5 + for k in range(n_arrows): + t = (k + 0.5) / n_arrows + tail = pi + t * axis - direction * (0.4 * scale) + dist_x.append(float(tail[0])) + dist_y.append(float(tail[1])) + dist_z.append(float(tail[2])) + dist_u.append(float(direction[0])) + dist_v.append(float(direction[1])) + dist_w.append(float(direction[2])) + + if nodal_x: + data.append( + _cone_trace( + nodal_x, + nodal_y, + nodal_z, + nodal_u, + nodal_v, + nodal_w, + color="#33d933", + name="nodal-loads", + size=scale, + ) + ) + if dist_x: + data.append( + _cone_trace( + dist_x, + dist_y, + dist_z, + dist_u, + dist_v, + dist_w, + color="#ff8c33", + name="element-loads", + size=0.6 * scale, + ) + ) + + def _build_local_axes( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> None: + if not opts.show_local_axes or not project.nodes: + return + node_by_id = {n.id: n for n in project.nodes} + pts = np.array([n.coords for n in project.nodes], dtype=float) + cap = max(_diag_of_points(pts) * 0.08, 1e-6) + axes: dict[str, dict[str, list[float]]] = { + "x": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, + "y": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, + "z": {"x": [], "y": [], "z": [], "u": [], "v": [], "w": []}, + } + for el in project.elements: + if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement): + continue + if not isinstance( + el, + ElasticBeamColumn + | DispBeamColumn + | ForceBeamColumn + | BeamWithHingesElement + | TrussElement + | CorotTrussElement, + ): + continue + node_i = node_by_id.get(el.nodes[0]) + node_j = node_by_id.get(el.nodes[1]) + if node_i is None or node_j is None: + continue + pi = np.asarray(node_i.coords, dtype=float) + pj = np.asarray(node_j.coords, dtype=float) + axis = pj - pi + length = float(np.linalg.norm(axis)) + if length < 1e-9: + continue + x_local = axis / length + y_local, z_local = _frame_basis(el, x_local) + mid = (pi + pj) / 2.0 + for key, direction in (("x", x_local), ("y", y_local), ("z", z_local)): + bucket = axes[key] + bucket["x"].append(float(mid[0])) + bucket["y"].append(float(mid[1])) + bucket["z"].append(float(mid[2])) + bucket["u"].append(float(direction[0])) + bucket["v"].append(float(direction[1])) + bucket["w"].append(float(direction[2])) + for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")): + bucket = axes[key] + if bucket["x"]: + data.append( + _cone_trace( + bucket["x"], + bucket["y"], + bucket["z"], + bucket["u"], + bucket["v"], + bucket["w"], + color=color, + name=f"local-{key}", + size=cap, + ) + ) + + def _build_extrusions( + self, project: Project, data: list[dict[str, Any]], opts: SceneOptions + ) -> None: + if not opts.show_extrusions: + return + from otko.services.section_bbox import bbox_for_section + + node_by_id = {n.id: n for n in project.nodes} + section_by_id = {s.id: s for s in project.sections} + mesh = _Mesh() + for el in project.elements: + if not isinstance(el, _FRAME_CLASSES): + continue + if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement): + continue + section_id = getattr(el, "section_id", None) + if section_id is None: + continue + section = section_by_id.get(section_id) + if section is None: + continue + dims = bbox_for_section(section, project) + if dims is None: + continue + w_y, h_z = dims + if w_y <= 0 or h_z <= 0: + continue + node_i = node_by_id.get(el.nodes[0]) + node_j = node_by_id.get(el.nodes[1]) + if node_i is None or node_j is None: + continue + pi = np.asarray(node_i.coords, dtype=float) + pj = np.asarray(node_j.coords, dtype=float) + axis = pj - pi + length = float(np.linalg.norm(axis)) + if length < 1e-9: + continue + x_local = axis / length + y_local, z_local = _frame_basis(el, x_local) + mesh.add_box(self._box_corners(pi, x_local, y_local, z_local, length, w_y, h_z)) + if not mesh.is_empty: + data.append(mesh.as_trace(color="#598cff", opacity=0.22, name="extrusions")) + + @staticmethod + def _box_corners( + pi: np.ndarray, + x_local: np.ndarray, + y_local: np.ndarray, + z_local: np.ndarray, + length: float, + w_y: float, + h_z: float, + ) -> np.ndarray: + hy, hz = w_y / 2.0, h_z / 2.0 + offsets = np.array( + [ + [0.0, -hy, -hz], + [length, -hy, -hz], + [length, +hy, -hz], + [0.0, +hy, -hz], + [0.0, -hy, +hz], + [length, -hy, +hz], + [length, +hy, +hz], + [0.0, +hy, +hz], + ] + ) + basis = np.column_stack([x_local, y_local, z_local]) + return pi + offsets @ basis.T + + # ── labels / hover marker ──────────────────────────────────────── + def _build_labels( + self, + project: Project, + data: list[dict[str, Any]], + opts: SceneOptions, + points: np.ndarray, + node_row: dict[int, int], + ) -> None: + if opts.show_node_labels and len(points): + labels = [(n.name.strip() if n.name.strip() else f"N{n.id}") for n in project.nodes] + data.append(_text_trace(points, labels, name="node-labels")) + if opts.show_element_labels and len(points): + centers: list[np.ndarray] = [] + labels: list[str] = [] + for el in project.elements: + if len(el.nodes) != 2: + continue + i = node_row.get(el.nodes[0]) + j = node_row.get(el.nodes[1]) + if i is None or j is None: + continue + centers.append((points[i] + points[j]) / 2.0) + labels.append(el.name.strip() if el.name.strip() else f"E{el.id}") + if centers: + data.append(_text_trace(np.asarray(centers), labels, name="element-labels")) + + @staticmethod + def _build_hover_marker(data: list[dict[str, Any]]) -> int: + data.append( + { + "type": "scatter3d", + "mode": "markers", + "x": [], + "y": [], + "z": [], + "marker": { + "color": "#ffd900", + "size": 13, + "line": {"color": "#8a6d00", "width": 1}, + }, + "hoverinfo": "skip", + "name": "snap-hover", + "showlegend": False, + "meta": {"kind": "hover"}, + } + ) + return len(data) - 1 + + +def _cone_trace( + x: list[float], + y: list[float], + z: list[float], + u: list[float], + v: list[float], + w: list[float], + *, + color: str, + name: str, + size: float, +) -> dict[str, Any]: + return { + "type": "cone", + "x": x, + "y": y, + "z": z, + "u": u, + "v": v, + "w": w, + "anchor": "tail", + "sizemode": "absolute", + "sizeref": float(size), + "colorscale": [[0, color], [1, color]], + "showscale": False, + "hoverinfo": "skip", + "name": name, + "showlegend": False, + } + + +def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]: + return { + "type": "scatter3d", + "mode": "text", + "x": [float(p[0]) for p in points], + "y": [float(p[1]) for p in points], + "z": [float(p[2]) for p in points], + "text": labels, + "textposition": "top center", + "textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"}, + "hoverinfo": "skip", + "name": name, + "showlegend": False, + } + + +def _rgb_to_hex(rgb: tuple[float, float, float]) -> str: + r, g, b = (int(round(v * 255)) for v in rgb) + return f"#{r:02x}{g:02x}{b:02x}" diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py new file mode 100644 index 0000000..92104be --- /dev/null +++ b/tests/unit/test_plotly_trace_builder.py @@ -0,0 +1,176 @@ +"""Unit tests for the pure Plotly trace builder (no Qt, no plotly import).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +from otko.services import load_project +from otko.views.canvas_plotly.trace_builder import ( + PlotlyTraceBuilder, + SceneOptions, +) + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +def _load(name: str): # type: ignore[no-untyped-def] + return load_project(EXAMPLES / f"{name}.osmodel") + + +def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def] + return [trace for trace in scene.data if trace.get("name") == name] + + +def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def] + return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data] + + +def test_builds_grid_nodes_and_frames() -> None: + scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) + names = {trace.get("name") for trace in scene.data} + assert "grid-active" in names + assert "nodes" in names + assert "elements" in names + assert scene.diagonal > 0 + # The hover-snap marker is always present so JS can restyle it. + assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover" + + +def test_nodes_carry_ids_as_customdata() -> None: + project = _load("basic_truss") + scene = PlotlyTraceBuilder().build(project, SceneOptions()) + (nodes,) = _traces(scene, "nodes") + assert nodes["meta"]["kind"] == "node" + assert nodes["customdata"] == [node.id for node in project.nodes] + assert len(nodes["marker"]["color"]) == len(project.nodes) + + +def test_selected_element_gets_its_own_trace() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_elements=frozenset({2}))) + normal = _traces(scene, "elements") + selected = _traces(scene, "elements-selected") + assert len(normal) == 1 and len(selected) == 1 + # Plotly cannot colour one segment of a single line trace, so the + # selected element moves to the highlight trace. + assert 2 in selected[0]["customdata"] + assert 2 not in normal[0]["customdata"] + + +def test_selected_node_is_highlighted_by_colour() -> None: + project = _load("basic_truss") + target = project.nodes[1].id + scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_nodes=frozenset({target}))) + (nodes,) = _traces(scene, "nodes") + ids = nodes["customdata"] + assert nodes["marker"]["color"][ids.index(target)] != nodes["marker"]["color"][0] + + +def test_working_plane_filters_grid_segments() -> None: + project = _load("basic_truss") + builder = PlotlyTraceBuilder() + iso = builder.build(project, SceneOptions()) + plane = builder.build(project, SceneOptions(working_plane=("XY", 0.0))) + (iso_grid,) = _traces(iso, "grid-active") + (plane_grid,) = _traces(plane, "grid-active") + assert len(plane_grid["x"]) <= len(iso_grid["x"]) + + +def test_snap_targets_carry_world_coordinates() -> None: + scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) + (snap,) = _traces(scene, "snap") + assert snap["meta"]["kind"] == "snap" + assert snap["customdata"], "expected at least one snappable intersection" + assert all(len(point) == 3 for point in snap["customdata"]) + assert all(isinstance(coord, float) for point in snap["customdata"] for coord in point) + + +def test_extrusions_add_a_mesh3d_trace() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build(project, SceneOptions(show_extrusions=True)) + (mesh,) = _traces(scene, "extrusions") + assert mesh["type"] == "mesh3d" + assert mesh["i"] and mesh["j"] and mesh["k"] + assert max(mesh["i"]) < len(mesh["x"]) + + +def test_local_axes_add_coloured_cones() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build(project, SceneOptions(show_local_axes=True)) + names = {trace.get("name") for trace in scene.data} + assert {"local-x", "local-y", "local-z"} <= names + for trace in scene.data: + if trace.get("name", "").startswith("local-"): + assert trace["type"] == "cone" + assert len(trace["u"]) == len(trace["x"]) > 0 + + +def test_labels_traces_are_text_only() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build( + project, SceneOptions(show_node_labels=True, show_element_labels=True) + ) + (node_labels,) = _traces(scene, "node-labels") + (element_labels,) = _traces(scene, "element-labels") + assert node_labels["mode"] == "text" + assert len(node_labels["text"]) == len(project.nodes) + assert element_labels["mode"] == "text" + + +def test_deformation_shifts_node_coordinates() -> None: + project = _load("cantilever") + + class _Shift: + def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray: + out = points.copy() + out[:, 2] += 1.0 + return out + + base = PlotlyTraceBuilder().build(project, SceneOptions()) + moved = PlotlyTraceBuilder().build(project, SceneOptions(deformation=_Shift())) + (base_nodes,) = _traces(base, "nodes") + (moved_nodes,) = _traces(moved, "nodes") + assert moved_nodes["z"] == pytest.approx([z + 1.0 for z in base_nodes["z"]]) + + +def test_payload_is_json_serialisable() -> None: + scene = PlotlyTraceBuilder().build( + _load("cantilever"), + SceneOptions( + selection_nodes=frozenset({1}), + selection_elements=frozenset({1}), + working_plane=("XY", 0.0), + show_extrusions=True, + show_local_axes=True, + show_node_labels=True, + show_element_labels=True, + ), + ) + payload = json.dumps(scene.to_payload()) + assert '"data"' in payload and '"layout"' in payload + assert scene.layout["scene"]["aspectmode"] == "data" + + +def test_empty_project_yields_only_the_hover_marker() -> None: + scene = PlotlyTraceBuilder().build(None, SceneOptions()) + assert scene.data == [] + assert scene.hover_trace == -1 + + from otko.core import Project + + empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions()) + assert _kinds(empty) == ["hover"] + + +def test_frame_trace_meta_marks_elements_pickable() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build(project, SceneOptions()) + (frames,) = _traces(scene, "elements") + assert frames["meta"]["kind"] == "element" + # None separators break the line into per-element segments. + assert None in frames["x"] + assert len(frames["customdata"]) == len(frames["x"]) From 804bf22f61c8b98fc9a2797d7ace8cf1833d6b32 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 18:43:59 -0400 Subject: [PATCH 3/7] 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. --- src/otko/views/canvas_plotly/bridge.py | 28 ++ src/otko/views/canvas_plotly/html.py | 175 +++++++++ src/otko/views/canvas_plotly/plotly_canvas.py | 353 ++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 src/otko/views/canvas_plotly/bridge.py create mode 100644 src/otko/views/canvas_plotly/html.py create mode 100644 src/otko/views/canvas_plotly/plotly_canvas.py 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" From 9a1baa07c2b0f0a1570e4e6fcae672f4dc859668 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 18:43:59 -0400 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20live=20canvas=20backend=20switching?= =?UTF-8?q?=20(Options=20=E2=86=92=20Canvas=20Backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both backends share one SelectionState owned by MainWindow and live side by side in a QStackedWidget — switching is setCurrentWidget, so no widget is destroyed mid-session (tearing a VTK window down leaves dangling make-current callbacks). The choice persists in QSettings. CanvasCapabilities declares per-backend gaps (force diagrams and video export stay PyVista-only, both are documented and greyed out) and the UI gates on capabilities rather than the backend name. The architecture gate now allows the canvas_plotly package. --- AGENTS.md | 3 +- src/otko/views/canvas3d/model_canvas.py | 4 + src/otko/views/canvas_base.py | 92 +++++++++++++ src/otko/views/dock_manager.py | 24 ++++ src/otko/views/main_window.py | 163 ++++++++++++++++++++---- src/otko/views/menu_builder.py | 37 ++++++ src/otko/views/render_controls.py | 7 +- tests/gui/test_canvas_backend_switch.py | 122 ++++++++++++++++++ tests/unit/test_architecture.py | 28 ++-- 9 files changed, 442 insertions(+), 38 deletions(-) create mode 100644 src/otko/views/canvas_base.py create mode 100644 tests/gui/test_canvas_backend_switch.py diff --git a/AGENTS.md b/AGENTS.md index bdc5f2e..53e073c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; **Windows requ ## Install ```bash -pip install -e ".[gui,dev]" # desktop: Qt + PyVista + dev tools +pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends) python -m otko # launch GUI (src/otko/app.py:run) ``` @@ -19,6 +19,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`. - `core/` (entities: `project.py`, `geometry/`, `materials/`, `sections/`, `loads/`, `analysis/`, `catalog/`): stdlib + numpy + pydantic only. **No Qt, no openseespy. Period.** - `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.** - `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service. +- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless. - `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses. - Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5). diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index b1b3417..2b69352 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -24,6 +24,7 @@ from otko.core import Project from otko.views.canvas3d.model_renderer import ModelRenderer from otko.views.canvas3d.selection import SelectionState from otko.views.canvas3d.style import RenderStyle +from otko.views.canvas_base import CanvasCapabilities PICK_DEBUG = False @@ -31,6 +32,9 @@ PICK_DEBUG = False class ModelCanvas(QtInteractor): # type: ignore[misc] """The central 3D viewport widget.""" + #: VTK supports every optional overlay layer. + capabilities = CanvasCapabilities() + # Convenience signals re-emitted from SelectionState. nodePicked = Signal(int) elementPicked = Signal(int) diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py new file mode 100644 index 0000000..0352961 --- /dev/null +++ b/src/otko/views/canvas_base.py @@ -0,0 +1,92 @@ +"""Backend-agnostic contract for the central 3D canvas. + +Two backends implement it: + +- :class:`otko.views.canvas3d.ModelCanvas` — PyVista/VTK (native OpenGL, default) +- :class:`otko.views.canvas_plotly.PlotlyCanvas` — plotly.js in a QWebEngineView + +The ``MainWindow`` swaps between them at runtime, so both must expose the same +public surface (signals, selection, working plane, view presets, display +toggles) and declare what optional overlay layers they support via +:class:`CanvasCapabilities`. +""" + +from __future__ import annotations + +import importlib.util +from dataclasses import dataclass +from typing import Any, Protocol + + +def plotly_available() -> bool: + """True when the optional ``plotly`` package can be imported. + + The Plotly backend needs plotly.js (shipped inside the ``plotly`` wheel) + to render; without it the backend menu entry is disabled. + """ + return importlib.util.find_spec("plotly") is not None + + +@dataclass(frozen=True) +class CanvasCapabilities: + """Optional overlay layers a backend may or may not implement. + + Consumers query these (see ``RenderControls._refresh_action_enablement``) + instead of assuming a feature works on every backend. + """ + + #: Force-diagram ribbon overlay (N/V/M diagrams). + diagrams: bool = True + #: SAP2000-style section extrusion overlay. + extrusions: bool = True + #: Per-element local-axis triad overlay. + local_axes: bool = True + #: Node / element text labels. + labels: bool = True + #: Off-screen frame capture (mode-shape / time-history video export). + animation_export: bool = True + + +class CanvasBackend(Protocol): + """Structural contract implemented by every canvas backend. + + This mirrors :class:`ModelCanvas`; it exists so the swap path and the + consumers have one written-down interface to code against. + """ + + capabilities: CanvasCapabilities + selection: Any + + def show_project(self, project: Any) -> None: ... + + def clear_model(self) -> None: ... + + def render(self) -> None: ... + + def reset_camera(self) -> None: ... + + def view_isometric(self) -> None: ... + + def view_xy(self) -> None: ... + + def view_xz(self) -> None: ... + + def view_yz(self) -> None: ... + + def set_working_plane(self, plane: str, offset: float) -> None: ... + + def clear_working_plane(self) -> None: ... + + def working_plane_type(self) -> str | None: ... + + def working_plane_offset(self) -> float | None: ... + + def set_snap_preview_enabled(self, enabled: bool) -> None: ... + + def set_show_section_extrusions(self, enabled: bool) -> None: ... + + def set_show_local_axes(self, enabled: bool) -> None: ... + + def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: ... + + def set_default_selection_enabled(self, enabled: bool) -> None: ... diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index df4b70e..cd34449 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -210,6 +210,14 @@ class DockManager: because the off-screen renderer must be touched from the GUI thread (VTK's Qt-backed render window isn't thread-safe). """ + if not self._canvas.capabilities.animation_export: + QMessageBox.information( + self, + "Export Mode Shape Animation", + "Animation export needs the PyVista backend " + "(Options → Canvas Backend → PyVista (Native)).", + ) + return from PySide6.QtWidgets import QFileDialog path, _sel = QFileDialog.getSaveFileName( @@ -260,6 +268,14 @@ class DockManager: "Run a Static analysis first; force diagrams visualise its element-force output.", ) return + if not self._canvas.capabilities.diagrams: + QMessageBox.information( + self, + "Force Diagram", + "Force diagrams are not available on the Plotly backend yet.\n\n" + "Switch back via Options → Canvas Backend → PyVista (Native).", + ) + return self._tear_down_post_dock() # Pick the component with the largest abs_max as the initial choice @@ -364,6 +380,14 @@ class DockManager: "Run a Transient (time-history) analysis first.", ) return + if not self._canvas.capabilities.animation_export: + QMessageBox.information( + self, + "Export Time-History Animation", + "Animation export needs the PyVista backend " + "(Options → Canvas Backend → PyVista (Native)).", + ) + return from pathlib import Path from PySide6.QtWidgets import QFileDialog, QInputDialog diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index 958459e..cbd21e3 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -14,15 +14,18 @@ The ``projectChanged`` / ``modelMutated`` signals live on the from __future__ import annotations from pathlib import Path +from typing import Any from PySide6.QtCore import QSettings from PySide6.QtGui import QCloseEvent, QIcon -from PySide6.QtWidgets import QMainWindow, QMessageBox +from PySide6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget from otko.viewmodels import AnalysisRunner, ProjectViewModel from otko.views.action_handlers import ActionHandlers from otko.views.canvas3d import ModelCanvas from otko.views.canvas3d.diagram_renderer import DiagramRenderer +from otko.views.canvas3d.selection import SelectionState +from otko.views.canvas_base import plotly_available from otko.views.dock_manager import DockManager from otko.views.menu_builder import MenuBuilder from otko.views.render_controls import RenderControls @@ -34,6 +37,9 @@ from otko.views.tools import ( ToolController, ) +#: Canvas backends, in menu order. +CANVAS_BACKENDS = ("pyvista", "plotly") + class MainWindow( QMainWindow, @@ -44,7 +50,7 @@ class MainWindow( ): """Top-level application shell.""" - def __init__(self) -> None: + def __init__(self, settings: QSettings | None = None) -> None: super().__init__() self.setWindowTitle("OTKO") self.setWindowIcon( @@ -60,12 +66,20 @@ class MainWindow( self._show_node_labels = False self._show_element_labels = False - self._build_central_canvas() - self._tool_controller = ToolController(self._canvas, self._vm, self) - self._select_tool = SelectTool(self._canvas, self._vm, self) - self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation - self._draw_node_tool: DrawNodeTool | None = None - self._draw_truss_tool: DrawTrussTool | None = None + # One selection state shared by every canvas backend so a live swap + # keeps the current selection (canvas, tree, table, properties dock). + self._selection = SelectionState(self) + + self._settings = settings if settings is not None else QSettings("OTKO", "OTKO") + self._canvas_backend = self._stored_canvas_backend() + self._canvases: dict[str, Any] = {} + self._diagram_renderers: dict[str, DiagramRenderer] = {} + # A stack holds every backend's canvas: swapping is just + # setCurrentWidget, so no backend widget is ever destroyed mid-session. + self._canvas_stack = QStackedWidget(self) + self.setCentralWidget(self._canvas_stack) + self._activate_canvas(self._canvas_backend) + self._build_tooling() self._build_docks() self._build_actions() @@ -77,19 +91,120 @@ class MainWindow( self._refresh_action_enablement() self.restore_layout() - # ── construction ───────────────────────────────────────────────── - def _build_central_canvas(self) -> None: - self._canvas = ModelCanvas(self) - self.setCentralWidget(self._canvas) - # Diagram overlay paints onto the same plotter as the model. - self._diagram_renderer = DiagramRenderer(self._canvas) + # ── canvas backend ─────────────────────────────────────────────── + def _stored_canvas_backend(self) -> str: + """The backend chosen last session, falling back to PyVista.""" + stored = self._settings.value("canvas/backend", "pyvista") + name = str(stored) if stored is not None else "pyvista" + if name == "plotly" and plotly_available(): + return "plotly" + return "pyvista" + + def _ensure_canvas(self, backend: str) -> Any: + """Create a backend's canvas on first use and cache it. + + Both canvases are kept alive and swapped with ``setCentralWidget`` + rather than destroyed: tearing a VTK render window down mid-session + leaves dangling make-current callbacks, and the widgets are cheap to + retain once built. + """ + canvas = self._canvases.get(backend) + if canvas is not None: + return canvas + if backend == "plotly": + # Imported lazily so a plotly-less install still boots PyVista. + from otko.views.canvas_plotly import PlotlyCanvas + + canvas = PlotlyCanvas(self, selection=self._selection) + else: + canvas = ModelCanvas(self, selection=self._selection) + self._canvases[backend] = canvas + return canvas + + def _activate_canvas(self, backend: str) -> None: + """Make the backend's canvas the central widget and rewire overlays.""" + if backend not in CANVAS_BACKENDS: + backend = "pyvista" + canvas = self._ensure_canvas(backend) + if self._canvas_stack.indexOf(canvas) < 0: + self._canvas_stack.addWidget(canvas) + self._canvas_stack.setCurrentWidget(canvas) + self._canvas = canvas + self._canvas_backend = backend + if backend == "pyvista": + renderer = self._diagram_renderers.get("pyvista") + if renderer is None: + # Diagram overlay paints onto the same plotter as the model. + renderer = DiagramRenderer(canvas) + self._diagram_renderers["pyvista"] = renderer + self._diagram_renderer = renderer + else: + self._diagram_renderer = None + + def canvas_backend(self) -> str: + """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" + return self._canvas_backend + + def _build_tooling(self) -> None: + """(Re)build the tool controller and tools against the current canvas.""" + self._tool_controller = ToolController(self._canvas, self._vm, self) + self._select_tool = SelectTool(self._canvas, self._vm, self) + self._draw_frame_tool: DrawFrameTool | None = None # lazy-created on activation + self._draw_node_tool: DrawNodeTool | None = None + self._draw_truss_tool: DrawTrussTool | None = None + self._tool_controller.toolChanged.connect(self._on_tool_changed) + self._tool_controller.toolChanged.connect(self._sync_tool_actions) + + def swap_canvas_backend(self, backend: str) -> None: + """Switch the live canvas backend, preserving model/selection state. + + The camera pose is intentionally re-framed rather than transferred: + the two engines have different camera models, and a wrong pose is + worse than a clean isometric reset. + """ + if backend not in CANVAS_BACKENDS or backend == self._canvas_backend: + return + if backend == "plotly" and not plotly_available(): + return + + project = self._vm.project + plane = self._canvas.working_plane_type() + offset = self._canvas.working_plane_offset() + active_name = getattr(self._tool_controller.active, "name", None) + + self._activate_canvas(backend) + self._build_tooling() + + # Project → re-frames the camera; working plane rebuilds the grid. + self._canvas.show_project(project) + if plane is not None and offset is not None: + self._canvas.set_working_plane(plane, offset) + # Re-apply the overlays whose state lives on the toolbar actions. + self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked()) + self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked()) + self._canvas.set_display_options( + show_node_labels=self._show_node_labels, + show_element_labels=self._show_element_labels, + ) + # Keep the active tool (and its snap preview) alive across the swap. + # Re-entering through the action handlers recreates the tool lazily + # against the new canvas. + if active_name == "Draw Node": + self._on_draw_node_tool() + elif active_name == "Draw Frame": + self._on_draw_frame_tool() + elif active_name == "Draw Truss": + self._on_draw_truss_tool() + + self._settings.setValue("canvas/backend", self._canvas_backend) + self._sync_canvas_backend_actions() + self._refresh_action_enablement() # ── layout persistence ─────────────────────────────────────────── def save_layout(self) -> None: """Persist window geometry and dock/toolbar state for next launch.""" - settings = QSettings("OTKO", "OTKO") - settings.setValue("geometry", self.saveGeometry()) - settings.setValue("windowState", self.saveState()) + self._settings.setValue("geometry", self.saveGeometry()) + self._settings.setValue("windowState", self.saveState()) def restore_layout(self) -> None: """Restore a previously saved geometry/dock layout, if any. @@ -97,11 +212,10 @@ class MainWindow( On first run no values are stored, so the defaults from ``__init__`` (``resize`` + dock construction) stay in effect. """ - settings = QSettings("OTKO", "OTKO") - geometry = settings.value("geometry") + geometry = self._settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry) - state = settings.value("windowState") + state = self._settings.value("windowState") if state is not None: self.restoreState(state) @@ -161,9 +275,12 @@ class MainWindow( self._act_tool_draw_node.triggered.connect(self._on_draw_node_tool) self._act_tool_draw_frame.triggered.connect(self._on_draw_frame_tool) self._act_tool_draw_truss.triggered.connect(self._on_draw_truss_tool) - self._tool_controller.toolChanged.connect(self._on_tool_changed) - # Keep the Tools toolbar checked state in sync with the controller. - self._tool_controller.toolChanged.connect(self._sync_tool_actions) + # toolChanged → toolbar/menu sync is wired in _build_tooling(), which + # also runs on a canvas-backend swap (then a fresh controller exists). + + # Canvas backend + self._act_canvas_pyvista.triggered.connect(lambda: self.swap_canvas_backend("pyvista")) + self._act_canvas_plotly.triggered.connect(lambda: self.swap_canvas_backend("plotly")) # Define self._act_grid.triggered.connect(self._on_grid_system) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index bd8af6a..0aa0b2e 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -193,8 +193,41 @@ class MenuBuilder: self._act_quick_guide = QAction("&Quick Guide", self) self._act_set_units = QAction("Set Display &Units…", self) + # Canvas backend — exclusive radio pair live-switching the 3D view. + from otko.views.canvas_base import plotly_available + + self._canvas_backend_group = QActionGroup(self) + self._canvas_backend_group.setExclusive(True) + self._act_canvas_pyvista = QAction("&PyVista (Native)", self, checkable=True) + self._act_canvas_pyvista.setToolTip( + "Native VTK/OpenGL viewport — fastest for large models." + ) + self._act_canvas_plotly = QAction("&Plotly (WebGL)", self, checkable=True) + self._act_canvas_plotly.setToolTip( + "plotly.js rendered in a WebEngine view — richer graphics, higher overhead." + ) + self._canvas_backend_group.addAction(self._act_canvas_pyvista) + self._canvas_backend_group.addAction(self._act_canvas_plotly) + if not plotly_available(): + self._act_canvas_plotly.setEnabled(False) + self._act_canvas_plotly.setToolTip( + 'Install the optional GUI extra (pip install -e ".[gui]") to enable plotly.' + ) + self._apply_toolbar_icons() + def _sync_canvas_backend_actions(self) -> None: + """Mirror the active canvas backend onto the Options radio pair.""" + active = getattr( + self, + "_act_canvas_plotly" if self._canvas_backend == "plotly" else "_act_canvas_pyvista", + ) + self._canvas_backend_group.blockSignals(True) + try: + active.setChecked(True) + finally: + self._canvas_backend_group.blockSignals(False) + def _apply_toolbar_icons(self) -> None: """Assign icons + self-documenting tips to every toolbar action. @@ -479,6 +512,10 @@ class MenuBuilder: m_options = mb.addMenu("&Options") m_options.addAction(self._act_set_units) + m_backend = m_options.addMenu("Canvas &Backend") + m_backend.addAction(self._act_canvas_pyvista) + m_backend.addAction(self._act_canvas_plotly) + self._sync_canvas_backend_actions() m_help = mb.addMenu("&Help") m_help.addAction(self._act_quick_guide) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 1d574c1..63d1f0f 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -459,6 +459,7 @@ class RenderControls: has_pushover = isinstance(self._latest_results, PushoverResults) has_rs = isinstance(self._latest_results, ResponseSpectrumResults) n_sel = len(self._canvas.selection.nodes) + caps = self._canvas.capabilities self._act_save.setEnabled(has_project) self._act_save_as.setEnabled(has_project) self._act_export_opspy.setEnabled(has_project) @@ -482,9 +483,11 @@ class RenderControls: self._act_tool_draw_truss.setEnabled(has_project) self._act_show_deformed.setEnabled(has_static) self._act_show_mode_shape.setEnabled(has_modal) - self._act_show_force_diagram.setEnabled(has_static) + # Force diagrams + video export are backend capabilities, not just + # data availability (Plotly cannot do either yet). + self._act_show_force_diagram.setEnabled(has_static and caps.diagrams) self._act_show_time_history.setEnabled(has_transient) - self._act_export_th_animation.setEnabled(has_transient) + self._act_export_th_animation.setEnabled(has_transient and caps.animation_export) self._act_show_hysteresis.setEnabled(has_transient) self._act_show_pushover.setEnabled(has_pushover) self._act_show_response_spectrum.setEnabled(has_rs) diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py new file mode 100644 index 0000000..ed77be1 --- /dev/null +++ b/tests/gui/test_canvas_backend_switch.py @@ -0,0 +1,122 @@ +"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly). + +QSettings are injected per-test so a swap never leaks into the real user +configuration or the next test. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("pyvistaqt") +pytest.importorskip("plotly") + +from PySide6.QtCore import QSettings + + +def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def] + from otko.views.main_window import MainWindow + + settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) + window = MainWindow(settings=settings) + qtbot.addWidget(window) + return window + + +@pytest.mark.gui +def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + from otko.views.canvas3d import ModelCanvas + + window = _make_window(tmp_path, qtbot) + assert window.canvas_backend() == "pyvista" + assert isinstance(window._canvas, ModelCanvas) + assert window._diagram_renderer is not None + + +@pytest.mark.gui +def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + from otko.views.canvas_plotly import PlotlyCanvas + + window = _make_window(tmp_path, qtbot) + window._vm.new_project() + window._canvas.selection.select_node(2) + + window.swap_canvas_backend("plotly") + + assert window.canvas_backend() == "plotly" + assert isinstance(window._canvas, PlotlyCanvas) + # One shared SelectionState: the tree/table/properties stay bound. + assert window._canvas.selection is window._selection + assert window._canvas.selection.nodes == frozenset({2}) + # Both canvases live in the stack; the PyVista one was not destroyed. + assert window._canvas_stack.count() == 2 + + +@pytest.mark.gui +def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _make_window(tmp_path, qtbot) + assert window._canvas.capabilities.diagrams is True + + window.swap_canvas_backend("plotly") + caps = window._canvas.capabilities + assert caps.diagrams is False + assert caps.animation_export is False + assert caps.extrusions is True and caps.labels is True + + window._refresh_action_enablement() + assert window._act_show_force_diagram.isEnabled() is False + assert window._act_export_th_animation.isEnabled() is False + + +@pytest.mark.gui +def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _make_window(tmp_path, qtbot) + pyvista_canvas = window._canvas + + window.swap_canvas_backend("plotly") + plotly_canvas = window._canvas + window.swap_canvas_backend("pyvista") + + assert window._canvas is pyvista_canvas + assert window._diagram_renderer is not None + # No duplicate widgets accumulate across repeated swaps. + window.swap_canvas_backend("plotly") + assert window._canvas is plotly_canvas + assert window._diagram_renderer is None # diagrams are PyVista-only + assert window._canvas_stack.count() == 2 + + +@pytest.mark.gui +def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _make_window(tmp_path, qtbot) + window.swap_canvas_backend("plotly") + assert window._settings.value("canvas/backend") == "plotly" + + # A fresh window with the same settings boots straight into Plotly. + from otko.views.canvas_plotly import PlotlyCanvas + from otko.views.main_window import MainWindow + + settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) + second = MainWindow(settings=settings) + qtbot.addWidget(second) + assert second.canvas_backend() == "plotly" + assert isinstance(second._canvas, PlotlyCanvas) + + +@pytest.mark.gui +def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _make_window(tmp_path, qtbot) + window._vm.open(_example("basic_truss")) + window._canvas.set_working_plane("XY", 0.0) + + window.swap_canvas_backend("plotly") + + assert window._canvas.working_plane_type() == "XY" + assert window._canvas.working_plane_offset() == 0.0 + + +def _example(name: str) -> str: + from pathlib import Path + + return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel") diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py index 9281e67..7b3f95c 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -2,11 +2,12 @@ The documented dependency stack is ``views -> viewmodels -> services -> core``; each layer may only import from itself or a lower layer, ``core`` stays free of -Qt/solver/plotting libraries, and the rendering backend is confined to -``views/canvas3d``. These tests never import the application modules; they read -the source tree with ``ast`` so the import discipline can be checked without -executing Qt or the solver. Directories that do not exist yet simply have -nothing to walk, which trivially satisfies a "forbidden import" rule. +Qt/solver/plotting libraries, and the rendering backends are confined to the +canvas packages (``views/canvas3d``, ``views/canvas_plotly``). These tests never +import the application modules; they read the source tree with ``ast`` so the +import discipline can be checked without executing Qt or the solver. +Directories that do not exist yet simply have nothing to walk, which trivially +satisfies a "forbidden import" rule. """ from __future__ import annotations @@ -16,7 +17,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SRC = ROOT / "src" / "otko" -CANVAS = SRC / "views" / "canvas3d" +CANVAS_DIRS = ( + SRC / "views" / "canvas3d", + SRC / "views" / "canvas_plotly", +) # Qt binding roots, in every spelling the project might use. QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"} @@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | { "matplotlib", } -# Only views/canvas3d/ may touch a 3D or plotting backend. +# Only the canvas packages may touch a 3D or plotting backend. VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"} @@ -75,14 +79,14 @@ def _violations( forbidden: set[str], *, skip: tuple[Path, ...] = (), - exclude_dir: Path | None = None, + exclude_dirs: tuple[Path, ...] = (), ) -> list[str]: """Return ``": "`` entries for every forbidden import found.""" found: list[str] = [] for path in _iter_python_files(root): if path in skip: continue - if exclude_dir is not None and _is_within(path, exclude_dir): + if any(_is_within(path, directory) for directory in exclude_dirs): continue for module in sorted(_root_imports(path) & forbidden): found.append(f"{path.relative_to(ROOT)}: {module}") @@ -112,9 +116,9 @@ def test_architecture_views_no_solver() -> None: def test_no_vtk_import_outside_canvas() -> None: - """3D/plot backends stay under views/canvas3d/.""" - violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS) - assert violations == [], f"backend imports outside views/canvas3d/: {violations}" + """3D/plot backends stay under the canvas packages.""" + violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS) + assert violations == [], f"backend imports outside the canvas packages: {violations}" # The documented dependency stack; ``commands`` sits beside viewmodels (a VM From d09e72e95dddfc0b189adb6c62f8072471485074 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:15:48 -0400 Subject: [PATCH 5/7] chore: clone opstool for visualization reference opstool v1.0.26 (GPL-3.0) is cloned read-only under .slim/clonedeps/repos/ to inspect its PyVista/Plotly visualization settings. GPLv3 section 13 permits combining it with this AGPL-3.0 project. The clone itself is git-ignored; the manifest and the AGENTS.md pointer are committed. --- .gitignore | 4 ++++ .ignore | 9 +++++++++ .slim/clonedeps.json | 15 +++++++++++++++ AGENTS.md | 14 ++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 .ignore create mode 100644 .slim/clonedeps.json diff --git a/.gitignore b/.gitignore index c82640e..eac3bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ examples/ozan*.osmodel examples/ozan_results/ /Ex*.csv /Ex*.png + +# BEGIN oh-my-opencode-slim clonedeps +.slim/clonedeps/repos/ +# END oh-my-opencode-slim clonedeps diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..32dbf4f --- /dev/null +++ b/.ignore @@ -0,0 +1,9 @@ +# BEGIN oh-my-opencode-slim clonedeps +!.slim/ +!.slim/clonedeps.json +!.slim/clonedeps/ +!.slim/clonedeps/repos/ +!.slim/clonedeps/repos/** +.slim/clonedeps/repos/**/.git/ +.slim/clonedeps/repos/**/.git/** +# END oh-my-opencode-slim clonedeps diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json new file mode 100644 index 0000000..ddddcab --- /dev/null +++ b/.slim/clonedeps.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "updatedAt": "2026-09-16T17:30:00.000Z", + "dependencies": [ + { + "name": "opstool", + "resolvedVersion": "1.0.26", + "repoUrl": "https://github.com/yexiang92/opstool.git", + "ref": "v1.0.26", + "path": ".slim/clonedeps/repos/yexiang92__opstool", + "packagePath": "opstool/vis", + "reason": "Read-only source for evaluating whether to port opstool's PyVista/Plotly visualization settings into otko's canvas backends, or to depend on opstool.vis directly (GPL-3.0, compatible with otko's AGPL-3.0 via GPLv3 section 13)." + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 53e073c..6b63da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,3 +57,17 @@ git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git - `examples/*.py` are source of truth; `examples/*.osmodel` are generated artifacts (checked in). Never hand-edit `.osmodel` — change the script and regen: `python examples/cantilever.py` (each script saves, reloads, asserts clean round-trip). - Projects persist as single Pydantic-validated JSON `.osmodel` (diffable); analysis output goes to `*.osresults.h5` (HDF5, one group per case). - Quick smoke: open `examples/cantilever.osmodel` → run `Tip-Load` → M3 peaks 50 kN·m at fixed end. + +## Cloned Dependency Source + +Read-only dependency source repositories are available under +`.slim/clonedeps/repos/` for inspection. Do not edit these clones. The +structured manifest is `.slim/clonedeps.json`. + +- `.slim/clonedeps/repos/yexiang92__opstool/` — `yexiang92/opstool` at `v1.0.26`; the OpenSeesPy + pre/post-processor whose PyVista and Plotly visualization settings + (`opstool/vis/{pyvista,plotly}/plot_utils.py`, `plot_resp_base.py`, `vis_model.py`) are the + reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining + it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice + and record that it was modified (GPLv3 §5a/b) — add that entry to `NOTICE` when the port lands. + From 40a673c5624d139502a8756ba662e8c2a2699573 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:15:48 -0400 Subject: [PATCH 6/7] test: isolate QSettings so user preferences cannot fail GUI tests MainWindow persists preferences (window layout, canvas backend) through QSettings("OTKO", "OTKO"), so tests constructing it were reading the developer's real settings: after switching to the Plotly backend the PyVista-specific viewport-axis tests failed with "PlotlyCanvas has no attribute renderer". A session fixture now redirects QSettings into a temp dir and is skipped when Qt is not installed, keeping the headless job Qt-free. --- tests/conftest.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 82dd86a..c91c064 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ domain after every test. The import is inside the teardown so that from __future__ import annotations +import importlib.util import os from collections.abc import Iterator @@ -19,6 +20,27 @@ import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +@pytest.fixture(autouse=True, scope="session") +def _isolate_qsettings(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: + """Redirect ``QSettings`` to a temp dir so tests never touch user state. + + ``MainWindow`` persists preferences (window layout, canvas backend) under + ``QSettings("OTKO", "OTKO")``. Without redirection a developer who, say, + switched to the Plotly backend would see PyVista-specific GUI tests fail + against their real settings. Skipped entirely when Qt is not installed, so + the headless job stays free of Qt. + """ + if importlib.util.find_spec("PySide6") is None: + yield + return + from PySide6.QtCore import QSettings + + settings_dir = tmp_path_factory.mktemp("qsettings") + for fmt in (QSettings.Format.NativeFormat, QSettings.Format.IniFormat): + QSettings.setPath(fmt, QSettings.Scope.UserScope, str(settings_dir)) + yield + + @pytest.fixture(autouse=True) def _wipe_opensees_domain() -> Iterator[None]: """Wipe the OpenseesPy domain after a test if the solver was imported.""" From 17d2ed21d3b2f43277ce2f2ffb7481ea66307119 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:15:55 -0400 Subject: [PATCH 7/7] feat: opstool-derived element palette and single-trace frame colours Ports opstool's per-family element colours and its diverging response scale; RenderStyle.response_scale_colors now also drives the PyVista force-diagram colouring instead of a hard-coded "coolwarm". The frame renderer drops the two-trace normal/selected workaround: my earlier assumption that plotly cannot colour segments individually was wrong. Scatter3d.line.color accepts an array mapped through a colorscale, so one trace now carries per-element colours (family + selection) and is ready to be coloured by response value later. Attribution recorded in NOTICE per GPLv3 section 5(a)/(b). --- NOTICE | 15 +++ src/otko/views/canvas3d/diagram_renderer.py | 8 +- src/otko/views/canvas3d/style.py | 35 ++++++ src/otko/views/canvas_plotly/trace_builder.py | 101 ++++++++++++------ tests/unit/test_plotly_trace_builder.py | 50 +++++++-- 5 files changed, 164 insertions(+), 45 deletions(-) diff --git a/NOTICE b/NOTICE index 10a928c..24b2dd1 100644 --- a/NOTICE +++ b/NOTICE @@ -45,6 +45,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Ported / adapted code: `opstool` + +The canvas element colour palette in `src/otko/views/canvas3d/style.py` +(per-family element colours and the diverging response colour scale) and the +resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were +adapted from the `opstool` project, which is distributed under the **GNU +General Public License v3.0**. opstool is Copyright © Yexiang Yan and +contributors. + +In accordance with GPLv3 §5(a)/(b) this notice records that the material was +modified and adapted for OTKO. Combining the GPLv3-covered material with +OTKO's AGPL-3.0 code is permitted by GPLv3 §13; the combined work is +conveyed under AGPL-3.0, and the GPLv3 terms continue to apply to the +opstool-derived portions. + ## Runtime dependencies OTKO depends on third-party software that is not covered by OTKO's diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 05c5f2c..57c0a70 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -31,6 +31,7 @@ import pyvista as pv from otko.core import Project from otko.services.element_forces import DiagramData, ForceComponent +from otko.views.canvas3d.style import RenderStyle _LOG = logging.getLogger("otko.diagram") @@ -62,8 +63,9 @@ class DiagramRenderer: - ``clear()`` removes the overlay. """ - def __init__(self, plotter: Any) -> None: + def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: self._plotter = plotter + self._style = style or RenderStyle() self._actor: Any = None self._label_actor: Any = None @@ -158,7 +160,9 @@ class DiagramRenderer: self._actor = self._plotter.add_mesh( mesh, scalars="value", - cmap="coolwarm", + # Shared response palette (opstool's default diverging scale), so + # the PyVista and Plotly backends stay visually consistent. + cmap=list(self._style.response_scale_colors), clim=(-vmax, vmax), show_scalar_bar=True, scalar_bar_args={"title": comp_label, "n_labels": 5}, diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index f25d674..3e1de73 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -27,6 +27,31 @@ class RenderStyle: zerolength_color: str = "#a020f0" selected_color: str = "#00d4ff" + # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ─────── + # opstool colours wireframe elements by *family* rather than painting + # every frame the same colour, which makes mixed beam/truss/link models + # readable at a glance. Values come from ``PLOT_ARGS_DEFAULT`` in + # ``opstool/vis/{pyvista,plotly}/plot_utils.py``; see ``NOTICE``. + element_beam_color: str = "#0652ff" # beam-column family + element_truss_color: str = "#FF8C00" # truss family + element_link_color: str = "#39FF14" # zero-length / link family + + #: Diverging scale for scalar response overlays (force diagrams today, + #: nodal / element response plots later): blue → red, evenly spaced. + #: This is opstool's ``default_cmap`` (RdYlBu reversed). + response_scale_colors: tuple[str, ...] = ( + "#313695", + "#4575b4", + "#74add1", + "#abd9e9", + "#e0f3f8", + "#fee090", + "#fdae61", + "#f46d43", + "#d73027", + "#a50026", + ) + fix_color: str = "#c0392b" # firebrick pin_color: str = "#c0392b" roller_color: str = "#e67e22" @@ -49,3 +74,13 @@ class RenderStyle: load_min_length: float = 0.5 selection_thickness_factor: float = 1.6 # multiplier for selected actors + + # ── helpers ────────────────────────────────────────────────────── + def response_colorscale(self) -> list[tuple[float, str]]: + """Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" + count = len(self.response_scale_colors) + if count < 2: + return [(0.0, self.response_scale_colors[0])] + return [ + (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) + ] diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 7c6d782..8c1538b 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -56,6 +56,19 @@ _FRAME_CLASSES = ( ZeroLengthElement, ) +#: Element families that get their own palette entry (see ``RenderStyle``). +_TRUSS_FAMILY = (TrussElement, CorotTrussElement) + + +def _family_index(el: Any) -> int: + """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length.""" + if isinstance(el, _TRUSS_FAMILY): + return 1 + if isinstance(el, ZeroLengthElement): + return 2 + return 0 + + #: Triangle indices for the 8-corner box sweep built by :meth:`_SceneBuilder._box_corners`. _BOX_TRIS = ( (0, 1, 2), @@ -504,12 +517,30 @@ class PlotlyTraceBuilder: points: np.ndarray, node_row: dict[int, int], ) -> None: + """One line trace whose per-point ``line.color`` carries the palette. + + plotly accepts an *array* for ``Scatter3d.line.color`` mapped through + a ``colorscale``, so a single trace can colour every element + individually — by family and by selection — instead of one trace per + colour. That also leaves room to colour by response value later. + Each element's two endpoints (and its ``None`` separator) share one + colour index, so each drawn segment is a solid colour. + """ if not len(points): return - normal: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - normal_ids: list[Any] = [] - selected_ids: list[Any] = [] + scale_colors = [ + self._style.element_beam_color, + self._style.element_truss_color, + self._style.element_link_color, + self._style.selected_color, + ] + selected_index = len(scale_colors) - 1 + + x: list[float | None] = [] + y: list[float | None] = [] + z: list[float | None] = [] + color_index: list[float] = [] + customdata: list[Any] = [] for el in project.elements: if not isinstance(el, _FRAME_CLASSES): continue @@ -517,37 +548,39 @@ class PlotlyTraceBuilder: j = node_row.get(el.nodes[1]) if i is None or j is None: continue - seg = (tuple(points[i]), tuple(points[j])) - if el.id in opts.selection_elements: - selected.append(seg) - selected_ids.extend([el.id, el.id, None]) - else: - normal.append(seg) - normal_ids.extend([el.id, el.id, None]) - if not normal and not selected: + index = float(selected_index if el.id in opts.selection_elements else _family_index(el)) + x.extend([float(points[i][0]), float(points[j][0]), None]) + y.extend([float(points[i][1]), float(points[j][1]), None]) + z.extend([float(points[i][2]), float(points[j][2]), None]) + color_index.extend([index, index, index]) + customdata.extend([el.id, el.id, None]) + if not x: return - if normal: - data.append( - _line_trace( - normal, - color="#338cd9", - width=4, - name="elements", - meta={"kind": "element"}, - customdata=normal_ids, - ) - ) - if selected: - data.append( - _line_trace( - selected, - color="#00ffff", - width=6, - name="elements-selected", - meta={"kind": "element"}, - customdata=selected_ids, - ) - ) + + count = len(scale_colors) + data.append( + { + "type": "scatter3d", + "mode": "lines", + "x": x, + "y": y, + "z": z, + "line": { + "color": color_index, + "colorscale": [ + (index / (count - 1), color) for index, color in enumerate(scale_colors) + ], + "cmin": 0, + "cmax": count - 1, + "width": 4, + }, + "customdata": customdata, + "meta": {"kind": "element"}, + "hoverinfo": "skip", + "name": "elements", + "showlegend": False, + } + ) def _build_supports( self, project: Project, data: list[dict[str, Any]], opts: SceneOptions diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py index 92104be..ae960a9 100644 --- a/tests/unit/test_plotly_trace_builder.py +++ b/tests/unit/test_plotly_trace_builder.py @@ -9,6 +9,7 @@ import numpy as np import pytest from otko.services import load_project +from otko.views.canvas3d.style import RenderStyle from otko.views.canvas_plotly.trace_builder import ( PlotlyTraceBuilder, SceneOptions, @@ -49,16 +50,47 @@ def test_nodes_carry_ids_as_customdata() -> None: assert len(nodes["marker"]["color"]) == len(project.nodes) -def test_selected_element_gets_its_own_trace() -> None: +def _color_for_index(trace: dict, index: float) -> str: + """Map a ``line.color`` scalar index to its colour via the colorscale.""" + scale = sorted(trace["line"]["colorscale"], key=lambda item: item[0]) + return scale[int(index)][1] + + +def test_frames_are_one_trace_with_per_element_colours() -> None: + """A single line trace carries the palette through its ``line.color`` array.""" project = _load("cantilever") - scene = PlotlyTraceBuilder().build(project, SceneOptions(selection_elements=frozenset({2}))) - normal = _traces(scene, "elements") - selected = _traces(scene, "elements-selected") - assert len(normal) == 1 and len(selected) == 1 - # Plotly cannot colour one segment of a single line trace, so the - # selected element moves to the highlight trace. - assert 2 in selected[0]["customdata"] - assert 2 not in normal[0]["customdata"] + style = RenderStyle() + scene = PlotlyTraceBuilder(style).build( + project, SceneOptions(selection_elements=frozenset({2})) + ) + frames = _traces(scene, "elements") + assert len(frames) == 1, "selection must not split the frame trace" + line = frames[0]["line"] + assert isinstance(line["color"], list) + assert len(line["color"]) == len(frames[0]["x"]) + assert line["colorscale"] + + colors = line["color"] + customdata = frames[0]["customdata"] + + def index_of(element_id: int) -> set[float]: + return {colors[k] for k, value in enumerate(customdata) if value == element_id} + + selected = index_of(2) + unselected = index_of(1) + assert len(selected) == 1 and len(unselected) == 1 + assert selected != unselected + assert _color_for_index(frames[0], next(iter(selected))) == style.selected_color + assert _color_for_index(frames[0], next(iter(unselected))) == style.element_beam_color + + +def test_element_palette_distinguishes_beam_from_truss() -> None: + style = RenderStyle() + builder = PlotlyTraceBuilder(style) + beam = _traces(builder.build(_load("cantilever"), SceneOptions()), "elements")[0] + truss = _traces(builder.build(_load("basic_truss"), SceneOptions()), "elements")[0] + assert _color_for_index(beam, beam["line"]["color"][0]) == style.element_beam_color + assert _color_for_index(truss, truss["line"]["color"][0]) == style.element_truss_color def test_selected_node_is_highlighted_by_colour() -> None: