From f99b3a3efd07a80aecdb692a17fb1ef1e5453716 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 18:43:53 -0400 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] =?UTF-8?q?feat:=20live=20canvas=20backend=20switchi?= =?UTF-8?q?ng=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 05/15] 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 06/15] 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 07/15] 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: From 56f1f6c08b28abc52a055d1e7518fae71c68c8bd Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:31:30 -0400 Subject: [PATCH 08/15] feat: route both canvas backends through RenderStyle Colours that were hard-coded in the renderers now come from the shared style: nodes, supports, nodal/element loads, section extrusions (+ opacity) and label font size. The PyVista frame LUT becomes a four-slot palette [beam, truss, link, selected] with the cell scalar carrying the family slot, so VTK matches the Plotly backend's per-family colouring that landed earlier. Adds style helpers shared by both backends (element_family_index, family_palette) and an immutable with_updates(). The style also gains the editable field table (STYLE_FIELDS) the Plot Properties dialog is built from. --- src/otko/views/canvas3d/model_renderer.py | 56 ++++++--- src/otko/views/canvas3d/style.py | 83 ++++++++++++- src/otko/views/canvas_plotly/trace_builder.py | 77 +++++++----- tests/unit/test_render_style.py | 117 ++++++++++++++++++ tests/unit/test_renderer.py | 9 +- 5 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 tests/unit/test_render_style.py diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 9eb9274..12c88cb 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -13,7 +13,7 @@ from __future__ import annotations import contextlib import enum from dataclasses import dataclass -from typing import Any, ClassVar +from typing import Any import numpy as np import pyvista as pv @@ -33,7 +33,12 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import RenderStyle +from otko.views.canvas3d.style import ( + SELECTED_STATE, + RenderStyle, + element_family_index, + family_palette, +) class RendererMode(enum.Enum): @@ -185,9 +190,6 @@ class ModelRenderer: r, g, b = (int(round(x * 255)) for x in rgb) return f"#{r:02x}{g:02x}{b:02x}" - _NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected - _FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected - def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: self._plotter = plotter self._style = style or RenderStyle() @@ -206,6 +208,8 @@ class ModelRenderer: self._frame_actor: Any = None self._frame_ids_ordered: list[int] = [] self._frame_id_to_row: dict[int, int] = {} + #: Per-frame palette index (family), parallel to ``_frame_ids_ordered``. + self._frame_family: list[int] = [] self._node_label_actor: Any = None self._element_label_actor: Any = None self._show_node_labels: bool = False @@ -278,6 +282,16 @@ class ModelRenderer: if self._project is not None: self.render(self._project) + def set_style(self, style: RenderStyle) -> None: + """Swap the visual style and rebuild the scene it colours. + + Colours are baked into actor LUTs at build time, so a style change + needs a full re-render rather than a scalar-array tweak. + """ + self._style = style + if self._project is not None: + self.render(self._project) + def set_working_plane(self, plane: tuple[str, float] | None) -> None: """Update the working-plane filter and rebuild the grid overlay. @@ -306,11 +320,12 @@ class ModelRenderer: self._reglyph_nodes() if self._frame_pd is not None and self._frame_ids_ordered: - states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8) + # Restore each cell to its family slot, then flag the selected ones. + states = np.array(self._frame_family, dtype=np.int8) for eid in element_ids: row = self._frame_id_to_row.get(eid) if row is not None: - states[row] = 1 + states[row] = SELECTED_STATE self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.Modified() @@ -379,7 +394,7 @@ class ModelRenderer: self._node_actor = self._plotter.add_mesh( glyph, scalars="_oss_state", - cmap=self._NODE_LUT, + cmap=[self._style.node_color, self._style.node_selected_color], clim=[0, 1], show_scalar_bar=False, pickable=True, @@ -392,6 +407,7 @@ class ModelRenderer: return cells: list[int] = [] ids: list[int] = [] + families: list[int] = [] for el in frames: try: i = self._node_id_to_row[el.nodes[0]] @@ -400,6 +416,7 @@ class ModelRenderer: continue cells.extend([2, i, j]) ids.append(el.id) + families.append(element_family_index(el)) if not ids: return pd = pv.PolyData() @@ -407,16 +424,18 @@ class ModelRenderer: pd.lines = np.array(cells, dtype=np.int64) pd.cell_data["_oss_id"] = np.array(ids, dtype=np.int64) pd.cell_data["_oss_kind"] = np.array(["element"] * len(ids), dtype=object) - pd.cell_data["_oss_state"] = np.zeros(len(ids), dtype=np.int8) + # Cell scalar carries the palette slot (family, or SELECTED_STATE). + pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8) self._frame_pd = pd self._frame_ids_ordered = ids self._frame_id_to_row = {eid: i for i, eid in enumerate(ids)} + self._frame_family = families self._frame_actor = self._plotter.add_mesh( pd, scalars="_oss_state", - cmap=self._FRAME_LUT, - clim=[0, 1], + cmap=family_palette(self._style), + clim=[0, SELECTED_STATE], show_scalar_bar=False, line_width=3.0, pickable=True, @@ -761,8 +780,8 @@ class ModelRenderer: """Add one semi-transparent steel-blue non-pickable aux actor.""" actor = self._plotter.add_mesh( mesh, - color=(0.35, 0.60, 0.85), # cool steel-blue - opacity=0.22, + color=self._style.extrusion_color, + opacity=self._style.extrusion_opacity, show_edges=True, edge_color=(0.15, 0.25, 0.45), line_width=1.0, @@ -982,7 +1001,7 @@ class ModelRenderer: ndf = project.ndf dof_idx = _dof_indices(ndf) size = max(self._diag_of_points(self._node_original_points) * 0.015, 1e-6) - support_color = (1.0, 0.5, 0.1) + support_color = self._style.support_color for node in project.nodes: if not any(node.restraint[i] for i in dof_idx): continue @@ -996,7 +1015,7 @@ class ModelRenderer: if not project.load_patterns or self._node_original_points is None: return scale = max(self._diag_of_points(self._node_original_points) * 0.05, 1e-6) - load_color = (0.2, 0.85, 0.2) + load_color = self._style.nodal_load_color node_by_id = {n.id: n for n in project.nodes} elem_by_id = {e.id: e for e in project.elements} for pattern in project.load_patterns: @@ -1027,7 +1046,7 @@ class ModelRenderer: # Draw N arrows along the element span, each perpendicular # to the axis in the direction of the load. Uses the same # orange-green palette as nodal loads but with shorter arrows. - elem_load_color = (1.0, 0.55, 0.2) # orange + elem_load_color = self._style.element_load_color # distributed loads n_arrows_per_elem = 5 for eload in pattern.element_loads: if not isinstance(eload, UniformElementLoad): @@ -1116,6 +1135,7 @@ class ModelRenderer: self._node_ids_ordered = [] self._node_id_to_row = {} self._frame_ids_ordered = [] + self._frame_family = [] self._frame_id_to_row = {} self._node_original_points = None self._deformation = None @@ -1148,7 +1168,7 @@ class ModelRenderer: return self._plotter.add_point_labels( pts, labels, - font_size=12, + font_size=self._style.label_font_size, shape_opacity=0.15, text_color="black", point_color="white", @@ -1178,7 +1198,7 @@ class ModelRenderer: return self._plotter.add_point_labels( np.asarray(centers), labels, - font_size=12, + font_size=self._style.label_font_size, shape_opacity=0.15, text_color="black", point_color="white", diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 3e1de73..6051fd9 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -4,11 +4,63 @@ All colors, sizes, and glyph parameters live here. Changes apply globally through the renderer; downstream code never hard-codes a color or radius. A future "theme" feature can simply swap a different RenderStyle instance. + +The subset in :attr:`RenderStyle.EDITABLE` is surfaced to the user through +Options → Plot Properties… and persisted in ``QSettings`` (``plot/props``). +Palette values marked "opstool" are adapted from opstool v1.0.26 (GPL-3.0); +see ``NOTICE``. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +from typing import Any, ClassVar + +from otko.core import CorotTrussElement, TrussElement, ZeroLengthElement + +#: Selected elements / nodes occupy the last slot of the palette LUT. +SELECTED_STATE: int = 3 + + +def element_family_index(element: object) -> int: + """Palette slot for a frame element: 0 beam, 1 truss, 2 link/zero-length.""" + if isinstance(element, TrussElement | CorotTrussElement): + return 1 + if isinstance(element, ZeroLengthElement): + return 2 + return 0 + + +def family_palette(style: RenderStyle) -> list[str]: + """LUT shared by both backends: [beam, truss, link, selected].""" + return [ + style.element_beam_color, + style.element_truss_color, + style.element_link_color, + style.selected_color, + ] + + +#: Colours / sizes the Plot Properties dialog exposes, in display order. +STYLE_FIELDS: tuple[tuple[str, str, str], ...] = ( + ("element_beam_color", "Beam elements", "color"), + ("element_truss_color", "Truss elements", "color"), + ("element_link_color", "Link / zero-length", "color"), + ("selected_color", "Selection highlight", "color"), + ("node_color", "Nodes", "color"), + ("node_selected_color", "Selected nodes", "color"), + ("support_color", "Supports", "color"), + ("nodal_load_color", "Nodal loads", "color"), + ("element_load_color", "Element loads", "color"), + ("extrusion_color", "Section extrusions", "color"), + ("extrusion_opacity", "Extrusion opacity", "float"), + ("background_top", "Background (top)", "color"), + ("background_bottom", "Background (bottom)", "color"), + ("label_font_size", "Label font size", "int"), +) + +#: Field names the dialog edits and that are persisted between sessions. +EDITABLE_FIELDS: tuple[str, ...] = tuple(name for name, _label, _kind in STYLE_FIELDS) @dataclass(frozen=True, slots=True) @@ -19,13 +71,13 @@ class RenderStyle: background_top: str = "#dbe2ef" background_bottom: str = "#f5f7fb" - node_color: str = "#f0a500" # warm gold - node_selected_color: str = "#00d4ff" # bright cyan + node_color: str = "#d9d9d9" # normal node sphere / marker + node_selected_color: str = "#00ffff" # selected node frame_color: str = "#1f1f1f" - truss_color: str = "#2e5cb8" + truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad zerolength_color: str = "#a020f0" - selected_color: str = "#00d4ff" + selected_color: str = "#00ffff" # selected element highlight # ── element palette (adapted from opstool v1.0.26, GPL-3.0) ─────── # opstool colours wireframe elements by *family* rather than painting @@ -36,6 +88,14 @@ class RenderStyle: element_truss_color: str = "#FF8C00" # truss family element_link_color: str = "#39FF14" # zero-length / link family + # ── overlay colours ───────────────────────────────────────────── + support_color: str = "#ff8019" + nodal_load_color: str = "#33d933" + element_load_color: str = "#ff8c33" + extrusion_color: str = "#598cff" + extrusion_opacity: float = 0.22 + label_font_size: int = 12 + #: 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). @@ -57,7 +117,7 @@ class RenderStyle: roller_color: str = "#e67e22" custom_support_color: str = "#7f8c8d" - load_color: str = "#27ae60" + load_color: str = "#27ae60" # Y-axis colour of the view triad mass_color: str = "#9b59b6" # ── sizes (relative to bbox diagonal unless absolute) ──────────── @@ -75,6 +135,9 @@ class RenderStyle: selection_thickness_factor: float = 1.6 # multiplier for selected actors + #: Names of the fields the Plot Properties dialog edits. + EDITABLE: ClassVar[tuple[str, ...]] = EDITABLE_FIELDS + # ── helpers ────────────────────────────────────────────────────── def response_colorscale(self) -> list[tuple[float, str]]: """Plotly ``colorscale`` form of :attr:`response_scale_colors`.""" @@ -84,3 +147,11 @@ class RenderStyle: return [ (index / (count - 1), color) for index, color in enumerate(self.response_scale_colors) ] + + def with_updates(self, **changes: Any) -> RenderStyle: + """Return a copy with ``changes`` applied (frozen-dataclass safe).""" + return replace(self, **changes) + + def editable_values(self) -> dict[str, Any]: + """The dialog-managed subset of this style, as a plain dict.""" + return {name: getattr(self, name) for name in self.EDITABLE} diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 8c1538b..40ae710 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -44,7 +44,11 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import RenderStyle +from otko.views.canvas3d.style import ( + RenderStyle, + element_family_index, + family_palette, +) _FRAME_CLASSES = ( ElasticBeamColumn, @@ -56,19 +60,6 @@ _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), @@ -96,7 +87,6 @@ _SUPPORT_SYMBOLS = { _NODE_MARKER_SIZE = 7.0 _SUPPORT_MARKER_SIZE = 11.0 _SNAP_MARKER_SIZE = 8.0 -_LABEL_FONT_SIZE = 11 @dataclass(frozen=True) @@ -275,6 +265,10 @@ class PlotlyTraceBuilder: def __init__(self, style: RenderStyle | None = None) -> None: self._style = style or RenderStyle() + def set_style(self, style: RenderStyle) -> None: + """Swap the visual style; the next :meth:`build` picks it up.""" + self._style = style + # ── public ─────────────────────────────────────────────────────── def build(self, project: Project | None, options: SceneOptions | None = None) -> Scene: opts = options or SceneOptions() @@ -488,7 +482,12 @@ class PlotlyTraceBuilder: ) -> None: if not len(points): return - colors = ["#00ffff" if nid in opts.selection_nodes else "#d9d9d9" for nid in node_ids] + colors = [ + self._style.node_selected_color + if nid in opts.selection_nodes + else self._style.node_color + for nid in node_ids + ] data.append( { "type": "scatter3d", @@ -528,12 +527,7 @@ class PlotlyTraceBuilder: """ if not len(points): return - scale_colors = [ - self._style.element_beam_color, - self._style.element_truss_color, - self._style.element_link_color, - self._style.selected_color, - ] + scale_colors = family_palette(self._style) selected_index = len(scale_colors) - 1 x: list[float | None] = [] @@ -548,7 +542,9 @@ class PlotlyTraceBuilder: j = node_row.get(el.nodes[1]) if i is None or j is None: continue - index = float(selected_index if el.id in opts.selection_elements else _family_index(el)) + index = float( + selected_index if el.id in opts.selection_elements else element_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]) @@ -603,7 +599,7 @@ class PlotlyTraceBuilder: "y": [float(n.coords[1]) for n in nodes], "z": [float(n.coords[2]) for n in nodes], "marker": { - "color": "#ff8019", + "color": self._style.support_color, "size": _SUPPORT_MARKER_SIZE, "symbol": _SUPPORT_SYMBOLS[kind], "line": {"color": "#7f3f00", "width": 1}, @@ -708,7 +704,7 @@ class PlotlyTraceBuilder: nodal_u, nodal_v, nodal_w, - color="#33d933", + color=self._style.nodal_load_color, name="nodal-loads", size=scale, ) @@ -722,7 +718,7 @@ class PlotlyTraceBuilder: dist_u, dist_v, dist_w, - color="#ff8c33", + color=self._style.element_load_color, name="element-loads", size=0.6 * scale, ) @@ -833,7 +829,13 @@ class PlotlyTraceBuilder: 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")) + data.append( + mesh.as_trace( + color=self._style.extrusion_color, + opacity=self._style.extrusion_opacity, + name="extrusions", + ) + ) @staticmethod def _box_corners( @@ -872,7 +874,11 @@ class PlotlyTraceBuilder: ) -> 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")) + data.append( + _text_trace( + points, labels, name="node-labels", font_size=self._style.label_font_size + ) + ) if opts.show_element_labels and len(points): centers: list[np.ndarray] = [] labels: list[str] = [] @@ -886,7 +892,14 @@ class PlotlyTraceBuilder: 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")) + data.append( + _text_trace( + np.asarray(centers), + labels, + name="element-labels", + font_size=self._style.label_font_size, + ) + ) @staticmethod def _build_hover_marker(data: list[dict[str, Any]]) -> int: @@ -942,7 +955,9 @@ def _cone_trace( } -def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str, Any]: +def _text_trace( + points: np.ndarray, labels: list[str], *, name: str, font_size: int +) -> dict[str, Any]: return { "type": "scatter3d", "mode": "text", @@ -951,7 +966,7 @@ def _text_trace(points: np.ndarray, labels: list[str], *, name: str) -> dict[str "z": [float(p[2]) for p in points], "text": labels, "textposition": "top center", - "textfont": {"size": _LABEL_FONT_SIZE, "color": "#111111"}, + "textfont": {"size": font_size, "color": "#111111"}, "hoverinfo": "skip", "name": name, "showlegend": False, diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py new file mode 100644 index 0000000..32b027b --- /dev/null +++ b/tests/unit/test_render_style.py @@ -0,0 +1,117 @@ +"""Unit tests for the shared render style and its editable subset.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from otko.core import ElasticBeamColumn, Node, Project, TrussElement, ZeroLengthElement +from otko.views.canvas3d.style import ( + EDITABLE_FIELDS, + SELECTED_STATE, + STYLE_FIELDS, + RenderStyle, + element_family_index, + family_palette, +) + + +def test_defaults_are_the_documented_palette() -> None: + style = RenderStyle() + assert style.node_color == "#d9d9d9" + assert style.selected_color == "#00ffff" + # opstool-derived element palette. + assert style.element_beam_color == "#0652ff" + assert style.element_truss_color == "#FF8C00" + assert style.element_link_color == "#39FF14" + assert len(style.response_scale_colors) == 10 + assert style.response_scale_colors[0] == "#313695" # cold end + assert style.response_scale_colors[-1] == "#a50026" # warm end + + +def test_with_updates_is_immutable() -> None: + original = RenderStyle() + updated = original.with_updates(node_color="#123456", label_font_size=20) + assert updated is not original + assert updated.node_color == "#123456" + assert updated.label_font_size == 20 + # The source instance is untouched (frozen dataclass contract). + assert original.node_color == "#d9d9d9" + assert original.label_font_size == 12 + + +def test_editable_subset_matches_the_field_table() -> None: + style = RenderStyle() + assert RenderStyle.EDITABLE == EDITABLE_FIELDS + assert set(style.editable_values()) == set(EDITABLE_FIELDS) + # Every advertised field must exist on the dataclass with the right kind. + kinds = {name: kind for name, _label, kind in STYLE_FIELDS} + for name in EDITABLE_FIELDS: + assert hasattr(style, name), name + assert kinds[name] in {"color", "float", "int"} + + +def test_response_colorscale_is_an_evenly_spaced_mapping() -> None: + style = RenderStyle() + scale = style.response_colorscale() + assert len(scale) == len(style.response_scale_colors) + assert scale[0] == (0.0, style.response_scale_colors[0]) + assert scale[-1] == (1.0, style.response_scale_colors[-1]) + positions = [position for position, _color in scale] + assert positions == sorted(positions) + + +@pytest.mark.parametrize( + ("element", "expected"), + [ + (ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 0), + (TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), 1), + (ZeroLengthElement(id=3, nodes=(1, 1), material_ids=(1,), dofs=(1,)), 2), + ], +) +def test_element_family_index(element: object, expected: int) -> None: + assert element_family_index(element) == expected + + +def test_family_palette_order_and_selected_slot() -> None: + style = RenderStyle(node_color="#010101") + palette = family_palette(style) + assert palette == [ + style.element_beam_color, + style.element_truss_color, + style.element_link_color, + style.selected_color, + ] + assert len(palette) - 1 == SELECTED_STATE + + +def test_editable_values_round_trip_through_replace() -> None: + style = RenderStyle() + values = style.editable_values() + assert RenderStyle(**values) == style + # And the persisted shape is JSON-friendly (str/int/float only). + import json + + assert json.loads(json.dumps(values)) == values + + +def test_style_is_a_frozen_slots_dataclass() -> None: + assert dataclasses.is_dataclass(RenderStyle) + with pytest.raises(dataclasses.FrozenInstanceError): + RenderStyle().node_color = "#000000" # type: ignore[misc] + + +def test_project_builder_helper_is_unused_but_valid() -> None: + """A style constructed from a project's elements classifies every family.""" + project = Project( + ndm=2, + ndf=3, + nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))], + elements=[ + ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), + TrussElement(id=2, nodes=(1, 2), area=0.01, material_id=1), + ], + ) + indices = [element_family_index(el) for el in project.elements] + assert indices == [0, 1] diff --git a/tests/unit/test_renderer.py b/tests/unit/test_renderer.py index 1586ca2..04529d6 100644 --- a/tests/unit/test_renderer.py +++ b/tests/unit/test_renderer.py @@ -24,6 +24,7 @@ from otko.views.canvas3d.model_renderer import ( RendererMode, _classify_support, ) +from otko.views.canvas3d.style import SELECTED_STATE # ──────────────────────────── support classification ──────────────────────────── @@ -125,9 +126,13 @@ def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist() # Nodes 1 and 3 selected → row 0 and row 2 assert node_states == [1, 0, 1] - # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered) + # Frame cells carry the palette slot: the element family normally, and + # SELECTED_STATE when selected. selected_frame_idx = r._frame_id_to_row[2] - assert frame_states[selected_frame_idx] == 1 + assert frame_states[selected_frame_idx] == SELECTED_STATE + for index, family in enumerate(r._frame_family): + expected = SELECTED_STATE if index == selected_frame_idx else family + assert frame_states[index] == expected def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def] From bd365be77d82dd267452ed916d87c344d1da15e3 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 19:31:38 -0400 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20Options=20=E2=86=92=20Plot=20Prop?= =?UTF-8?q?erties=20dialog=20with=20live=20preview=20and=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a table-driven dialog over RenderStyle.STYLE_FIELDS (colour swatches, an opacity spin and a label font size), reachable from Options. Edits preview immediately on every canvas, Cancel restores the style the dialog opened with, and only OK persists — as JSON under QSettings plot/props, reloaded on the next launch. Both canvases gain set_style(); the dialog deliberately exposes plot_style() rather than style() so QWidget.style() keeps its Qt meaning. --- AGENTS.md | 1 + src/otko/views/canvas3d/model_canvas.py | 11 ++ src/otko/views/canvas_plotly/plotly_canvas.py | 6 + src/otko/views/dialogs/__init__.py | 2 + src/otko/views/dialogs/plot_properties.py | 139 ++++++++++++++++++ src/otko/views/main_window.py | 38 ++++- src/otko/views/menu_builder.py | 5 + src/otko/views/render_controls.py | 17 +++ tests/gui/test_plot_properties.py | 131 +++++++++++++++++ 9 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 src/otko/views/dialogs/plot_properties.py create mode 100644 tests/gui/test_plot_properties.py diff --git a/AGENTS.md b/AGENTS.md index 6b63da2..bbe6d81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`. - `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. +- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`. - `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 2b69352..3e00425 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -342,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc] self._renderer.set_show_local_axes(enabled) self.render() + def set_style(self, style: RenderStyle) -> None: + """Swap the visual style and repaint the scene. + + Applies the background immediately (it lives outside the renderer) + and rebuilds the actors whose colours come from the style LUTs. + """ + self._style = style + self.set_background(style.background_bottom, top=style.background_top) + self._renderer.set_style(style) + self.render() + def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: """Toggle viewport labels such as node and element names.""" self._renderer.set_display_options( diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index e5af025..1e771d5 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -271,6 +271,12 @@ class PlotlyCanvas(QWidget): self._options = replace(self._options, show_local_axes=bool(enabled)) self.render() + def set_style(self, style: RenderStyle) -> None: + """Swap the visual style and repaint the figure.""" + self._style = style + self._builder.set_style(style) + self.render() + def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: self._options = replace( self._options, diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index d2a3b45..e4dff02 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog from otko.views.dialogs.plain_pattern import PlainPatternDialog +from otko.views.dialogs.plot_properties import PlotPropertiesDialog from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog from otko.views.dialogs.quick_guide import QuickGuideDialog from otko.views.dialogs.replicate import ReplicateDialog @@ -69,6 +70,7 @@ __all__ = [ "PathTimeSeriesDialog", "PatternLoadsDialog", "PlainPatternDialog", + "PlotPropertiesDialog", "QuickGridLinesDialog", "QuickGuideDialog", "ReplicateDialog", diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py new file mode 100644 index 0000000..5240546 --- /dev/null +++ b/src/otko/views/dialogs/plot_properties.py @@ -0,0 +1,139 @@ +"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`. + +The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``), +so adding an editable property is a one-line change there plus a default on +the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed` +so the canvas can preview them live; ``style()`` returns the composed result. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QColorDialog, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle + + +class _ColorButton(QPushButton): + """A swatch that opens :class:`QColorDialog` and reports the hex value.""" + + colorChanged = Signal(str) + + def __init__(self, color: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._color = color + self.setFixedWidth(96) + self._refresh() + self.clicked.connect(self._pick) + + def color(self) -> str: + """Current colour as a ``#rrggbb`` string.""" + return self._color + + def set_color(self, color: str) -> None: + """Set the swatch colour (does not emit :attr:`colorChanged`).""" + self._color = color + self._refresh() + + def _refresh(self) -> None: + self.setText(self._color.upper()) + self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};") + + def _text_color(self) -> str: + color = QColor(self._color) + luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue() + return "#000000" if luminance > 140 else "#ffffff" + + def _pick(self) -> None: + chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour") + if chosen.isValid(): + self.set_color(chosen.name()) + self.colorChanged.emit(self._color) + + +class PlotPropertiesDialog(QDialog): + """Modeless-free editor for the persisted plot style.""" + + #: Emitted with the composed :class:`RenderStyle` on every edit. + changed = Signal(object) + + def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Plot Properties") + self._base = style + self._colors: dict[str, _ColorButton] = {} + self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {} + + layout = QVBoxLayout(self) + form = QFormLayout() + for name, label, kind in STYLE_FIELDS: + widget = self._build_widget(name, getattr(style, name), kind) + form.addRow(QLabel(f"{label}:"), widget) + layout.addLayout(form) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole) + assert reset is not None + reset.clicked.connect(self._reset) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + # ── public ────────────────────────────────────────────────────── + def plot_style(self) -> RenderStyle: + """The composed style for the current widget values. + + Named ``plot_style`` (not ``style``) because :meth:`QWidget.style` + already exists and must keep its Qt meaning. + """ + updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()} + updates.update({name: w.value() for name, w in self._numbers.items()}) + return self._base.with_updates(**updates) + + # ── internals ─────────────────────────────────────────────────── + def _build_widget(self, name: str, value: object, kind: str) -> QWidget: + if kind == "color": + button = _ColorButton(str(value)) + button.colorChanged.connect(self._emit_changed) + self._colors[name] = button + return button + if kind == "float": + spin = QDoubleSpinBox() + spin.setRange(0.0, 1.0) + spin.setSingleStep(0.05) + spin.setDecimals(2) + spin.setValue(float(value)) # type: ignore[arg-type] + spin.valueChanged.connect(self._emit_changed) + self._numbers[name] = spin + return spin + int_spin = QSpinBox() + int_spin.setRange(6, 32) + int_spin.setValue(int(value)) # type: ignore[call-overload] + int_spin.valueChanged.connect(self._emit_changed) + self._numbers[name] = int_spin + return int_spin + + def _emit_changed(self, *_args: object) -> None: + self.changed.emit(self.plot_style()) + + def _reset(self) -> None: + defaults = RenderStyle() + for name, button in self._colors.items(): + button.set_color(getattr(defaults, name)) + for name, spin in self._numbers.items(): + spin.setValue(getattr(defaults, name)) + self._emit_changed() diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index cbd21e3..cca1d7e 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -13,6 +13,7 @@ The ``projectChanged`` / ``modelMutated`` signals live on the from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ 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.canvas3d.style import RenderStyle from otko.views.canvas_base import plotly_available from otko.views.dock_manager import DockManager from otko.views.menu_builder import MenuBuilder @@ -72,6 +74,8 @@ class MainWindow( self._settings = settings if settings is not None else QSettings("OTKO", "OTKO") self._canvas_backend = self._stored_canvas_backend() + #: Shared, user-editable visual style (both backends read it). + self._style = self._load_plot_style() self._canvases: dict[str, Any] = {} self._diagram_renderers: dict[str, DiagramRenderer] = {} # A stack holds every backend's canvas: swapping is just @@ -115,9 +119,9 @@ class MainWindow( # Imported lazily so a plotly-less install still boots PyVista. from otko.views.canvas_plotly import PlotlyCanvas - canvas = PlotlyCanvas(self, selection=self._selection) + canvas = PlotlyCanvas(self, style=self._style, selection=self._selection) else: - canvas = ModelCanvas(self, selection=self._selection) + canvas = ModelCanvas(self, style=self._style, selection=self._selection) self._canvases[backend] = canvas return canvas @@ -145,6 +149,35 @@ class MainWindow( """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" return self._canvas_backend + # ── plot style ─────────────────────────────────────────────────── + def _load_plot_style(self) -> RenderStyle: + """Style from the last session, ignoring anything unrecognised.""" + raw = self._settings.value("plot/props") + if not raw: + return RenderStyle() + try: + data = json.loads(str(raw)) + except ValueError: + return RenderStyle() + if not isinstance(data, dict): + return RenderStyle() + allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE} + try: + return RenderStyle(**allowed) + except (TypeError, ValueError): + return RenderStyle() + + def _save_plot_style(self) -> None: + self._settings.setValue("plot/props", json.dumps(self._style.editable_values())) + + def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None: + """Apply a style to every canvas (live preview when ``persist`` is off).""" + self._style = style + for canvas in self._canvases.values(): + canvas.set_style(style) + if persist: + self._save_plot_style() + 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) @@ -261,6 +294,7 @@ class MainWindow( self._act_about.triggered.connect(self._on_about) self._act_quick_guide.triggered.connect(self._on_quick_guide) self._act_set_units.triggered.connect(self._on_set_units) + self._act_plot_properties.triggered.connect(self._on_plot_properties) # Edit self._act_delete.triggered.connect(self._on_delete) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index 0aa0b2e..5e7a271 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -192,6 +192,10 @@ class MenuBuilder: # No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run. self._act_quick_guide = QAction("&Quick Guide", self) self._act_set_units = QAction("Set Display &Units…", self) + self._act_plot_properties = QAction("&Plot Properties…", self) + self._act_plot_properties.setToolTip( + "Colours and sizes used by both canvas backends (persisted)." + ) # Canvas backend — exclusive radio pair live-switching the 3D view. from otko.views.canvas_base import plotly_available @@ -512,6 +516,7 @@ class MenuBuilder: m_options = mb.addMenu("&Options") m_options.addAction(self._act_set_units) + m_options.addAction(self._act_plot_properties) m_backend = m_options.addMenu("Canvas &Backend") m_backend.addAction(self._act_canvas_pyvista) m_backend.addAction(self._act_canvas_plotly) diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 63d1f0f..06db479 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -50,6 +50,23 @@ class RenderControls: ) # ── SAP2000-style view switching + working level ─────────────── + def _on_plot_properties(self) -> None: + """Edit the shared visual style. + + Edits preview live on both canvases; Cancel restores the style the + dialog opened with, and only OK persists to ``QSettings``. + """ + from otko.views.dialogs import PlotPropertiesDialog + + original = self._style + dialog = PlotPropertiesDialog(original, self) + dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False)) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.set_plot_style(dialog.plot_style()) + self._log("Plot properties updated.") + else: + self.set_plot_style(original, persist=False) + def _on_toggle_parallel(self, on: bool) -> None: cam = self._canvas.camera cam.parallel_projection = on diff --git a/tests/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py new file mode 100644 index 0000000..3ef3601 --- /dev/null +++ b/tests/gui/test_plot_properties.py @@ -0,0 +1,131 @@ +"""GUI tests for Options → Plot Properties. + +Covers the dialog itself plus MainWindow's live-apply / persist / revert +wiring. QSettings are injected so nothing touches real user state. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("pyvistaqt") + +from PySide6.QtCore import QObject, QSettings, Signal +from PySide6.QtWidgets import QDialog + + +def _settings(tmp_path): # type: ignore[no-untyped-def] + return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat) + + +def _window(tmp_path, qtbot): # type: ignore[no-untyped-def] + from otko.views.main_window import MainWindow + + window = MainWindow(settings=_settings(tmp_path)) + qtbot.addWidget(window) + return window + + +def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + from otko.views.canvas3d.style import RenderStyle + + window = _window(tmp_path, qtbot) + assert window._style == RenderStyle() + + +def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _window(tmp_path, qtbot) + window.swap_canvas_backend("plotly") # build the second canvas too + + window.set_plot_style( + window._style.with_updates(element_beam_color="#ff0000", label_font_size=20) + ) + + assert set(window._canvases) == {"pyvista", "plotly"} + for canvas in window._canvases.values(): + assert canvas._style.element_beam_color == "#ff0000" + assert canvas._style.label_font_size == 20 + + # A fresh window sharing the settings file reloads the customisation. + from otko.views.main_window import MainWindow + + fresh = MainWindow(settings=_settings(tmp_path)) + qtbot.addWidget(fresh) + assert fresh._style.element_beam_color == "#ff0000" + assert fresh._style.label_font_size == 20 + + +def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + window = _window(tmp_path, qtbot) + window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False) + + assert window._canvas._style.node_color == "#010203" + assert window._settings.value("plot/props") is None + + +def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] + window = _window(tmp_path, qtbot) + original = window._style + preview = original.with_updates(node_color="#010203") + + class _RejectingDialog(QObject): + changed = Signal(object) + + def __init__(self, style: object, parent: QObject | None = None) -> None: + super().__init__(parent) + self._style = preview + + def exec(self) -> QDialog.DialogCode: + self.changed.emit(preview) # live preview happens... + return QDialog.DialogCode.Rejected # ...but the user cancels + + def plot_style(self): # type: ignore[no-untyped-def] + return self._style + + monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog) + window._on_plot_properties() + + assert window._style == original + assert window._canvas._style.node_color == original.node_color + assert window._settings.value("plot/props") is None + + +def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] + window = _window(tmp_path, qtbot) + preview = window._style.with_updates(node_color="#010203") + + class _AcceptingDialog(QObject): + changed = Signal(object) + + def __init__(self, style: object, parent: QObject | None = None) -> None: + super().__init__(parent) + self._style = preview + + def exec(self) -> QDialog.DialogCode: + return QDialog.DialogCode.Accepted + + def plot_style(self): # type: ignore[no-untyped-def] + return self._style + + monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog) + window._on_plot_properties() + + assert window._style.node_color == "#010203" + assert window._canvas._style.node_color == "#010203" + assert "#010203" in str(window._settings.value("plot/props")) + + +def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def] + from otko.views.canvas3d.style import RenderStyle + from otko.views.dialogs import PlotPropertiesDialog + + dialog = PlotPropertiesDialog(RenderStyle()) + qtbot.addWidget(dialog) + assert dialog.plot_style() == RenderStyle() + + dialog._colors["node_color"].set_color("#abcdef") + assert dialog.plot_style().node_color == "#abcdef" + + dialog._reset() + assert dialog.plot_style() == RenderStyle() From 5f977e9df0805ac6d894ce5d1216341330f345d1 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:05:26 -0400 Subject: [PATCH 10/15] feat: opstool-style scene recipe for the Plotly canvas Frames the view deterministically: Scene now carries padded per-axis bounds (opstool's pad_ratio 0.15, with unit slack on degenerate axes) plus axis_overrides() pinning range + autorange=False, so the model is never flush against the viewport edge. Adds entity hover templates (Node #n / Element #n with an empty extra tag), and an optional axis outline (grid + ticks) - off by default so the SAP2000-like clean viewport is unchanged; the coloured X/Y/Z axis lines always stay as the orientation cue. The outline is exposed as a bool in the Plot Properties table, so the table-driven dialog now builds a checkbox for kind=bool. --- src/otko/views/canvas3d/style.py | 6 ++ src/otko/views/canvas_plotly/trace_builder.py | 62 +++++++++++++++++-- src/otko/views/dialogs/plot_properties.py | 11 ++++ tests/unit/test_plotly_trace_builder.py | 54 ++++++++++++++++ tests/unit/test_render_style.py | 2 +- 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py index 6051fd9..a55d43b 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -56,6 +56,7 @@ STYLE_FIELDS: tuple[tuple[str, str, str], ...] = ( ("extrusion_opacity", "Extrusion opacity", "float"), ("background_top", "Background (top)", "color"), ("background_bottom", "Background (bottom)", "color"), + ("show_axis_outline", "Axis outline (grid + ticks)", "bool"), ("label_font_size", "Label font size", "int"), ) @@ -96,6 +97,11 @@ class RenderStyle: extrusion_opacity: float = 0.22 label_font_size: int = 12 + #: Draw the plotly scene grid + tick marks around the model (the "outline" + #: of opstool's scene recipe). Off by default to keep the SAP2000-like + #: clean viewport; the coloured X/Y/Z axis lines always stay visible. + show_axis_outline: bool = False + #: 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). diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 40ae710..fa5fc94 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -114,11 +114,25 @@ class Scene: diagonal: float = 1.0 #: Trace index (in ``data``) of the empty hover-snap marker, or -1. hover_trace: int = -1 + #: Padded ``(min, max)`` per axis, used to frame the camera deterministically. + axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict) 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} + def axis_overrides(self) -> dict[str, Any]: + """Nested ``scene`` range pins for the framed data window. + + Only pushed together with the camera (view presets / new project), so + the user's own zoom and pan survive ordinary data updates. The padding + comes from opstool's scene recipe (``pad_ratio`` 0.15). + """ + return { + f"{name}axis": {"range": [low, high], "autorange": False} + for name, (low, high) in self.axis_bounds.items() + } + @dataclass class _Mesh: @@ -173,6 +187,34 @@ def _diag_of_points(pts: np.ndarray | None) -> float: return d if d > 0 else 1.0 +#: Padding fraction added around the model when framing the view. +#: From opstool's scene recipe (``pad_ratio=0.15`` for model views). +_PAD_RATIO = 0.15 + + +def _padded_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]: + """Padded ``(min, max)`` per axis; degenerate axes get unit slack.""" + if pts is None or len(pts) == 0: + return {name: (-1.0, 1.0) for name in ("x", "y", "z")} + lower = np.asarray(pts, dtype=float).min(axis=0) + upper = np.asarray(pts, dtype=float).max(axis=0) + pad = (upper - lower) * _PAD_RATIO + bounds: dict[str, tuple[float, float]] = {} + for index, name in enumerate(("x", "y", "z")): + low = float(lower[index] - pad[index]) + high = float(upper[index] + pad[index]) + if high - low < 1e-9: # planar / single-point model + low, high = low - 1.0, high + 1.0 + bounds[name] = (low, high) + return bounds + + +def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float: + """Padded bounding-box diagonal — the framing distance yardstick.""" + diagonal = float(np.linalg.norm([high - low for low, high in bounds.values()])) + return diagonal if diagonal > 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)) @@ -298,25 +340,31 @@ class PlotlyTraceBuilder: 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) + bounds = _padded_axis_bounds(all_pts) return Scene( data=data, layout=self._layout(), center=(float(center[0]), float(center[1]), float(center[2])), - diagonal=_diag_of_points(all_pts), + diagonal=_diag_of_bounds(bounds), hover_trace=hover_trace, + axis_bounds=bounds, ) # ── layout ─────────────────────────────────────────────────────── def _layout(self) -> dict[str, Any]: style = self._style + outline = style.show_axis_outline def axis(color: str, title: str) -> dict[str, Any]: + # Coloured axis lines + titles stay visible as the orientation cue + # (plotly has no corner triad); the grid/ticks are the optional + # "outline" from opstool's scene recipe. return { "title": {"text": title, "font": {"color": color, "size": 12}}, - "showgrid": False, + "showgrid": outline, "showbackground": False, - "zeroline": False, - "showticklabels": False, + "zeroline": outline, + "showticklabels": outline, "showspikes": False, "visible": True, "linecolor": color, @@ -502,7 +550,9 @@ class PlotlyTraceBuilder: }, "customdata": list(node_ids), "meta": {"kind": "node"}, - "hoverinfo": "skip", + # Engineering-notation hover (opstool's trace recipe) so a + # hover identifies the entity instead of showing the raw id. + "hovertemplate": "Node #%{customdata}", "name": "nodes", "showlegend": False, } @@ -572,7 +622,7 @@ class PlotlyTraceBuilder: }, "customdata": customdata, "meta": {"kind": "element"}, - "hoverinfo": "skip", + "hovertemplate": "Element #%{customdata}", "name": "elements", "showlegend": False, } diff --git a/src/otko/views/dialogs/plot_properties.py b/src/otko/views/dialogs/plot_properties.py index 5240546..ae249ef 100644 --- a/src/otko/views/dialogs/plot_properties.py +++ b/src/otko/views/dialogs/plot_properties.py @@ -11,6 +11,7 @@ from __future__ import annotations from PySide6.QtCore import Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( + QCheckBox, QColorDialog, QDialog, QDialogButtonBox, @@ -75,6 +76,7 @@ class PlotPropertiesDialog(QDialog): self._base = style self._colors: dict[str, _ColorButton] = {} self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {} + self._checks: dict[str, QCheckBox] = {} layout = QVBoxLayout(self) form = QFormLayout() @@ -102,6 +104,7 @@ class PlotPropertiesDialog(QDialog): """ updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()} updates.update({name: w.value() for name, w in self._numbers.items()}) + updates.update({name: w.isChecked() for name, w in self._checks.items()}) return self._base.with_updates(**updates) # ── internals ─────────────────────────────────────────────────── @@ -120,6 +123,12 @@ class PlotPropertiesDialog(QDialog): spin.valueChanged.connect(self._emit_changed) self._numbers[name] = spin return spin + if kind == "bool": + check = QCheckBox() + check.setChecked(bool(value)) + check.toggled.connect(self._emit_changed) + self._checks[name] = check + return check int_spin = QSpinBox() int_spin.setRange(6, 32) int_spin.setValue(int(value)) # type: ignore[call-overload] @@ -136,4 +145,6 @@ class PlotPropertiesDialog(QDialog): button.set_color(getattr(defaults, name)) for name, spin in self._numbers.items(): spin.setValue(getattr(defaults, name)) + for name, check in self._checks.items(): + check.setChecked(getattr(defaults, name)) self._emit_changed() diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py index ae960a9..45b2b21 100644 --- a/tests/unit/test_plotly_trace_builder.py +++ b/tests/unit/test_plotly_trace_builder.py @@ -206,3 +206,57 @@ def test_frame_trace_meta_marks_elements_pickable() -> None: # None separators break the line into per-element segments. assert None in frames["x"] assert len(frames["customdata"]) == len(frames["x"]) + + +# ── scene recipe (opstool-derived framing) ─────────────────────────────── +def test_axis_bounds_are_padded_around_the_model() -> None: + project = _load("cantilever") + scene = PlotlyTraceBuilder().build(project, SceneOptions()) + points = np.array([node.coords for node in project.nodes], dtype=float) + for index, axis in enumerate(("x", "y", "z")): + low, high = scene.axis_bounds[axis] + assert low < points[:, index].min() or low <= points[:, index].min() + assert high > points[:, index].max() or high >= points[:, index].max() + assert scene.diagonal > 0 + + +def test_planar_model_gets_unit_slack_on_the_flat_axis() -> None: + project = _load("basic_truss") + scene = PlotlyTraceBuilder().build(project, SceneOptions()) + low, high = scene.axis_bounds["z"] + assert high - low > 0 # a degenerate axis must not collapse the view + + +def test_axis_overrides_pin_ranges_with_autorange_off() -> None: + scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions()) + overrides = scene.axis_overrides() + assert set(overrides) == {"xaxis", "yaxis", "zaxis"} + for axis, override in overrides.items(): + assert override["autorange"] is False + assert len(override["range"]) == 2 + assert override["range"] == list(scene.axis_bounds[axis[0]]) + + +def test_hover_templates_identify_entities() -> None: + scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) + (nodes,) = _traces(scene, "nodes") + (frames,) = _traces(scene, "elements") + assert nodes["hovertemplate"] == "Node #%{customdata}" + assert frames["hovertemplate"] == "Element #%{customdata}" + # Hover must not fall back to the raw-id "skip" mode. + assert "hoverinfo" not in nodes + + +def test_axis_outline_flag_toggles_grid_and_ticks() -> None: + plain = RenderStyle() + outlined = RenderStyle(show_axis_outline=True) + project = _load("cantilever") + scene_plain = PlotlyTraceBuilder(plain).build(project, SceneOptions()) + scene_outlined = PlotlyTraceBuilder(outlined).build(project, SceneOptions()) + + off_axis = scene_plain.layout["scene"]["xaxis"] + on_axis = scene_outlined.layout["scene"]["xaxis"] + assert off_axis["showgrid"] is False and off_axis["showticklabels"] is False + assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True + # The coloured axis lines stay visible either way (orientation cue). + assert off_axis["visible"] is True and on_axis["visible"] is True diff --git a/tests/unit/test_render_style.py b/tests/unit/test_render_style.py index 32b027b..c179eb7 100644 --- a/tests/unit/test_render_style.py +++ b/tests/unit/test_render_style.py @@ -49,7 +49,7 @@ def test_editable_subset_matches_the_field_table() -> None: kinds = {name: kind for name, _label, kind in STYLE_FIELDS} for name in EDITABLE_FIELDS: assert hasattr(style, name), name - assert kinds[name] in {"color", "float", "int"} + assert kinds[name] in {"color", "float", "int", "bool"} def test_response_colorscale_is_an_evenly_spaced_mapping() -> None: From 8f7bc3cf4fb786e40eabc8d404d3ff0f29584c46 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:05:35 -0400 Subject: [PATCH 11/15] fix: keep the Plotly camera and axis ranges through data updates Plotly.react resets any scene attribute the incoming layout omits, so every selection change snapped the camera back to the default and re-fit the ranges - the pinned framing was meaningless. Data-only pushes now pass preserveView and html.py merges the live camera and axis ranges into the incoming layout; only an explicit re-frame sends computed framing. Also fixes the ordering bug where the framing flag was consumed before the not-ready early return, which dropped the very first framing on the floor (show_project runs before loadFinished). Verified in the browser: user orbit + zoom survive selection and working plane updates, while reset_camera re-frames. Regression-tested headlessly by asserting the emitted preserveView flag. --- AGENTS.md | Bin 6216 -> 6694 bytes src/otko/views/canvas_plotly/html.py | 20 +++- src/otko/views/canvas_plotly/plotly_canvas.py | 64 ++++++++---- tests/gui/test_plotly_view_preservation.py | 97 ++++++++++++++++++ 4 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 tests/gui/test_plotly_view_preservation.py diff --git a/AGENTS.md b/AGENTS.md index bbe6d81ae9f93827dedba19041d40702ee26143b..9bf672ed364662b78c3069ba6e5aa0ff386b4a4e 100644 GIT binary patch delta 489 ZcmX?Mu*_tGheSQYz=Dx(e8JPhXeo(DFWjF diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py index 278abba..e7bb27f 100644 --- a/src/otko/views/canvas_plotly/html.py +++ b/src/otko/views/canvas_plotly/html.py @@ -122,8 +122,26 @@ _PAGE = """ gd.on('plotly_unhover', function () { clearHover(); }); } - window.otkoUpdate = function (payloadJson) { + window.otkoUpdate = function (payloadJson, preserveView) { var fig = JSON.parse(payloadJson); + // `Plotly.react` resets any scene attribute the incoming layout omits, + // which would snap the camera and the padded ranges back on every data + // update. When this is not an explicit re-frame, carry the user's + // current view forward into the incoming layout. + if (preserveView) { + var gd = document.getElementById('plot'); + var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null; + if (scene) { + fig.layout.scene = fig.layout.scene || {}; + fig.layout.scene.camera = scene.camera; + ['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) { + if (!scene[axis]) return; + fig.layout.scene[axis] = fig.layout.scene[axis] || {}; + fig.layout.scene[axis].range = scene[axis].range; + fig.layout.scene[axis].autorange = false; + }); + } + } Plotly.react('plot', fig.data, fig.layout, config).then(function () { findHover(); installHandlers(); diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index 1e771d5..d5c0ff0 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -5,10 +5,13 @@ 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. +Update strategy: figures are pushed with ``Plotly.react``. Because react +resets any scene attribute the incoming layout omits, a non-framing push is +flagged ``preserveView`` and the JS side carries the live camera and axis +ranges forward — so re-rendering on a model edit or selection change never +yanks the camera the user is orbiting. Only an explicit re-frame (new +project, view preset, ``reset_camera``, style change) sends the computed +camera plus opstool-style padded axis ranges. """ from __future__ import annotations @@ -138,7 +141,7 @@ class PlotlyCanvas(QWidget): self._default_selection_enabled = True self._working_plane: tuple[str, float] | None = None self._camera = _CameraShim(self) - self._camera_dirty = True + self._framing_dirty = True self._renderer = _PlotlyRendererFacade(self) self._ready = False @@ -173,7 +176,7 @@ class PlotlyCanvas(QWidget): self.set_project(project) if project is not None and project.nodes: self._view_preset = "iso" - self._camera_dirty = True + self._framing_dirty = True self.render() def clear_model(self) -> None: @@ -206,34 +209,34 @@ class PlotlyCanvas(QWidget): def set_parallel_projection(self, on: bool) -> None: self._parallel = bool(on) - self._camera_dirty = True + self._framing_dirty = True def reset_camera(self) -> None: self._view_preset = "iso" - self._camera_dirty = True + self._framing_dirty = True self.render() def view_isometric(self) -> None: self._view_preset = "iso" - self._camera_dirty = True + self._framing_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._framing_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._framing_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._framing_dirty = True self.render() # ── working plane ──────────────────────────────────────────────── @@ -272,9 +275,14 @@ class PlotlyCanvas(QWidget): self.render() def set_style(self, style: RenderStyle) -> None: - """Swap the visual style and repaint the figure.""" + """Swap the visual style and repaint the figure. + + Treated as a re-frame because the background and axis outline are + layout-level (``Plotly.restyle`` cannot carry them). + """ self._style = style self._builder.set_style(style) + self._framing_dirty = True self.render() def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: @@ -291,18 +299,32 @@ class PlotlyCanvas(QWidget): # ── internals ─────────────────────────────────────────────────── def _push_scene(self) -> None: + """Send the figure to plotly.js. + + ``Plotly.react`` resets any scene attribute the incoming layout omits, + which used to snap the camera *and* the padded ranges back on every + selection change. So a non-framing push marks itself + ``preserveView`` and the JS side carries the live camera/ranges + forward; only an explicit re-frame (new project, view preset, + ``reset_camera``, style change) sends the computed framing. + """ 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: + # Nothing to push yet; crucially this must come *before* the + # framing flag is consumed, or the framing scheduled before + # loadFinished would be dropped on the floor. return + layout = dict(self._scene.layout) + re_framed = self._framing_dirty + if re_framed: + scene_layout = dict(layout["scene"]) + scene_layout["camera"] = self._camera_dict(self._scene) + for axis, override in self._scene.axis_overrides().items(): + scene_layout[axis] = {**scene_layout.get(axis, {}), **override} + layout["scene"] = scene_layout + self._framing_dirty = False payload = json.dumps({"data": self._scene.data, "layout": layout}) - self._web.page().runJavaScript(f"window.otkoUpdate({json.dumps(payload)})") + self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})") def _camera_dict(self, scene: Scene) -> dict[str, Any]: cx, cy, cz = scene.center diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py new file mode 100644 index 0000000..c748163 --- /dev/null +++ b/tests/gui/test_plotly_view_preservation.py @@ -0,0 +1,97 @@ +"""Regression tests for the Plotly canvas push contract. + +``Plotly.react`` resets any scene attribute the incoming layout omits, so a +data-only push must be flagged ``preserveView`` (the JS side then carries the +live camera and axis ranges forward). Only explicit re-frames may send the +computed framing. These tests capture the JavaScript the canvas emits instead +of driving the browser, so they stay fast and deterministic. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("plotly") + +from otko.services import load_project + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +def _canvas_with_captured_js(qtbot): # type: ignore[no-untyped-def] + from otko.views.canvas_plotly import PlotlyCanvas + + canvas = PlotlyCanvas() + qtbot.addWidget(canvas) + # Pretend the page finished loading, then capture instead of running JS. + canvas._ready = True + calls: list[str] = [] + canvas._eval = lambda js: calls.append(js) # type: ignore[method-assign] + return canvas, calls + + +def _parse_call(call: str) -> tuple[dict, bool]: + """Split a captured ``window.otkoUpdate(, )`` call.""" + body = call[len("window.otkoUpdate(") : -1] + literal, _sep, flag = body.rpartition(", ") + return json.loads(json.loads(literal)), flag == "true" + + +@pytest.mark.gui +def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignore[no-untyped-def] + canvas, calls = _canvas_with_captured_js(qtbot) + + canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) + + assert calls, "show_project must push a figure" + payload, preserve = _parse_call(calls[-1]) + assert preserve is False, "initial push must be a re-frame" + # The framing push carries the camera and the padded ranges. + scene = payload["layout"]["scene"] + assert "camera" in scene + assert "range" in scene["xaxis"] + assert scene["xaxis"]["autorange"] is False + + +@pytest.mark.gui +def test_data_update_preserves_the_view(qtbot) -> None: # type: ignore[no-untyped-def] + canvas, calls = _canvas_with_captured_js(qtbot) + + canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) + calls.clear() + + canvas.selection.select_node(1) # data-only update + + assert calls, "selection change must push" + payload, preserve = _parse_call(calls[-1]) + assert preserve is True, "data update must preserve the view" + assert "camera" not in payload["layout"]["scene"], "must not re-send the camera" + + +@pytest.mark.gui +def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-untyped-def] + canvas, calls = _canvas_with_captured_js(qtbot) + + canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) + + for action in (canvas.view_xy, canvas.view_xz, canvas.view_yz, canvas.reset_camera): + calls.clear() + action() + assert calls[-1].endswith(", false)"), f"{action.__name__} must re-frame" + + +@pytest.mark.gui +def test_style_change_re_frames_for_layout_only_attrs(qtbot) -> None: # type: ignore[no-untyped-def] + from otko.views.canvas3d.style import RenderStyle + + canvas, calls = _canvas_with_captured_js(qtbot) + canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) + calls.clear() + + canvas.set_style(RenderStyle(show_axis_outline=True)) + + assert calls[-1].endswith(", false)"), "background/outline live in the layout" From 0611153555e80ee3ae913d35c00a1d4ce3f35a6b Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:14:05 -0400 Subject: [PATCH 12/15] fix: stop the Plotly canvas wedging on rapid or overlapping updates Three defects in the view-preservation path could leave the plot frozen (no updates, no orbit, stale colours): 1. The merge injected raw _fullLayout objects - including undefined when a push landed before the previous react resolved - and plotly validates layouts, so one bad value made every later react fail permanently. currentView() now deep-copies and validates eye/center/up and each range, and mergeView() is wrapped so it can never block an update. 2. Updates were not serialized: overlapping Plotly.react calls on one graph div left it unresponsive. otkoUpdate now queues and coalesces (one react at a time, latest payload wins), and logs instead of failing silently. 3. A style change re-framed the camera, so re-colouring yanked a view the user had orbited. Colour/opacity ride the traces and background/outline ride the layout, so set_style is now a non-framing push. Verified in the browser: a user orbit survives colour changes, 30 rapid preview updates land on the final value and stay responsive, and deleting the live camera no longer wedges the plot. --- src/otko/views/canvas_plotly/html.py | 110 +++++++++++++----- src/otko/views/canvas_plotly/plotly_canvas.py | 10 +- tests/gui/test_plotly_view_preservation.py | 8 +- 3 files changed, 96 insertions(+), 32 deletions(-) diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py index e7bb27f..2afa289 100644 --- a/src/otko/views/canvas_plotly/html.py +++ b/src/otko/views/canvas_plotly/html.py @@ -89,6 +89,90 @@ _PAGE = """ Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]); } + // --- view preservation ------------------------------------------------- + // `Plotly.react` resets any scene attribute the incoming layout omits, so a + // non-framing update carries the live camera/ranges forward. Only plain, + // validated copies are merged: the raw `_fullLayout` objects are owned by + // plotly (react mutates them in place), and injecting an undefined value + // would make every later react fail, wedging the plot. + function toPlain(value) { + try { return JSON.parse(JSON.stringify(value)); } catch (err) { return null; } + } + + function currentView() { + var gd = document.getElementById('plot'); + var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null; + if (!scene) return null; + var view = { ranges: {} }; + var camera = toPlain(scene.camera); + if (camera && camera.eye && camera.center) { + view.camera = { eye: camera.eye, center: camera.center, up: camera.up }; + if (camera.projection) view.camera.projection = camera.projection; + } + ['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) { + var source = scene[axis]; + if (!source || !Array.isArray(source.range) || source.range.length !== 2) return; + var low = Number(source.range[0]); + var high = Number(source.range[1]); + if (isFinite(low) && isFinite(high) && high > low) view.ranges[axis] = [low, high]; + }); + return view; + } + + function mergeView(fig, view) { + try { + if (!view) return; + fig.layout.scene = fig.layout.scene || {}; + if (view.camera) fig.layout.scene.camera = view.camera; + Object.keys(view.ranges).forEach(function (axis) { + fig.layout.scene[axis] = fig.layout.scene[axis] || {}; + fig.layout.scene[axis].range = view.ranges[axis]; + fig.layout.scene[axis].autorange = false; + }); + } catch (err) { + // Never let view preservation block the figure update. + if (window.console) console.warn('otko: view merge skipped', err); + } + } + + // --- update queue ------------------------------------------------------ + // One react at a time, latest payload wins. Overlapping reacts on the same + // graph div are what left the plot unresponsive before. + var pendingUpdate = null; + var reactBusy = false; + + function runUpdate() { + if (reactBusy || !pendingUpdate) return; + var job = pendingUpdate; + pendingUpdate = null; + reactBusy = true; + + var fig; + try { + fig = JSON.parse(job.payload); + } catch (err) { + reactBusy = false; + if (window.console) console.error('otko: bad payload', err); + return; + } + mergeView(fig, job.preserve ? currentView() : null); + + Plotly.react('plot', fig.data, fig.layout, config).then(function () { + findHover(); + installHandlers(); + }, function (err) { + if (window.console) console.error('otko: react failed', err); + }).then(function () { + reactBusy = false; + runUpdate(); + }); + } + + window.otkoUpdate = function (payloadJson, preserveView) { + pendingUpdate = { payload: payloadJson, preserve: !!preserveView }; + runUpdate(); + }; + function installHandlers() { if (handlersReady) return; var gd = document.getElementById('plot'); @@ -122,32 +206,6 @@ _PAGE = """ gd.on('plotly_unhover', function () { clearHover(); }); } - window.otkoUpdate = function (payloadJson, preserveView) { - var fig = JSON.parse(payloadJson); - // `Plotly.react` resets any scene attribute the incoming layout omits, - // which would snap the camera and the padded ranges back on every data - // update. When this is not an explicit re-frame, carry the user's - // current view forward into the incoming layout. - if (preserveView) { - var gd = document.getElementById('plot'); - var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null; - if (scene) { - fig.layout.scene = fig.layout.scene || {}; - fig.layout.scene.camera = scene.camera; - ['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) { - if (!scene[axis]) return; - fig.layout.scene[axis] = fig.layout.scene[axis] || {}; - fig.layout.scene[axis].range = scene[axis].range; - fig.layout.scene[axis].autorange = false; - }); - } - } - Plotly.react('plot', fig.data, fig.layout, config).then(function () { - findHover(); - installHandlers(); - }); - }; - window.otkoSetCamera = function (cameraJson) { Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) }); }; diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index d5c0ff0..754a779 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -277,12 +277,13 @@ class PlotlyCanvas(QWidget): def set_style(self, style: RenderStyle) -> None: """Swap the visual style and repaint the figure. - Treated as a re-frame because the background and axis outline are - layout-level (``Plotly.restyle`` cannot carry them). + Deliberately *not* a re-frame: colour/opacity are trace props and the + background/outline ride the layout, so a style change (including the + live preview while dragging a colour) updates without snapping the + camera the user has moved. """ self._style = style self._builder.set_style(style) - self._framing_dirty = True self.render() def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None: @@ -306,7 +307,8 @@ class PlotlyCanvas(QWidget): selection change. So a non-framing push marks itself ``preserveView`` and the JS side carries the live camera/ranges forward; only an explicit re-frame (new project, view preset, - ``reset_camera``, style change) sends the computed framing. + ``reset_camera``) sends the computed framing. Style changes are + non-framing: they re-colour in place. """ self._scene = self._builder.build(self._project, self._options) if not self._ready: diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py index c748163..3250779 100644 --- a/tests/gui/test_plotly_view_preservation.py +++ b/tests/gui/test_plotly_view_preservation.py @@ -85,7 +85,8 @@ def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-unty @pytest.mark.gui -def test_style_change_re_frames_for_layout_only_attrs(qtbot) -> None: # type: ignore[no-untyped-def] +def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def] + """Re-colouring must not snap the camera back to the preset.""" from otko.views.canvas3d.style import RenderStyle canvas, calls = _canvas_with_captured_js(qtbot) @@ -94,4 +95,7 @@ def test_style_change_re_frames_for_layout_only_attrs(qtbot) -> None: # type: i canvas.set_style(RenderStyle(show_axis_outline=True)) - assert calls[-1].endswith(", false)"), "background/outline live in the layout" + assert calls, "a style change must push" + payload, preserve = _parse_call(calls[-1]) + assert preserve is True, "colour changes must not re-frame the view" + assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied From 7cf323df23b2e1a43a0559927a0c5f9d4b8f8e27 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:18:23 -0400 Subject: [PATCH 13/15] feat: surface the Plotly page console in the Console dock A plotly failure inside the WebEngine page was invisible from Python - the react promise just rejected and the canvas looked frozen with no evidence. PlotlyCanvas now installs a QWebEnginePage that forwards javaScriptConsoleMessage into the Console dock with the right severity (error/warning/info), so the next silent failure is diagnosable. --- src/otko/views/canvas_plotly/plotly_canvas.py | 36 ++++++++++++++++++- src/otko/views/main_window.py | 17 ++++++++- tests/gui/test_plotly_view_preservation.py | 22 ++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index 754a779..ddecb15 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -18,11 +18,13 @@ from __future__ import annotations import json import math +from collections.abc import Callable from dataclasses import replace from typing import Any -from PySide6.QtCore import QUrl, Signal +from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtWebChannel import QWebChannel +from PySide6.QtWebEngineCore import QWebEnginePage from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWidgets import QVBoxLayout, QWidget @@ -47,6 +49,34 @@ _VIEW_DIRECTIONS = { } +class _LoggingPage(QWebEnginePage): + """A web page that forwards the JS console to the app's console dock. + + A plotly failure inside the page is otherwise invisible from Python (the + ``react`` promise just rejects), which makes a frozen canvas very hard to + diagnose. + """ + + def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None: + super().__init__(parent) + self._log = log + + def javaScriptConsoleMessage( + self, + level: QWebEnginePage.JavaScriptConsoleMessageLevel, + message: str, + line_number: int, + source_id: str, + ) -> None: + names = { + QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error", + QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning", + } + severity = names.get(level, "info") + source = source_id.rsplit("/", 1)[-1] or "plotly" + self._log(f"[web] {source}:{line_number} {message}", severity) + + class _CameraShim: """Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere.""" @@ -126,8 +156,10 @@ class PlotlyCanvas(QWidget): parent: QWidget | None = None, style: RenderStyle | None = None, selection: SelectionState | None = None, + log: Callable[[str, str], None] | None = None, ) -> None: super().__init__(parent) + self._log = log self._style = style or RenderStyle() self.selection = selection or SelectionState(self) self._builder = PlotlyTraceBuilder(self._style) @@ -153,6 +185,8 @@ class PlotlyCanvas(QWidget): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self._web = QWebEngineView(self) + if self._log is not None: + self._web.setPage(_LoggingPage(self._log, self._web)) self._channel = QWebChannel(self._web.page()) self._bridge = _Bridge(self) self._channel.registerObject("otkoBridge", self._bridge) diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index cca1d7e..5241869 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -119,7 +119,12 @@ class MainWindow( # Imported lazily so a plotly-less install still boots PyVista. from otko.views.canvas_plotly import PlotlyCanvas - canvas = PlotlyCanvas(self, style=self._style, selection=self._selection) + canvas = PlotlyCanvas( + self, + style=self._style, + selection=self._selection, + log=self._log_canvas_message, + ) else: canvas = ModelCanvas(self, style=self._style, selection=self._selection) self._canvases[backend] = canvas @@ -149,6 +154,16 @@ class MainWindow( """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" return self._canvas_backend + def _log_canvas_message(self, message: str, severity: str = "info") -> None: + """Route a JS console line from the Plotly page to the Console dock. + + The dock may not exist yet while canvases are being constructed, so + the lookup is guarded. + """ + dock = getattr(self, "_console_dock", None) + if dock is not None: + dock.log(message, severity=severity) + # ── plot style ─────────────────────────────────────────────────── def _load_plot_style(self) -> RenderStyle: """Style from the last session, ignoring anything unrecognised.""" diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py index 3250779..97ef3ee 100644 --- a/tests/gui/test_plotly_view_preservation.py +++ b/tests/gui/test_plotly_view_preservation.py @@ -99,3 +99,25 @@ def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # t payload, preserve = _parse_call(calls[-1]) assert preserve is True, "colour changes must not re-frame the view" assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied + + +@pytest.mark.gui +def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def] + """A plotly failure inside the page must not be invisible from Python.""" + from PySide6.QtWebEngineCore import QWebEnginePage + + from otko.views.canvas_plotly.plotly_canvas import _LoggingPage + + messages: list[tuple[str, str]] = [] + page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg))) + + levels = QWebEnginePage.JavaScriptConsoleMessageLevel + page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html") + page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html") + page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "") + + assert messages == [ + ("error", "[web] plot.html:7 boom"), + ("warning", "[web] plot.html:8 careful"), + ("info", "[web] plotly:9 hello"), + ] From 5262d31e336cb26960c4890e281e03793ba210c7 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:33:30 -0400 Subject: [PATCH 14/15] fix: stop the Plotly hover handler redrawing on every mouse move plotly_hover/plotly_unhover fire continuously while the mouse moves, and the unhover handler restyled the snap marker unconditionally - even with snapping off and nothing visible. That meant a full Plotly.restyle plus redraw per mouse event, which re-fired hover until the page died with 'RangeError: Maximum call stack size exceeded' and the canvas froze (reported live after opening a model). The marker is now updated only when its visible state actually changes: no marker when snapping is off, and a restyle only when the snapped point differs from the one already shown. Deliberately synchronous - timers are throttled to about a second by WebEngine when the page is not compositing, which stalled the preview. Measured: 200 mouse-move events went from 200 restyles to 0; 200 hovers on one grid dot cost a single restyle. --- src/otko/views/canvas_plotly/html.py | 43 ++++++++++--- tests/gui/test_plotly_hover.py | 93 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 tests/gui/test_plotly_hover.py diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py index 2afa289..46e145e 100644 --- a/src/otko/views/canvas_plotly/html.py +++ b/src/otko/views/canvas_plotly/html.py @@ -77,16 +77,36 @@ _PAGE = """ var meta = gd.data[i] && gd.data[i].meta; if (meta && meta.kind === 'hover') { hoverIndex = i; break; } } + // A freshly pushed figure has an empty hover marker again. + hoverShown = false; + hoverPoint = null; } - function setHover(x, y, z) { - if (hoverIndex < 0) return; - Plotly.restyle('plot', { x: [[x]], y: [[y]], z: [[z]] }, [hoverIndex]); + // --- snap-target marker ------------------------------------------------ + // Updated synchronously but only when the visual state actually changes. + // Restyling on every hover/unhover made the plot redraw per mouse move, + // which re-fired hover and ended in a runaway redraw loop (RangeError: + // Maximum call stack size exceeded, frozen canvas). Timers were avoided + // deliberately: WebEngine throttles them to ~1 s when the page is not + // compositing, which made the preview lag. + var hoverShown = false; + var hoverPoint = null; + + function samePoint(a, b) { + return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; } - function clearHover() { + function setHoverMarker(point) { + var want = snapEnabled ? point : null; + if (samePoint(want, hoverPoint) && !!want === hoverShown) return; + if (!want && !hoverShown) { hoverPoint = null; return; } + hoverPoint = want; + hoverShown = !!want; if (hoverIndex < 0) return; - Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]); + var x = want ? [want[0]] : []; + var y = want ? [want[1]] : []; + var z = want ? [want[2]] : []; + Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]); } // --- view preservation ------------------------------------------------- @@ -198,12 +218,17 @@ _PAGE = """ var pts = ev.points || []; if (!pts.length) return; var p = pts[0]; - if (kindOf(p) !== 'snap') { clearHover(); return; } + if (kindOf(p) !== 'snap') { setHoverMarker(null); return; } var c = p.customdata; - setHover(c[0], c[1], c[2]); + if (c) setHoverMarker([c[0], c[1], c[2]]); }); - gd.on('plotly_unhover', function () { clearHover(); }); + gd.on('plotly_unhover', function () { + // Nothing can be visible unless snapping is armed, so stay out of the + // redraw path entirely for ordinary mouse movement. + if (!snapEnabled) return; + setHoverMarker(null); + }); } window.otkoSetCamera = function (cameraJson) { @@ -212,7 +237,7 @@ _PAGE = """ window.otkoSetSnapEnabled = function (on) { snapEnabled = !!on; - if (!snapEnabled) clearHover(); + if (!snapEnabled) setHoverMarker(null); }; })(); diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py new file mode 100644 index 0000000..78d5160 --- /dev/null +++ b/tests/gui/test_plotly_hover.py @@ -0,0 +1,93 @@ +"""Regression tests for the Plotly hover/snap-marker contract. + +``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves. +Touching the plot on each one made it redraw per mouse move, re-firing hover +until the stack blew (``RangeError: Maximum call stack size exceeded``) and the +canvas froze. The marker is therefore coalesced and only touched when its +shown/hidden state actually changes. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("plotly") + +from otko.services import load_project + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + +#: 200 mouse-move events over a *non-snap* point with snapping armed. +_CHURN_PROBE = """(function(){ + var gd = document.getElementById('plot'); + var n = 0; var original = Plotly.restyle; + Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); }; + var point = { data: gd.data[0], customdata: [0, 0, 0] }; + try { + for (var i = 0; i < 200; i++) { + gd.emit('plotly_hover', { points: [point] }); + gd.emit('plotly_unhover', {}); + } + return n; + } finally { Plotly.restyle = original; } +})()""" + +_HOVER_MARKER_X = ( + "JSON.stringify((document.getElementById('plot').data" + ".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)" +) + +_EMIT_SNAP_HOVER = """(function(){ + var gd = document.getElementById('plot'); + var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0]; + var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] }; + gd.emit('plotly_hover', { points: [point] }); + return true; +})()""" + + +def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def] + from otko.views.canvas_plotly import PlotlyCanvas + + canvas = PlotlyCanvas() + qtbot.addWidget(canvas) + canvas.show_project(load_project(EXAMPLES / name)) + qtbot.waitUntil(lambda: canvas._ready, timeout=30000) + qtbot.wait(1500) # let the first Plotly.react settle + return canvas + + +def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def] + box: dict[str, object] = {} + canvas._web.page().runJavaScript(script, lambda value: box.update(value=value)) + qtbot.waitUntil(lambda: "value" in box, timeout=timeout) + return box["value"] + + +@pytest.mark.gui +def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def] + """The unfixed version restyled once per hover/unhover event.""" + canvas = _open_canvas(qtbot, "space_frame_3d.osmodel") + + restyles = _run_js(canvas, qtbot, _CHURN_PROBE) + + assert restyles == 0, f"mouse movement caused {restyles} plot restyles" + + +@pytest.mark.gui +def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def] + canvas = _open_canvas(qtbot, "basic_truss.osmodel") + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]" + + canvas.set_snap_preview_enabled(True) + qtbot.wait(100) + _run_js(canvas, qtbot, _EMIT_SNAP_HOVER) + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown" + + _run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})") + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared" From 429ebc0790688e1582f083d701adb3da427f2811 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:33:30 -0400 Subject: [PATCH 15/15] docs: repair AGENTS.md NUL corruption and record the canvas contracts The previous AGENTS.md edit wrote 478 NUL bytes instead of the intended sentence, so git and grep treated the file as binary. Restored the clean UTF-8 text and documented both Plotly canvas invariants: data-only pushes must pass preserveView (never re-send the camera), and the hover/snap marker must only be restyled when its visible state changes. --- AGENTS.md | Bin 6694 -> 6659 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9bf672ed364662b78c3069ba6e5aa0ff386b4a4e..babac38c49eec7dce3b0f5a314a7391224f1e087 100644 GIT binary patch delta 458 zcmZXQF-`+P3`NVd9O0KBg``P^M8yRV1#Q;ZF_}?jXGXSnmaS570yqRU5(U>oyJ0IT zTlV*}{XchayRVPc8O>wN7RLulm9#mz1iT2kHV@UCm8GGNHfRn mu@BoR+n4*<{Cy2P?Eicr3pq_@tpCd|>iNk@@%_H~x%vggajc5~ delta 494 ecmZoRS!S|f68mOf&TRtq3By8VeX4}Jh0V+M!-