diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c834e6..f0a7165 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 ".[gui,dev]" + - run: pip install -e ".[dev]" - run: xvfb-run -a pytest tests/gui -m "not slow" test-integration: diff --git a/.gitignore b/.gitignore index eac3bc1..c82640e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,3 @@ 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 deleted file mode 100644 index 32dbf4f..0000000 --- a/.ignore +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index ddddcab..0000000 --- a/.slim/clonedeps.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 babac38..bdc5f2e 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 + plotly.js backend + dev tools +pip install -e ".[gui,dev]" # desktop: Qt + PyVista + 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,8 +19,6 @@ 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. `PlotlyCanvas` pushes with `Plotly.react`, which resets scene attributes the layout omits: data-only pushes pass `preserveView` so `html.py` carries the live camera and padded axis ranges forward, and the hover/snap marker is restyled only when its visible state changes (a restyle per hover event redraws per mouse move until the stack blows). Regression tests: `tests/gui/test_plotly_view_preservation.py`, `tests/gui/test_plotly_hover.py`. -- `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). @@ -58,17 +56,3 @@ 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. - diff --git a/NOTICE b/NOTICE index 24b2dd1..10a928c 100644 --- a/NOTICE +++ b/NOTICE @@ -45,21 +45,6 @@ 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/pyproject.toml b/pyproject.toml index d67c45f..ffab17d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ gui = [ "vtk>=9.3", "pyqtgraph>=0.13", "imageio[ffmpeg]>=2.34", - "plotly>=5.18", ] dev = [ "pytest>=7.4", diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 57c0a70..05c5f2c 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -31,7 +31,6 @@ 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") @@ -63,9 +62,8 @@ class DiagramRenderer: - ``clear()`` removes the overlay. """ - def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: + def __init__(self, plotter: Any) -> None: self._plotter = plotter - self._style = style or RenderStyle() self._actor: Any = None self._label_actor: Any = None @@ -160,9 +158,7 @@ class DiagramRenderer: self._actor = self._plotter.add_mesh( mesh, scalars="value", - # Shared response palette (opstool's default diverging scale), so - # the PyVista and Plotly backends stay visually consistent. - cmap=list(self._style.response_scale_colors), + cmap="coolwarm", clim=(-vmax, vmax), show_scalar_bar=True, scalar_bar_args={"title": comp_label, "n_labels": 5}, diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index 3e00425..b1b3417 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -24,7 +24,6 @@ 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 @@ -32,9 +31,6 @@ 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) @@ -342,17 +338,6 @@ 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/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 12c88cb..9eb9274 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 +from typing import Any, ClassVar import numpy as np import pyvista as pv @@ -33,12 +33,7 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.views.canvas3d.style import ( - SELECTED_STATE, - RenderStyle, - element_family_index, - family_palette, -) +from otko.views.canvas3d.style import RenderStyle class RendererMode(enum.Enum): @@ -190,6 +185,9 @@ 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() @@ -208,8 +206,6 @@ 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 @@ -282,16 +278,6 @@ 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. @@ -320,12 +306,11 @@ class ModelRenderer: self._reglyph_nodes() if self._frame_pd is not None and self._frame_ids_ordered: - # Restore each cell to its family slot, then flag the selected ones. - states = np.array(self._frame_family, dtype=np.int8) + states = np.zeros(len(self._frame_ids_ordered), dtype=np.int8) for eid in element_ids: row = self._frame_id_to_row.get(eid) if row is not None: - states[row] = SELECTED_STATE + states[row] = 1 self._frame_pd.cell_data["_oss_state"] = states self._frame_pd.Modified() @@ -394,7 +379,7 @@ class ModelRenderer: self._node_actor = self._plotter.add_mesh( glyph, scalars="_oss_state", - cmap=[self._style.node_color, self._style.node_selected_color], + cmap=self._NODE_LUT, clim=[0, 1], show_scalar_bar=False, pickable=True, @@ -407,7 +392,6 @@ 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]] @@ -416,7 +400,6 @@ 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() @@ -424,18 +407,16 @@ 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) - # Cell scalar carries the palette slot (family, or SELECTED_STATE). - pd.cell_data["_oss_state"] = np.array(families, dtype=np.int8) + pd.cell_data["_oss_state"] = np.zeros(len(ids), 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=family_palette(self._style), - clim=[0, SELECTED_STATE], + cmap=self._FRAME_LUT, + clim=[0, 1], show_scalar_bar=False, line_width=3.0, pickable=True, @@ -780,8 +761,8 @@ class ModelRenderer: """Add one semi-transparent steel-blue non-pickable aux actor.""" actor = self._plotter.add_mesh( mesh, - color=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, + color=(0.35, 0.60, 0.85), # cool steel-blue + opacity=0.22, show_edges=True, edge_color=(0.15, 0.25, 0.45), line_width=1.0, @@ -1001,7 +982,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 = self._style.support_color + support_color = (1.0, 0.5, 0.1) for node in project.nodes: if not any(node.restraint[i] for i in dof_idx): continue @@ -1015,7 +996,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 = self._style.nodal_load_color + load_color = (0.2, 0.85, 0.2) 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: @@ -1046,7 +1027,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 = self._style.element_load_color # distributed loads + elem_load_color = (1.0, 0.55, 0.2) # orange n_arrows_per_elem = 5 for eload in pattern.element_loads: if not isinstance(eload, UniformElementLoad): @@ -1135,7 +1116,6 @@ 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 @@ -1168,7 +1148,7 @@ class ModelRenderer: return self._plotter.add_point_labels( pts, labels, - font_size=self._style.label_font_size, + font_size=12, shape_opacity=0.15, text_color="black", point_color="white", @@ -1198,7 +1178,7 @@ class ModelRenderer: return self._plotter.add_point_labels( np.asarray(centers), labels, - font_size=self._style.label_font_size, + font_size=12, 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 a55d43b..f25d674 100644 --- a/src/otko/views/canvas3d/style.py +++ b/src/otko/views/canvas3d/style.py @@ -4,64 +4,11 @@ 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, 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"), - ("show_axis_outline", "Axis outline (grid + ticks)", "bool"), - ("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) +from dataclasses import dataclass @dataclass(frozen=True, slots=True) @@ -72,58 +19,20 @@ class RenderStyle: background_top: str = "#dbe2ef" background_bottom: str = "#f5f7fb" - node_color: str = "#d9d9d9" # normal node sphere / marker - node_selected_color: str = "#00ffff" # selected node + node_color: str = "#f0a500" # warm gold + node_selected_color: str = "#00d4ff" # bright cyan frame_color: str = "#1f1f1f" - truss_color: str = "#2e5cb8" # also the Z-axis colour of the view triad + truss_color: str = "#2e5cb8" zerolength_color: str = "#a020f0" - 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 - # 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 - - # ── 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 - - #: 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). - response_scale_colors: tuple[str, ...] = ( - "#313695", - "#4575b4", - "#74add1", - "#abd9e9", - "#e0f3f8", - "#fee090", - "#fdae61", - "#f46d43", - "#d73027", - "#a50026", - ) + selected_color: str = "#00d4ff" fix_color: str = "#c0392b" # firebrick pin_color: str = "#c0392b" roller_color: str = "#e67e22" custom_support_color: str = "#7f8c8d" - load_color: str = "#27ae60" # Y-axis colour of the view triad + load_color: str = "#27ae60" mass_color: str = "#9b59b6" # ── sizes (relative to bbox diagonal unless absolute) ──────────── @@ -140,24 +49,3 @@ class RenderStyle: load_min_length: float = 0.5 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`.""" - 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) - ] - - 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_base.py b/src/otko/views/canvas_base.py deleted file mode 100644 index 0352961..0000000 --- a/src/otko/views/canvas_base.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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/canvas_plotly/__init__.py b/src/otko/views/canvas_plotly/__init__.py deleted file mode 100644 index 7fabb6d..0000000 --- a/src/otko/views/canvas_plotly/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""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/bridge.py b/src/otko/views/canvas_plotly/bridge.py deleted file mode 100644 index 9dafbce..0000000 --- a/src/otko/views/canvas_plotly/bridge.py +++ /dev/null @@ -1,28 +0,0 @@ -"""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 deleted file mode 100644 index 46e145e..0000000 --- a/src/otko/views/canvas_plotly/html.py +++ /dev/null @@ -1,276 +0,0 @@ -"""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 deleted file mode 100644 index ddecb15..0000000 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ /dev/null @@ -1,417 +0,0 @@ -"""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: 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 - -import json -import math -from collections.abc import Callable -from dataclasses import replace -from typing import Any - -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 - -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 _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.""" - - 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, - 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) - 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._framing_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) - 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) - 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._framing_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._framing_dirty = True - - def reset_camera(self) -> None: - self._view_preset = "iso" - self._framing_dirty = True - self.render() - - def view_isometric(self) -> None: - self._view_preset = "iso" - 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._framing_dirty = True - self.render() - - def view_xz(self) -> None: - """Front view: the eye sits on -Y.""" - self._view_preset = "xz" - self._framing_dirty = True - self.render() - - def view_yz(self) -> None: - """Right view: the eye sits on +X.""" - self._view_preset = "yz" - self._framing_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_style(self, style: RenderStyle) -> None: - """Swap the visual style and repaint the figure. - - 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.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: - """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``) 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: - # 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._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 - 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" diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py deleted file mode 100644 index fa5fc94..0000000 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ /dev/null @@ -1,1028 +0,0 @@ -"""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, - element_family_index, - family_palette, -) - -_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 - - -@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 - #: 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: - """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 - - -#: 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)) - 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() - - 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() - 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) - 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_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": outline, - "showbackground": False, - "zeroline": outline, - "showticklabels": outline, - "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 = [ - 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", - "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"}, - # 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, - } - ) - - def _build_frames( - self, - project: Project, - data: list[dict[str, Any]], - opts: SceneOptions, - 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 - scale_colors = family_palette(self._style) - 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 - i = node_row.get(el.nodes[0]) - 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 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]) - color_index.extend([index, index, index]) - customdata.extend([el.id, el.id, None]) - if not x: - return - - 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"}, - "hovertemplate": "Element #%{customdata}", - "name": "elements", - "showlegend": False, - } - ) - - 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": self._style.support_color, - "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=self._style.nodal_load_color, - 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=self._style.element_load_color, - 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=self._style.extrusion_color, - opacity=self._style.extrusion_opacity, - 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", font_size=self._style.label_font_size - ) - ) - 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", - font_size=self._style.label_font_size, - ) - ) - - @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, font_size: int -) -> 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": 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/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index e4dff02..d2a3b45 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -34,7 +34,6 @@ 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 @@ -70,7 +69,6 @@ __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 deleted file mode 100644 index ae249ef..0000000 --- a/src/otko/views/dialogs/plot_properties.py +++ /dev/null @@ -1,150 +0,0 @@ -"""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 ( - QCheckBox, - 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] = {} - self._checks: dict[str, QCheckBox] = {} - - 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()}) - updates.update({name: w.isChecked() for name, w in self._checks.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 - 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] - 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)) - for name, check in self._checks.items(): - check.setChecked(getattr(defaults, name)) - self._emit_changed() diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index cd34449..df4b70e 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -210,14 +210,6 @@ 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( @@ -268,14 +260,6 @@ 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 @@ -380,14 +364,6 @@ 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 5241869..958459e 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -13,21 +13,16 @@ The ``projectChanged`` / ``modelMutated`` signals live on the from __future__ import annotations -import json 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, QStackedWidget +from PySide6.QtWidgets import QMainWindow, QMessageBox 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.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 from otko.views.render_controls import RenderControls @@ -39,9 +34,6 @@ from otko.views.tools import ( ToolController, ) -#: Canvas backends, in menu order. -CANVAS_BACKENDS = ("pyvista", "plotly") - class MainWindow( QMainWindow, @@ -52,7 +44,7 @@ class MainWindow( ): """Top-level application shell.""" - def __init__(self, settings: QSettings | None = None) -> None: + def __init__(self) -> None: super().__init__() self.setWindowTitle("OTKO") self.setWindowIcon( @@ -68,22 +60,12 @@ class MainWindow( self._show_node_labels = False self._show_element_labels = False - # 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() - #: 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 - # 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_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 self._build_docks() self._build_actions() @@ -95,164 +77,19 @@ class MainWindow( self._refresh_action_enablement() self.restore_layout() - # ── 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, - 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 - 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 _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.""" - 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) - 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() + # ── 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) # ── layout persistence ─────────────────────────────────────────── def save_layout(self) -> None: """Persist window geometry and dock/toolbar state for next launch.""" - self._settings.setValue("geometry", self.saveGeometry()) - self._settings.setValue("windowState", self.saveState()) + settings = QSettings("OTKO", "OTKO") + settings.setValue("geometry", self.saveGeometry()) + settings.setValue("windowState", self.saveState()) def restore_layout(self) -> None: """Restore a previously saved geometry/dock layout, if any. @@ -260,10 +97,11 @@ class MainWindow( On first run no values are stored, so the defaults from ``__init__`` (``resize`` + dock construction) stay in effect. """ - geometry = self._settings.value("geometry") + settings = QSettings("OTKO", "OTKO") + geometry = settings.value("geometry") if geometry is not None: self.restoreGeometry(geometry) - state = self._settings.value("windowState") + state = settings.value("windowState") if state is not None: self.restoreState(state) @@ -309,7 +147,6 @@ 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) @@ -324,12 +161,9 @@ 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) - # 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")) + 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) # 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 5e7a271..bd8af6a 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -192,46 +192,9 @@ 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 - - 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. @@ -516,11 +479,6 @@ 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) - 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 06db479..1d574c1 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -50,23 +50,6 @@ 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 @@ -476,7 +459,6 @@ 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) @@ -500,11 +482,9 @@ 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) - # 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_force_diagram.setEnabled(has_static) self._act_show_time_history.setEnabled(has_transient) - self._act_export_th_animation.setEnabled(has_transient and caps.animation_export) + self._act_export_th_animation.setEnabled(has_transient) 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/conftest.py b/tests/conftest.py index c91c064..82dd86a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ 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 @@ -20,27 +19,6 @@ 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.""" diff --git a/tests/gui/test_canvas_backend_switch.py b/tests/gui/test_canvas_backend_switch.py deleted file mode 100644 index ed77be1..0000000 --- a/tests/gui/test_canvas_backend_switch.py +++ /dev/null @@ -1,122 +0,0 @@ -"""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/gui/test_plot_properties.py b/tests/gui/test_plot_properties.py deleted file mode 100644 index 3ef3601..0000000 --- a/tests/gui/test_plot_properties.py +++ /dev/null @@ -1,131 +0,0 @@ -"""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() diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py deleted file mode 100644 index 78d5160..0000000 --- a/tests/gui/test_plotly_hover.py +++ /dev/null @@ -1,93 +0,0 @@ -"""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" diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py deleted file mode 100644 index 97ef3ee..0000000 --- a/tests/gui/test_plotly_view_preservation.py +++ /dev/null @@ -1,123 +0,0 @@ -"""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_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) - canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel")) - calls.clear() - - canvas.set_style(RenderStyle(show_axis_outline=True)) - - 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 - - -@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"), - ] diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py index 7b3f95c..9281e67 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -2,12 +2,11 @@ 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 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. +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. """ from __future__ import annotations @@ -17,10 +16,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SRC = ROOT / "src" / "otko" -CANVAS_DIRS = ( - SRC / "views" / "canvas3d", - SRC / "views" / "canvas_plotly", -) +CANVAS = SRC / "views" / "canvas3d" # Qt binding roots, in every spelling the project might use. QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"} @@ -36,7 +32,7 @@ CORE_FORBIDDEN = QT_ROOTS | { "matplotlib", } -# Only the canvas packages may touch a 3D or plotting backend. +# Only views/canvas3d/ may touch a 3D or plotting backend. VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"} @@ -79,14 +75,14 @@ def _violations( forbidden: set[str], *, skip: tuple[Path, ...] = (), - exclude_dirs: tuple[Path, ...] = (), + exclude_dir: Path | None = None, ) -> 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 any(_is_within(path, directory) for directory in exclude_dirs): + if exclude_dir is not None and _is_within(path, exclude_dir): continue for module in sorted(_root_imports(path) & forbidden): found.append(f"{path.relative_to(ROOT)}: {module}") @@ -116,9 +112,9 @@ def test_architecture_views_no_solver() -> None: def test_no_vtk_import_outside_canvas() -> None: - """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}" + """3D/plot backends stay under views/canvas3d/.""" + violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS) + assert violations == [], f"backend imports outside views/canvas3d/: {violations}" # The documented dependency stack; ``commands`` sits beside viewmodels (a VM diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py deleted file mode 100644 index 45b2b21..0000000 --- a/tests/unit/test_plotly_trace_builder.py +++ /dev/null @@ -1,262 +0,0 @@ -"""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.canvas3d.style import RenderStyle -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 _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") - 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: - 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"]) - - -# ── 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 deleted file mode 100644 index c179eb7..0000000 --- a/tests/unit/test_render_style.py +++ /dev/null @@ -1,117 +0,0 @@ -"""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", "bool"} - - -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 04529d6..1586ca2 100644 --- a/tests/unit/test_renderer.py +++ b/tests/unit/test_renderer.py @@ -24,7 +24,6 @@ from otko.views.canvas3d.model_renderer import ( RendererMode, _classify_support, ) -from otko.views.canvas3d.style import SELECTED_STATE # ──────────────────────────── support classification ──────────────────────────── @@ -126,13 +125,9 @@ 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] - # Frame cells carry the palette slot: the element family normally, and - # SELECTED_STATE when selected. + # Element 2 selected → it's the second frame (index 1 in frame_ids_ordered) selected_frame_idx = r._frame_id_to_row[2] - 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 + assert frame_states[selected_frame_idx] == 1 def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]