Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3878f23b3 | |||
| 1b0620a392 |
26 changed files with 1829 additions and 243 deletions
BIN
AGENTS.md
BIN
AGENTS.md
Binary file not shown.
7
NOTICE
7
NOTICE
|
|
@ -51,8 +51,11 @@ The canvas element colour palette in `src/otko/views/canvas3d/style.py`
|
||||||
(per-family element colours and the diverging response colour scale) and the
|
(per-family element colours and the diverging response colour scale) and the
|
||||||
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
|
resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were
|
||||||
adapted from the `opstool` project, which is distributed under the **GNU
|
adapted from the `opstool` project, which is distributed under the **GNU
|
||||||
General Public License v3.0**. opstool is Copyright © Yexiang Yan and
|
General Public License v3.0**. The support (boundary-condition) glyph
|
||||||
contributors.
|
geometry in `views/canvas_plotly/trace_builder.py` (`_support_loops`) is
|
||||||
|
ported and modified from opstool's `_get_bc_points_3d` / `_get_bc_points_2d`,
|
||||||
|
and the plotly response-colour and load-pattern presentation follows
|
||||||
|
opstool's plotly recipes. opstool is Copyright © Yexiang Yan and contributors.
|
||||||
|
|
||||||
In accordance with GPLv3 §5(a)/(b) this notice records that the material was
|
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
|
modified and adapted for OTKO. Combining the GPLv3-covered material with
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ correctly.
|
||||||
periods, frequencies, and participation factors per mode.
|
periods, frequencies, and participation factors per mode.
|
||||||
4. **Display → Animate Mode Shape** to view each mode. Use the mode
|
4. **Display → Animate Mode Shape** to view each mode. Use the mode
|
||||||
selector and the animation controls, and **Export…** if you want a
|
selector and the animation controls, and **Export…** if you want a
|
||||||
video of the mode shape.
|
video of the mode shape. On the Plotly backend the **Play** button runs
|
||||||
|
the animation inside the viewport (plotly frames); on PyVista it is
|
||||||
|
driven from Python.
|
||||||
5. Modal results also feed the response-spectrum case: define a response
|
5. Modal results also feed the response-spectrum case: define a response
|
||||||
spectrum, then run the SRSS or CQC combination and open
|
spectrum, then run the SRSS or CQC combination and open
|
||||||
**Display → Show Response Spectrum**.
|
**Display → Show Response Spectrum**.
|
||||||
|
|
@ -77,12 +79,20 @@ After a run, the results/report panel shows a summary for the active case
|
||||||
(static reactions and forces, modal periods, and so on). Use the display
|
(static reactions and forces, modal periods, and so on). Use the display
|
||||||
actions to inspect the model visually:
|
actions to inspect the model visually:
|
||||||
|
|
||||||
- **Display → Show Deformed Shape** — with a scale slider.
|
- **Display → Show Deformed Shape** — with a scale slider. The deformed
|
||||||
|
shape is coloured by displacement magnitude with a colourbar, and
|
||||||
|
**Display → Show Undeformed Reference** overlays the original shape for
|
||||||
|
comparison.
|
||||||
- **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment
|
- **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment
|
||||||
(M2/M3) diagrams.
|
(M2/M3) diagrams.
|
||||||
- **Display → Show Pushover Curve**, **Show Time-History**, **Show
|
- **Display → Show Pushover Curve**, **Show Time-History**, **Show
|
||||||
Hysteresis** as applicable.
|
Hysteresis** as applicable.
|
||||||
|
|
||||||
|
Nodal and distributed loads are drawn as arrows whose length scales with the
|
||||||
|
load magnitude; each load pattern gets its own colour and hovering an arrow
|
||||||
|
shows its value. Support symbols show which translation directions are
|
||||||
|
restrained.
|
||||||
|
|
||||||
To hand the analysis to someone else, or to archive exactly what was run,
|
To hand the analysis to someone else, or to archive exactly what was run,
|
||||||
export a script:
|
export a script:
|
||||||
|
|
||||||
|
|
@ -122,6 +132,23 @@ wrapped in a single macro, so one undo removes the whole step. File
|
||||||
loads, analysis runs, and display-only changes are not model mutations and
|
loads, analysis runs, and display-only changes are not model mutations and
|
||||||
are not undoable.
|
are not undoable.
|
||||||
|
|
||||||
|
## 6. Navigating the 3D view
|
||||||
|
|
||||||
|
Both canvas backends (**Options → Canvas Backend**) use the same VTK-style mouse
|
||||||
|
bindings, and **Help → Mouse Controls** lists them in the app:
|
||||||
|
|
||||||
|
- **Rotate** — left-drag.
|
||||||
|
- **Pan** — Shift + left-drag, or middle-drag.
|
||||||
|
- **Zoom** — right-drag, or the mouse wheel.
|
||||||
|
- **Spin (roll)** — Ctrl + left-drag.
|
||||||
|
- **Select** — left-click a node or element; Ctrl+click or Shift+click adds
|
||||||
|
to the selection. A drag moves the view; only a click without movement
|
||||||
|
changes the selection.
|
||||||
|
|
||||||
|
View presets: **Ctrl+1** isometric, **Ctrl+2** top (XY), **Ctrl+3** front
|
||||||
|
(XZ), **Ctrl+4** right (YZ), **Ctrl+E** zoom extents. The camera keeps Z up,
|
||||||
|
so the horizon stays level while you orbit.
|
||||||
|
|
||||||
## Where to go next
|
## Where to go next
|
||||||
|
|
||||||
- [`architecture.md`](architecture.md) — MVVM layering and command order.
|
- [`architecture.md`](architecture.md) — MVVM layering and command order.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@ class DeformationSource:
|
||||||
out[i] += self.scale * self.displacements[row]
|
out[i] += self.scale * self.displacements[row]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def magnitudes(self, node_ids: list[int]) -> np.ndarray:
|
||||||
|
"""Per-node displacement magnitude (scaled) in ``node_ids`` order.
|
||||||
|
|
||||||
|
Used to colour a deformed / modal shape by response value through
|
||||||
|
plotly's colour axis (the contour recipe adapted from opstool).
|
||||||
|
"""
|
||||||
|
out = np.zeros(len(node_ids), dtype=float)
|
||||||
|
for i, nid in enumerate(node_ids):
|
||||||
|
row = self.node_id_to_row.get(nid)
|
||||||
|
if row is not None:
|
||||||
|
out[i] = self.scale * float(np.linalg.norm(self.displacements[row]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def static_to_deformation(
|
def static_to_deformation(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ from otko.views.dialogs import (
|
||||||
MaterialLibraryDialog,
|
MaterialLibraryDialog,
|
||||||
MaterialTesterDialog,
|
MaterialTesterDialog,
|
||||||
MirrorDialog,
|
MirrorDialog,
|
||||||
|
MouseControlsDialog,
|
||||||
MoveDialog,
|
MoveDialog,
|
||||||
PathTimeSeriesDialog,
|
PathTimeSeriesDialog,
|
||||||
PatternLoadsDialog,
|
PatternLoadsDialog,
|
||||||
|
|
@ -987,6 +988,19 @@ class ActionHandlers:
|
||||||
dlg.raise_()
|
dlg.raise_()
|
||||||
dlg.activateWindow()
|
dlg.activateWindow()
|
||||||
|
|
||||||
|
def _on_mouse_controls(self) -> None:
|
||||||
|
"""Help → Mouse Controls — modeless viewport navigation reference.
|
||||||
|
|
||||||
|
Cached like the Quick Guide so re-invoking raises the same window.
|
||||||
|
"""
|
||||||
|
dlg = getattr(self, "_mouse_controls_dialog", None)
|
||||||
|
if dlg is None:
|
||||||
|
dlg = MouseControlsDialog(self) # type: ignore[arg-type]
|
||||||
|
self._mouse_controls_dialog = dlg
|
||||||
|
dlg.show()
|
||||||
|
dlg.raise_()
|
||||||
|
dlg.activateWindow()
|
||||||
|
|
||||||
def _on_set_units(self) -> None:
|
def _on_set_units(self) -> None:
|
||||||
"""Options → Set Display Units — SAP2000 parity.
|
"""Options → Set Display Units — SAP2000 parity.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,11 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||||
self._renderer.set_show_local_axes(enabled)
|
self._renderer.set_show_local_axes(enabled)
|
||||||
self.render()
|
self.render()
|
||||||
|
|
||||||
|
def set_show_undeformed(self, enabled: bool) -> None:
|
||||||
|
"""Show a faint undeformed reference behind a deformed / modal shape."""
|
||||||
|
self._renderer.set_show_undeformed(enabled)
|
||||||
|
self.render()
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
def set_style(self, style: RenderStyle) -> None:
|
||||||
"""Swap the visual style and repaint the scene.
|
"""Swap the visual style and repaint the scene.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -33,6 +32,7 @@ from otko.core import (
|
||||||
ZeroLengthElement,
|
ZeroLengthElement,
|
||||||
ZeroLengthSectionElement,
|
ZeroLengthSectionElement,
|
||||||
)
|
)
|
||||||
|
from otko.services.deformation import DeformationSource
|
||||||
from otko.views.canvas3d.style import (
|
from otko.views.canvas3d.style import (
|
||||||
SELECTED_STATE,
|
SELECTED_STATE,
|
||||||
RenderStyle,
|
RenderStyle,
|
||||||
|
|
@ -165,23 +165,6 @@ def _dof_indices(ndf: int) -> tuple[int, ...]:
|
||||||
return tuple(range(ndf))
|
return tuple(range(ndf))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeformationSource:
|
|
||||||
"""Per-node displacement vectors used to draw deformed shapes."""
|
|
||||||
|
|
||||||
displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
|
|
||||||
node_id_to_row: dict[int, int]
|
|
||||||
scale: float = 1.0
|
|
||||||
|
|
||||||
def shifted(self, original_points: np.ndarray, node_ids: list[int]) -> np.ndarray:
|
|
||||||
out = original_points.copy()
|
|
||||||
for i, nid in enumerate(node_ids):
|
|
||||||
row = self.node_id_to_row.get(nid)
|
|
||||||
if row is not None:
|
|
||||||
out[i] += self.scale * self.displacements[row]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class ModelRenderer:
|
class ModelRenderer:
|
||||||
"""Glyphed-PolyData renderer with mode-aware deformation support."""
|
"""Glyphed-PolyData renderer with mode-aware deformation support."""
|
||||||
|
|
||||||
|
|
@ -219,6 +202,8 @@ class ModelRenderer:
|
||||||
self._hover_actor: Any = None # single yellow-ring snap marker
|
self._hover_actor: Any = None # single yellow-ring snap marker
|
||||||
self._show_section_extrusions: bool = False
|
self._show_section_extrusions: bool = False
|
||||||
self._show_local_axes: bool = False
|
self._show_local_axes: bool = False
|
||||||
|
self._show_undeformed: bool = False
|
||||||
|
self._undeformed_actor: Any = None
|
||||||
# SAP2000-style working plane: when set, the grid renders ONLY
|
# SAP2000-style working plane: when set, the grid renders ONLY
|
||||||
# the lines / intersections lying on this plane so a user in
|
# the lines / intersections lying on this plane so a user in
|
||||||
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
|
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
|
||||||
|
|
@ -282,6 +267,13 @@ class ModelRenderer:
|
||||||
if self._project is not None:
|
if self._project is not None:
|
||||||
self.render(self._project)
|
self.render(self._project)
|
||||||
|
|
||||||
|
def set_show_undeformed(self, on: bool) -> None:
|
||||||
|
"""Toggle the faint undeformed-shape reference in deformed/modal views."""
|
||||||
|
if self._show_undeformed == on:
|
||||||
|
return
|
||||||
|
self._show_undeformed = on
|
||||||
|
self._refresh_undeformed_overlay()
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
def set_style(self, style: RenderStyle) -> None:
|
||||||
"""Swap the visual style and rebuild the scene it colours.
|
"""Swap the visual style and rebuild the scene it colours.
|
||||||
|
|
||||||
|
|
@ -1114,8 +1106,39 @@ class ModelRenderer:
|
||||||
if self._frame_pd is not None:
|
if self._frame_pd is not None:
|
||||||
self._frame_pd.points = new_pts
|
self._frame_pd.points = new_pts
|
||||||
self._frame_pd.Modified()
|
self._frame_pd.Modified()
|
||||||
|
self._refresh_undeformed_overlay()
|
||||||
self._rebuild_labels()
|
self._rebuild_labels()
|
||||||
|
|
||||||
|
def _refresh_undeformed_overlay(self) -> None:
|
||||||
|
"""Faint undeformed wireframe shown behind a deformed / modal shape."""
|
||||||
|
if self._undeformed_actor is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self._plotter.remove_actor(self._undeformed_actor, render=False)
|
||||||
|
self._undeformed_actor = None
|
||||||
|
if (
|
||||||
|
not self._show_undeformed
|
||||||
|
or self._mode == RendererMode.MODEL
|
||||||
|
or self._deformation is None
|
||||||
|
or self._frame_pd is None
|
||||||
|
or self._node_original_points is None
|
||||||
|
):
|
||||||
|
self._plotter.render()
|
||||||
|
return
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
ghost = pv.PolyData()
|
||||||
|
ghost.points = self._node_original_points
|
||||||
|
ghost.lines = np.asarray(self._frame_pd.lines).copy()
|
||||||
|
self._undeformed_actor = self._plotter.add_mesh(
|
||||||
|
ghost,
|
||||||
|
color="#9aa3ad",
|
||||||
|
opacity=0.45,
|
||||||
|
line_width=1,
|
||||||
|
lighting=False,
|
||||||
|
pickable=False,
|
||||||
|
)
|
||||||
|
self._plotter.render()
|
||||||
|
|
||||||
# ── helpers ─────────────────────────────────────────────────────
|
# ── helpers ─────────────────────────────────────────────────────
|
||||||
def _teardown_all(self) -> None:
|
def _teardown_all(self) -> None:
|
||||||
self._clear_label_actors()
|
self._clear_label_actors()
|
||||||
|
|
@ -1126,6 +1149,10 @@ class ModelRenderer:
|
||||||
for a in self._aux_actors:
|
for a in self._aux_actors:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
self._plotter.remove_actor(a, render=False)
|
self._plotter.remove_actor(a, render=False)
|
||||||
|
if self._undeformed_actor is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self._plotter.remove_actor(self._undeformed_actor, render=False)
|
||||||
|
self._undeformed_actor = None
|
||||||
self._node_actor = None
|
self._node_actor = None
|
||||||
self._frame_actor = None
|
self._frame_actor = None
|
||||||
self._aux_actors.clear()
|
self._aux_actors.clear()
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ class CanvasCapabilities:
|
||||||
labels: bool = True
|
labels: bool = True
|
||||||
#: Off-screen frame capture (mode-shape / time-history video export).
|
#: Off-screen frame capture (mode-shape / time-history video export).
|
||||||
animation_export: bool = True
|
animation_export: bool = True
|
||||||
|
#: In-page animation playback (plotly frames) instead of Python-driven
|
||||||
|
#: per-frame re-renders.
|
||||||
|
in_page_animation: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CanvasBackend(Protocol):
|
class CanvasBackend(Protocol):
|
||||||
|
|
@ -87,6 +90,8 @@ class CanvasBackend(Protocol):
|
||||||
|
|
||||||
def set_show_local_axes(self, enabled: bool) -> None: ...
|
def set_show_local_axes(self, enabled: bool) -> None: ...
|
||||||
|
|
||||||
|
def set_show_undeformed(self, enabled: bool) -> None: ...
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: 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: ...
|
def set_default_selection_enabled(self, enabled: bool) -> None: ...
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,21 @@ The JS side exposes three entry points to Python (called via
|
||||||
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
|
- ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets,
|
||||||
parallel-projection toggle).
|
parallel-projection toggle).
|
||||||
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
|
- ``otkoSetSnapEnabled(bool)`` — arm/disarm the hover snap-target preview.
|
||||||
|
- ``otkoAnimate(payloadJson, durationMs, loops, preserveView)`` /
|
||||||
|
``otkoStopAnimation()`` — push a pre-built frame list and let plotly play
|
||||||
|
it in-page (mode-shape / time-history animation).
|
||||||
|
|
||||||
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
|
Clicks travel the other way through the ``otkoBridge`` QWebChannel object:
|
||||||
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
|
entity picks carry the trace ``meta.kind`` and the point ``customdata``.
|
||||||
|
|
||||||
|
There is also an in-page mouse-gesture layer. plotly's built-in drag bindings
|
||||||
|
disagree with the PyVista backend's VTK trackball style (plotly's shift+left
|
||||||
|
rotates, ctrl+left pans, middle zooms), so both backends would feel different.
|
||||||
|
The scene stays in ``turntable`` mode (Z-up, level horizon) and the
|
||||||
|
non-default gestures are re-implemented in JS against the same gl-plot3d
|
||||||
|
camera object plotly exposes, matching VTK: shift+left pan, ctrl+left spin,
|
||||||
|
ctrl+shift+left / right dolly, middle pan. Plain left drag and the wheel stay
|
||||||
|
on plotly's native path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -38,6 +50,12 @@ _PAGE = """<!DOCTYPE html>
|
||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
|
html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; background: #f5f7fb; }
|
||||||
#plot { width: 100%; height: 100%; }
|
#plot { width: 100%; height: 100%; }
|
||||||
|
#otko-snap-marker {
|
||||||
|
position: fixed; display: none; width: 13px; height: 13px; z-index: 5;
|
||||||
|
margin: 0; pointer-events: none; border-radius: 50%;
|
||||||
|
background: #ffd900; border: 1px solid #8a6d00;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
.modebar { display: none !important; }
|
.modebar { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -49,7 +67,6 @@ _PAGE = """<!DOCTYPE html>
|
||||||
var bridge = null;
|
var bridge = null;
|
||||||
var snapEnabled = false;
|
var snapEnabled = false;
|
||||||
var handlersReady = false;
|
var handlersReady = false;
|
||||||
var hoverIndex = -1;
|
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
|
@ -69,44 +86,78 @@ _PAGE = """<!DOCTYPE html>
|
||||||
return data && data.meta ? data.meta.kind : null;
|
return data && data.meta ? data.meta.kind : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findHover() {
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
hoverIndex = -1;
|
|
||||||
if (!gd || !gd.data) return;
|
|
||||||
for (var i = 0; i < gd.data.length; i++) {
|
|
||||||
var meta = gd.data[i] && gd.data[i].meta;
|
|
||||||
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
|
|
||||||
}
|
|
||||||
// A freshly pushed figure has an empty hover marker again.
|
|
||||||
hoverShown = false;
|
|
||||||
hoverPoint = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- snap-target marker ------------------------------------------------
|
// --- snap-target marker ------------------------------------------------
|
||||||
// Updated synchronously but only when the visual state actually changes.
|
// A pointer-events-none DOM dot positioned by projecting the snapped world
|
||||||
// Restyling on every hover/unhover made the plot redraw per mouse move,
|
// point through gl-plot3d's own camera matrices. It deliberately is *not* a
|
||||||
// which re-fired hover and ended in a runaway redraw loop (RangeError:
|
// plotly trace: Plotly.restyle on a gl3d plot costs ~90 ms even for a single
|
||||||
// Maximum call stack size exceeded, frozen canvas). Timers were avoided
|
// trace, so a trace marker lagged far behind the cursor and stalled orbiting.
|
||||||
// deliberately: WebEngine throttles them to ~1 s when the page is not
|
// A projection plus one style write is a few microseconds.
|
||||||
// compositing, which made the preview lag.
|
var snapPoint = null;
|
||||||
var hoverShown = false;
|
var snapMarker = null;
|
||||||
var hoverPoint = null;
|
|
||||||
|
|
||||||
function samePoint(a, b) {
|
function snapMarkerElement() {
|
||||||
return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
if (!snapMarker) {
|
||||||
|
snapMarker = document.createElement('div');
|
||||||
|
snapMarker.id = 'otko-snap-marker';
|
||||||
|
document.body.appendChild(snapMarker);
|
||||||
|
}
|
||||||
|
return snapMarker;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHoverMarker(point) {
|
function mat4Multiply(a, b) {
|
||||||
var want = snapEnabled ? point : null;
|
var out = new Array(16);
|
||||||
if (samePoint(want, hoverPoint) && !!want === hoverShown) return;
|
for (var col = 0; col < 4; col++) {
|
||||||
if (!want && !hoverShown) { hoverPoint = null; return; }
|
for (var row = 0; row < 4; row++) {
|
||||||
hoverPoint = want;
|
var sum = 0;
|
||||||
hoverShown = !!want;
|
for (var k = 0; k < 4; k++) { sum += a[k * 4 + row] * b[col * 4 + k]; }
|
||||||
if (hoverIndex < 0) return;
|
out[col * 4 + row] = sum;
|
||||||
var x = want ? [want[0]] : [];
|
}
|
||||||
var y = want ? [want[1]] : [];
|
}
|
||||||
var z = want ? [want[2]] : [];
|
return out;
|
||||||
Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]);
|
}
|
||||||
|
|
||||||
|
function projectToClient(point) {
|
||||||
|
var gd = document.getElementById('plot');
|
||||||
|
var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
|
||||||
|
var params = scene && scene.glplot ? scene.glplot.cameraParams : null;
|
||||||
|
var camera = scene && scene.camera ? scene.camera : null;
|
||||||
|
if (!params || !camera) return null;
|
||||||
|
// gl-plot3d only refreshes ``params.view`` on the render loop, so after a
|
||||||
|
// camera jump (view preset / relayout) it can be a frame behind. Recompute
|
||||||
|
// the live view matrix first so the marker lands on the settled camera.
|
||||||
|
camera.view.recalcMatrix(camera.view.lastT());
|
||||||
|
var matrix = mat4Multiply(
|
||||||
|
mat4Multiply(params.projection, camera.view.computedMatrix), params.model
|
||||||
|
);
|
||||||
|
var x = point[0], y = point[1], z = point[2];
|
||||||
|
var clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||||
|
if (!isFinite(clipW) || Math.abs(clipW) < 1e-9) return null;
|
||||||
|
var clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||||
|
var clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||||
|
var canvas = scene.glplot.canvas;
|
||||||
|
var ratio = scene.glplot.pixelRatio || 1;
|
||||||
|
var localX = ((clipX / clipW) * 0.5 + 0.5) * canvas.width / ratio;
|
||||||
|
var localY = (1 - ((clipY / clipW) * 0.5 + 0.5)) * canvas.height / ratio;
|
||||||
|
if (localX < 0 || localY < 0 || localX > canvas.clientWidth || localY > canvas.clientHeight) {
|
||||||
|
return null; // off-screen target
|
||||||
|
}
|
||||||
|
var rect = canvas.getBoundingClientRect();
|
||||||
|
return [rect.left + localX, rect.top + localY];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSnapMarker(point) {
|
||||||
|
var marker = snapMarkerElement();
|
||||||
|
snapPoint = snapEnabled ? point : null;
|
||||||
|
if (!snapPoint) { marker.style.display = 'none'; return; }
|
||||||
|
var position = projectToClient(snapPoint);
|
||||||
|
if (!position) { marker.style.display = 'none'; return; }
|
||||||
|
marker.style.left = position[0] + 'px';
|
||||||
|
marker.style.top = position[1] + 'px';
|
||||||
|
marker.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSnapMarker() {
|
||||||
|
if (snapPoint) setSnapMarker(snapPoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- view preservation -------------------------------------------------
|
// --- view preservation -------------------------------------------------
|
||||||
|
|
@ -178,8 +229,8 @@ _PAGE = """<!DOCTYPE html>
|
||||||
mergeView(fig, job.preserve ? currentView() : null);
|
mergeView(fig, job.preserve ? currentView() : null);
|
||||||
|
|
||||||
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
|
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
|
||||||
findHover();
|
|
||||||
installHandlers();
|
installHandlers();
|
||||||
|
logRendererOnce();
|
||||||
}, function (err) {
|
}, function (err) {
|
||||||
if (window.console) console.error('otko: react failed', err);
|
if (window.console) console.error('otko: react failed', err);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
|
|
@ -188,6 +239,24 @@ _PAGE = """<!DOCTYPE html>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-off diagnostic: whether WebGL is hardware-accelerated decides how
|
||||||
|
// smooth orbiting feels, and it is invisible from Python otherwise.
|
||||||
|
var loggedRenderer = false;
|
||||||
|
function logRendererOnce() {
|
||||||
|
if (loggedRenderer) return;
|
||||||
|
loggedRenderer = true;
|
||||||
|
try {
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
|
if (!gl) { if (window.console) console.warn('otko: WebGL unavailable'); return; }
|
||||||
|
var info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
var name = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||||
|
if (window.console) console.warn('otko: WebGL renderer = ' + name);
|
||||||
|
} catch (err) {
|
||||||
|
if (window.console) console.warn('otko: WebGL probe failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.otkoUpdate = function (payloadJson, preserveView) {
|
window.otkoUpdate = function (payloadJson, preserveView) {
|
||||||
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
|
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
|
||||||
runUpdate();
|
runUpdate();
|
||||||
|
|
@ -218,26 +287,166 @@ _PAGE = """<!DOCTYPE html>
|
||||||
var pts = ev.points || [];
|
var pts = ev.points || [];
|
||||||
if (!pts.length) return;
|
if (!pts.length) return;
|
||||||
var p = pts[0];
|
var p = pts[0];
|
||||||
if (kindOf(p) !== 'snap') { setHoverMarker(null); return; }
|
if (kindOf(p) !== 'snap') { setSnapMarker(null); return; }
|
||||||
var c = p.customdata;
|
var c = p.customdata;
|
||||||
if (c) setHoverMarker([c[0], c[1], c[2]]);
|
if (c) setSnapMarker([c[0], c[1], c[2]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Orbit/zoom moves the world point on screen: re-project so the marker
|
||||||
|
// stays glued to the target without a plotly redraw.
|
||||||
|
gd.on('plotly_relayout', function () { refreshSnapMarker(); });
|
||||||
|
|
||||||
gd.on('plotly_unhover', function () {
|
gd.on('plotly_unhover', function () {
|
||||||
// Nothing can be visible unless snapping is armed, so stay out of the
|
// Nothing can be visible unless snapping is armed, so stay out of the
|
||||||
// redraw path entirely for ordinary mouse movement.
|
// redraw path entirely for ordinary mouse movement.
|
||||||
if (!snapEnabled) return;
|
if (!snapEnabled) return;
|
||||||
setHoverMarker(null);
|
setSnapMarker(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
installGestureHandlers(gd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mouse gestures (VTK/PyVista parity) -------------------------------
|
||||||
|
// plotly's built-in drag bindings clash with the PyVista backend's VTK
|
||||||
|
// trackball style, so both backends would feel different. We keep the
|
||||||
|
// turntable scene (Z-up, level horizon) and re-implement the non-default
|
||||||
|
// gestures, driving the same gl-plot3d camera object plotly exposes. Plain
|
||||||
|
// left drag and the wheel stay on plotly's native path.
|
||||||
|
var gesture = null;
|
||||||
|
var gesturesReady = false;
|
||||||
|
|
||||||
|
function plotCamera() {
|
||||||
|
var gd = document.getElementById('plot');
|
||||||
|
var scene = gd && gd._fullLayout ? gd._fullLayout.scene._scene : null;
|
||||||
|
return scene && scene.camera ? scene.camera : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMs() {
|
||||||
|
return window.performance && window.performance.now ? window.performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gestureModeFor(button, mods) {
|
||||||
|
// button: 0 left, 1 middle, 2 right.
|
||||||
|
if (button === 1) return 'pan';
|
||||||
|
if (button === 2) return mods.shift ? 'rotate' : 'zoom';
|
||||||
|
if (button !== 0) return null;
|
||||||
|
if (mods.ctrl && mods.shift) return 'zoom';
|
||||||
|
if (mods.ctrl) return 'roll'; // VTK spin
|
||||||
|
if (mods.shift) return 'pan';
|
||||||
|
if (mods.alt) return 'rotate'; // VTK ignores Alt: falls back to rotate
|
||||||
|
return null; // plain left drag -> plotly's native rotate
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGesture(camera, mode, dx, dy) {
|
||||||
|
// Mirror the maths gl-plot3d uses for its own mouse handler so the feel
|
||||||
|
// matches; dx/dy are normalised by the viewport height.
|
||||||
|
var view = camera.view;
|
||||||
|
var t = nowMs();
|
||||||
|
var distance = Math.exp(view.computedRadius[0]);
|
||||||
|
var speed = camera.translateSpeed || 1;
|
||||||
|
var drot = Math.PI * (camera.rotateSpeed || 1);
|
||||||
|
if (mode === 'rotate') {
|
||||||
|
view.rotate(t, -drot * dx, drot * dy, 0);
|
||||||
|
} else if (mode === 'pan') {
|
||||||
|
view.pan(t, -speed * dx * distance, speed * dy * distance, 0);
|
||||||
|
} else if (mode === 'zoom') {
|
||||||
|
view.pan(t, 0, 0, distance * (Math.exp(-3.0 * dy) - 1));
|
||||||
|
} else if (mode === 'roll') {
|
||||||
|
view.rotate(t, 0, 0, drot * dx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGestureHandlers(gd) {
|
||||||
|
if (gesturesReady) return;
|
||||||
|
gesturesReady = true;
|
||||||
|
|
||||||
|
// Capture phase: run before plotly's own (bubble-phase) camera listener
|
||||||
|
// on the scene container, so keyBindingMode is already off when it sees
|
||||||
|
// the event and it no-ops for this gesture.
|
||||||
|
gd.addEventListener('mousedown', function (ev) {
|
||||||
|
if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
|
||||||
|
var mode = gestureModeFor(ev.button, {
|
||||||
|
ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey
|
||||||
|
});
|
||||||
|
if (!mode) return;
|
||||||
|
var camera = plotCamera();
|
||||||
|
if (!camera) return;
|
||||||
|
camera.keyBindingMode = false;
|
||||||
|
gesture = { mode: mode, x: ev.clientX, y: ev.clientY };
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', function (ev) {
|
||||||
|
if (!gesture) return;
|
||||||
|
var camera = plotCamera();
|
||||||
|
if (!camera) { gesture = null; return; }
|
||||||
|
// A Plotly.react mid-gesture re-enables plotly's handler; keep it off.
|
||||||
|
camera.keyBindingMode = false;
|
||||||
|
var height = camera.element.clientHeight || 1;
|
||||||
|
var dx = (ev.clientX - gesture.x) / height;
|
||||||
|
var dy = (ev.clientY - gesture.y) / height;
|
||||||
|
gesture.x = ev.clientX;
|
||||||
|
gesture.y = ev.clientY;
|
||||||
|
applyGesture(camera, gesture.mode, dx, dy);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', function () {
|
||||||
|
if (!gesture) return;
|
||||||
|
var camera = plotCamera();
|
||||||
|
gesture = null;
|
||||||
|
// Restore plotly's rotate binding. Its mouseup runs with buttons === 0,
|
||||||
|
// so it only refreshes its bookkeeping and never jumps the camera.
|
||||||
|
if (camera) camera.keyBindingMode = 'rotate';
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.otkoSetCamera = function (cameraJson) {
|
window.otkoSetCamera = function (cameraJson) {
|
||||||
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
|
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- frame animation ---------------------------------------------------
|
||||||
|
// A pre-built frame list is pushed once and played by plotly itself, so a
|
||||||
|
// mode shape / time history animates in-page instead of re-reacting the
|
||||||
|
// whole figure ~30x per second from Python.
|
||||||
|
window.otkoAnimate = function (payloadJson, durationMs, loops, preserveView) {
|
||||||
|
var payload;
|
||||||
|
try { payload = JSON.parse(payloadJson); } catch (err) {
|
||||||
|
if (window.console) console.error('otko: bad animation payload', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingUpdate = null; // the animation owns the graph div
|
||||||
|
var names = payload.frames.map(function (frame, index) {
|
||||||
|
frame.name = 'otko-' + index;
|
||||||
|
return frame.name;
|
||||||
|
});
|
||||||
|
var sequence = [];
|
||||||
|
var repeat = Math.max(1, loops || 1);
|
||||||
|
for (var i = 0; i < repeat; i++) { sequence = sequence.concat(names); }
|
||||||
|
mergeView(payload, preserveView ? currentView() : null);
|
||||||
|
Plotly.react('plot', payload.data, payload.layout, config).then(function () {
|
||||||
|
return Plotly.addFrames('plot', payload.frames);
|
||||||
|
}).then(function () {
|
||||||
|
return Plotly.animate('plot', sequence, {
|
||||||
|
frame: { duration: durationMs, redraw: true },
|
||||||
|
transition: { duration: 0 },
|
||||||
|
fromcurrent: false,
|
||||||
|
mode: 'immediate'
|
||||||
|
});
|
||||||
|
}).then(function () {
|
||||||
|
installHandlers();
|
||||||
|
}, function (err) {
|
||||||
|
// An interrupt (a newer animate call, or otkoStopAnimation) rejects with
|
||||||
|
// no reason; only surface real failures.
|
||||||
|
if (err && window.console) console.error('otko: animate failed', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.otkoStopAnimation = function () {
|
||||||
|
Plotly.animate('plot', [], { mode: 'immediate', transition: { duration: 0 } });
|
||||||
|
};
|
||||||
|
|
||||||
window.otkoSetSnapEnabled = function (on) {
|
window.otkoSetSnapEnabled = function (on) {
|
||||||
snapEnabled = !!on;
|
snapEnabled = !!on;
|
||||||
if (!snapEnabled) setHoverMarker(null);
|
if (!snapEnabled) setSnapMarker(null);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from otko.views.canvas_plotly.trace_builder import (
|
||||||
PlotlyTraceBuilder,
|
PlotlyTraceBuilder,
|
||||||
Scene,
|
Scene,
|
||||||
SceneOptions,
|
SceneOptions,
|
||||||
|
framed_camera_distance,
|
||||||
)
|
)
|
||||||
|
|
||||||
#: View preset directions (unit-ish vectors from the scene centre to the eye).
|
#: View preset directions (unit-ish vectors from the scene centre to the eye).
|
||||||
|
|
@ -149,7 +150,9 @@ class PlotlyCanvas(QWidget):
|
||||||
|
|
||||||
#: Force diagrams and off-screen video capture are not implemented on
|
#: Force diagrams and off-screen video capture are not implemented on
|
||||||
#: this backend yet (both are PyVista-specific today).
|
#: this backend yet (both are PyVista-specific today).
|
||||||
capabilities = CanvasCapabilities(diagrams=False, animation_export=False)
|
capabilities = CanvasCapabilities(
|
||||||
|
diagrams=False, animation_export=False, in_page_animation=True
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -170,6 +173,7 @@ class PlotlyCanvas(QWidget):
|
||||||
self._parallel = False
|
self._parallel = False
|
||||||
self._view_preset = "iso"
|
self._view_preset = "iso"
|
||||||
self._snap_enabled = False
|
self._snap_enabled = False
|
||||||
|
self._show_undeformed = False
|
||||||
self._default_selection_enabled = True
|
self._default_selection_enabled = True
|
||||||
self._working_plane: tuple[str, float] | None = None
|
self._working_plane: tuple[str, float] | None = None
|
||||||
self._camera = _CameraShim(self)
|
self._camera = _CameraShim(self)
|
||||||
|
|
@ -308,6 +312,45 @@ class PlotlyCanvas(QWidget):
|
||||||
self._options = replace(self._options, show_local_axes=bool(enabled))
|
self._options = replace(self._options, show_local_axes=bool(enabled))
|
||||||
self.render()
|
self.render()
|
||||||
|
|
||||||
|
def set_show_undeformed(self, enabled: bool) -> None:
|
||||||
|
"""Faint undeformed reference behind a deformed / modal shape."""
|
||||||
|
self._show_undeformed = bool(enabled)
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def base_scene_options(self) -> SceneOptions:
|
||||||
|
"""The current display options, for callers building animation frames."""
|
||||||
|
return replace(self._options, show_undeformed=self._show_undeformed)
|
||||||
|
|
||||||
|
def animate(
|
||||||
|
self,
|
||||||
|
options: list[SceneOptions],
|
||||||
|
*,
|
||||||
|
duration_ms: int = 40,
|
||||||
|
loops: int = 100,
|
||||||
|
) -> None:
|
||||||
|
"""Play a pre-built list of scenes as an in-page plotly animation.
|
||||||
|
|
||||||
|
Each entry's traces are built once and handed to plotly's frame
|
||||||
|
machinery, so playback does not round-trip through Python per frame.
|
||||||
|
"""
|
||||||
|
if not self._ready or not options:
|
||||||
|
return
|
||||||
|
frames = [self._builder.build(self._project, opts).data for opts in options]
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"data": frames[0],
|
||||||
|
"frames": [{"data": frame} for frame in frames],
|
||||||
|
"layout": dict(self._scene.layout),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._eval(
|
||||||
|
f"window.otkoAnimate({json.dumps(payload)}, {int(duration_ms)}, {int(loops)}, true)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop_animation(self) -> None:
|
||||||
|
"""Stop any in-page plotly animation."""
|
||||||
|
self._eval("window.otkoStopAnimation()")
|
||||||
|
|
||||||
def set_style(self, style: RenderStyle) -> None:
|
def set_style(self, style: RenderStyle) -> None:
|
||||||
"""Swap the visual style and repaint the figure.
|
"""Swap the visual style and repaint the figure.
|
||||||
|
|
||||||
|
|
@ -344,7 +387,10 @@ class PlotlyCanvas(QWidget):
|
||||||
``reset_camera``) sends the computed framing. Style changes are
|
``reset_camera``) sends the computed framing. Style changes are
|
||||||
non-framing: they re-colour in place.
|
non-framing: they re-colour in place.
|
||||||
"""
|
"""
|
||||||
self._scene = self._builder.build(self._project, self._options)
|
self._scene = self._builder.build(
|
||||||
|
self._project,
|
||||||
|
replace(self._options, show_undeformed=self._show_undeformed),
|
||||||
|
)
|
||||||
if not self._ready:
|
if not self._ready:
|
||||||
# Nothing to push yet; crucially this must come *before* the
|
# Nothing to push yet; crucially this must come *before* the
|
||||||
# framing flag is consumed, or the framing scheduled before
|
# framing flag is consumed, or the framing scheduled before
|
||||||
|
|
@ -363,20 +409,31 @@ class PlotlyCanvas(QWidget):
|
||||||
self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
|
self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})")
|
||||||
|
|
||||||
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
|
def _camera_dict(self, scene: Scene) -> dict[str, Any]:
|
||||||
cx, cy, cz = scene.center
|
"""A framing camera in plotly's normalized scene coordinates.
|
||||||
distance = max(scene.diagonal, 1e-6) * 1.6
|
|
||||||
|
plotly does not use data units for ``scene.camera``: gl-plot3d scales
|
||||||
|
the scene box by the trace extents and re-centres it on the origin, so
|
||||||
|
the camera lives in a normalized space (the origin is the model centre
|
||||||
|
and the default eye is only ~1.25 away). Passing a data-unit eye — the
|
||||||
|
old ``diagonal * 1.6`` — parked the camera hundreds of normalized units
|
||||||
|
out and rendered the model as a speck. So: centre on the origin, and
|
||||||
|
take the eye distance from the model's *normalized* bounding sphere.
|
||||||
|
"""
|
||||||
|
distance = framed_camera_distance(scene)
|
||||||
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
|
direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"])
|
||||||
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
|
norm = math.sqrt(sum(component * component for component in direction)) or 1.0
|
||||||
eye = (
|
eye = (
|
||||||
cx + direction[0] / norm * distance,
|
direction[0] / norm * distance,
|
||||||
cy + direction[1] / norm * distance,
|
direction[1] / norm * distance,
|
||||||
cz + direction[2] / norm * distance,
|
direction[2] / norm * distance,
|
||||||
)
|
)
|
||||||
# Looking straight down the Z axis needs a non-degenerate up vector.
|
# Turntable dragmode pins up to +Z; the turntable controller derives a
|
||||||
up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0)
|
# well-defined screen-up from the view angle even for the straight-down
|
||||||
|
# Top (XY) preset, so no special-case up vector is needed.
|
||||||
|
up = (0.0, 0.0, 1.0)
|
||||||
return {
|
return {
|
||||||
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
|
"eye": {"x": eye[0], "y": eye[1], "z": eye[2]},
|
||||||
"center": {"x": cx, "y": cy, "z": cz},
|
"center": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
"up": {"x": up[0], "y": up[1], "z": up[2]},
|
"up": {"x": up[0], "y": up[1], "z": up[2]},
|
||||||
"projection": {"type": "orthographic" if self._parallel else "perspective"},
|
"projection": {"type": "orthographic" if self._parallel else "perspective"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
import math
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from itertools import pairwise
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -76,7 +78,7 @@ _BOX_TRIS = (
|
||||||
(1, 6, 5),
|
(1, 6, 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Support kind → plotly 3D marker symbol.
|
#: Support kind → plotly 3D marker symbol (fallback for rotation-only supports).
|
||||||
_SUPPORT_SYMBOLS = {
|
_SUPPORT_SYMBOLS = {
|
||||||
"fix": "square",
|
"fix": "square",
|
||||||
"pin": "triangle-up",
|
"pin": "triangle-up",
|
||||||
|
|
@ -88,6 +90,18 @@ _NODE_MARKER_SIZE = 7.0
|
||||||
_SUPPORT_MARKER_SIZE = 11.0
|
_SUPPORT_MARKER_SIZE = 11.0
|
||||||
_SNAP_MARKER_SIZE = 8.0
|
_SNAP_MARKER_SIZE = 8.0
|
||||||
|
|
||||||
|
#: Distinct colours cycled across load patterns (opstool tints each pattern).
|
||||||
|
_PATTERN_PALETTE = (
|
||||||
|
"#1f77b4",
|
||||||
|
"#ff7f0e",
|
||||||
|
"#2ca02c",
|
||||||
|
"#d62728",
|
||||||
|
"#9467bd",
|
||||||
|
"#8c564b",
|
||||||
|
"#e377c2",
|
||||||
|
"#17becf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SceneOptions:
|
class SceneOptions:
|
||||||
|
|
@ -102,6 +116,13 @@ class SceneOptions:
|
||||||
show_element_labels: bool = False
|
show_element_labels: bool = False
|
||||||
show_extrusions: bool = False
|
show_extrusions: bool = False
|
||||||
show_local_axes: bool = False
|
show_local_axes: bool = False
|
||||||
|
#: Draw the undeformed shape faintly behind a deformed / modal shape.
|
||||||
|
show_undeformed: bool = False
|
||||||
|
#: Per-node scalar response (aligned with ``project.nodes``), coloured
|
||||||
|
#: through a shared colour axis with a colorbar — opstool's contour recipe.
|
||||||
|
scalars: Any = None
|
||||||
|
scalar_label: str = ""
|
||||||
|
scalar_clim: tuple[float, float] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -112,10 +133,11 @@ class Scene:
|
||||||
layout: dict[str, Any]
|
layout: dict[str, Any]
|
||||||
center: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
center: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
diagonal: float = 1.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.
|
#: Padded ``(min, max)`` per axis, used to frame the camera deterministically.
|
||||||
axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
|
axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
|
||||||
|
#: Unpadded model ``(min, max)`` per axis (no grid), used to normalise the
|
||||||
|
#: camera the same way plotly normalises the scene box.
|
||||||
|
data_bounds: dict[str, tuple[float, float]] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_payload(self) -> dict[str, Any]:
|
def to_payload(self) -> dict[str, Any]:
|
||||||
"""Figure dict without the camera — camera is owned by the widget."""
|
"""Figure dict without the camera — camera is owned by the widget."""
|
||||||
|
|
@ -215,6 +237,54 @@ def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float:
|
||||||
return diagonal if diagonal > 0 else 1.0
|
return diagonal if diagonal > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_axis_bounds(pts: np.ndarray | None) -> dict[str, tuple[float, float]]:
|
||||||
|
"""Unpadded model ``(min, max)`` per axis.
|
||||||
|
|
||||||
|
plotly scales the scene box by the *trace* extents, so the camera frame
|
||||||
|
distance has to be computed from these (grid-free, unpadded) bounds.
|
||||||
|
Degenerate axes keep a zero extent; callers substitute a unit scale.
|
||||||
|
"""
|
||||||
|
if pts is None or len(pts) == 0:
|
||||||
|
return {name: (0.0, 0.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)
|
||||||
|
return {
|
||||||
|
name: (float(lower[index]), float(upper[index]))
|
||||||
|
for index, name in enumerate(("x", "y", "z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: gl-plot3d's fixed vertical field of view (radians).
|
||||||
|
_FOV_Y = math.pi / 4
|
||||||
|
#: How much of the viewport the model's bounding sphere should span.
|
||||||
|
_FRAME_MARGIN = 1.25
|
||||||
|
|
||||||
|
|
||||||
|
def framed_camera_distance(scene: Scene) -> float:
|
||||||
|
"""Eye distance, in plotly's normalized scene units, that fits the model.
|
||||||
|
|
||||||
|
``layout.scene.camera`` is not in data units: gl-plot3d re-centres the
|
||||||
|
scene on the origin and, with ``aspectmode`` ``data``, scales each axis by
|
||||||
|
``product(data_scale) ** (1/3) / data_scale`` (plotly's ``scene.js``) — so
|
||||||
|
the default eye is only ~1.25 away. Mirror that to get the model's
|
||||||
|
bounding-sphere radius in the camera's own units, then back off by half the
|
||||||
|
field of view so the model fills the viewport with a small margin.
|
||||||
|
"""
|
||||||
|
bounds = scene.data_bounds or scene.axis_bounds
|
||||||
|
if len(bounds) < 3:
|
||||||
|
return 1.6
|
||||||
|
scales = []
|
||||||
|
for low, high in bounds.values():
|
||||||
|
extent = high - low
|
||||||
|
scales.append(1.0 / extent if extent > 1e-12 else 1.0)
|
||||||
|
axis_scale = (scales[0] * scales[1] * scales[2]) ** (1.0 / 3.0)
|
||||||
|
aspect = [axis_scale / scale for scale in scales]
|
||||||
|
radius = 0.5 * math.sqrt(sum(value * value for value in aspect))
|
||||||
|
if radius <= 1e-12:
|
||||||
|
return 1.6
|
||||||
|
return radius / math.sin(_FOV_Y / 2.0) * _FRAME_MARGIN
|
||||||
|
|
||||||
|
|
||||||
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
def _frame_basis(el: Any, x_local: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
"""Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``."""
|
||||||
x = x_local / float(np.linalg.norm(x_local))
|
x = x_local / float(np.linalg.norm(x_local))
|
||||||
|
|
@ -264,6 +334,104 @@ def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) ->
|
||||||
return "custom"
|
return "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar_clim(scalars: Any, clim: tuple[float, float] | None) -> tuple[float, float]:
|
||||||
|
"""Colour limits for a scalar array, falling back to its finite range."""
|
||||||
|
if clim is not None:
|
||||||
|
return float(clim[0]), float(clim[1])
|
||||||
|
values = np.asarray(scalars, dtype=float).ravel()
|
||||||
|
values = values[np.isfinite(values)]
|
||||||
|
if not len(values):
|
||||||
|
return 0.0, 1.0
|
||||||
|
low, high = float(values.min()), float(values.max())
|
||||||
|
if high <= low:
|
||||||
|
high = low + 1.0
|
||||||
|
return low, high
|
||||||
|
|
||||||
|
|
||||||
|
def _support_loops(
|
||||||
|
coord: tuple[float, float, float],
|
||||||
|
restraint: tuple[bool, ...],
|
||||||
|
size: float,
|
||||||
|
ndm: int,
|
||||||
|
) -> list[list[tuple[float, float, float]]]:
|
||||||
|
"""Closed polylines for a support's restrained translation DOFs.
|
||||||
|
|
||||||
|
Adapted from opstool's ``_get_bc_points_3d`` / ``_get_bc_points_2d``
|
||||||
|
(GPL-3.0; see ``NOTICE``). Each fixed translation axis gets a plate
|
||||||
|
perpendicular to it; a 2D roller gets a circle. Returns an empty list when
|
||||||
|
only rotations are restrained (the caller falls back to a marker).
|
||||||
|
"""
|
||||||
|
x, y, z = coord
|
||||||
|
s = size
|
||||||
|
loops: list[list[tuple[float, float, float]]] = []
|
||||||
|
if ndm >= 3:
|
||||||
|
if restraint[0]:
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(x, y - s / 2, z - s / 2),
|
||||||
|
(x, y + s / 2, z - s / 2),
|
||||||
|
(x, y + s / 2, z + s / 2),
|
||||||
|
(x, y - s / 2, z + s / 2),
|
||||||
|
(x, y - s / 2, z - s / 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if restraint[1]:
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(x - s / 2, y, z - s / 2),
|
||||||
|
(x + s / 2, y, z - s / 2),
|
||||||
|
(x + s / 2, y, z + s / 2),
|
||||||
|
(x - s / 2, y, z + s / 2),
|
||||||
|
(x - s / 2, y, z - s / 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if restraint[2]:
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(x - s / 2, y - s / 2, z),
|
||||||
|
(x + s / 2, y - s / 2, z),
|
||||||
|
(x + s / 2, y + s / 2, z),
|
||||||
|
(x - s / 2, y + s / 2, z),
|
||||||
|
(x - s / 2, y - s / 2, z),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif restraint[2]:
|
||||||
|
yb = y - s / 2
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(x - s / 2, yb - s / 2, z),
|
||||||
|
(x + s / 2, yb - s / 2, z),
|
||||||
|
(x + s / 2, yb + s / 2, z),
|
||||||
|
(x - s / 2, yb + s / 2, z),
|
||||||
|
(x - s / 2, yb - s / 2, z),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif restraint[0] and restraint[1]:
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(x - s * 0.5, y - s, z),
|
||||||
|
(x + s * 0.5, y - s, z),
|
||||||
|
(x, y, z),
|
||||||
|
(x - s * 0.5, y - s, z),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif restraint[0] or restraint[1]:
|
||||||
|
angles = np.linspace(0.0, 2.0 * np.pi, 17)
|
||||||
|
ox = x - s / 2 if restraint[0] else x
|
||||||
|
oy = y - s / 2 if restraint[1] else y
|
||||||
|
loops.append(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
float(ox + 0.5 * s * math.cos(angle)),
|
||||||
|
float(oy + 0.5 * s * math.sin(angle)),
|
||||||
|
z,
|
||||||
|
)
|
||||||
|
for angle in angles
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return loops
|
||||||
|
|
||||||
|
|
||||||
def _line_trace(
|
def _line_trace(
|
||||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -320,12 +488,28 @@ class PlotlyTraceBuilder:
|
||||||
nodes = list(project.nodes)
|
nodes = list(project.nodes)
|
||||||
node_ids = [n.id for n in 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))
|
points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3))
|
||||||
|
original_points = points.copy()
|
||||||
if opts.deformation is not None and len(points):
|
if opts.deformation is not None and len(points):
|
||||||
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
|
points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float)
|
||||||
node_row = {nid: i for i, nid in enumerate(node_ids)}
|
node_row = {nid: i for i, nid in enumerate(node_ids)}
|
||||||
|
|
||||||
|
# Response colouring: auto-derive a per-node scalar from the deformation
|
||||||
|
# source (displacement magnitude) unless the caller supplied one.
|
||||||
|
if opts.scalars is None and opts.deformation is not None:
|
||||||
|
get_magnitudes = getattr(opts.deformation, "magnitudes", None)
|
||||||
|
if callable(get_magnitudes) and len(node_ids):
|
||||||
|
opts = replace(
|
||||||
|
opts,
|
||||||
|
scalars=get_magnitudes(node_ids),
|
||||||
|
scalar_label=opts.scalar_label or "|u|",
|
||||||
|
)
|
||||||
|
elif opts.scalars is not None:
|
||||||
|
opts = replace(opts, scalars=np.asarray(opts.scalars, dtype=float))
|
||||||
|
|
||||||
data: list[dict[str, Any]] = []
|
data: list[dict[str, Any]] = []
|
||||||
grid_pts = self._build_grid(project, data, opts)
|
grid_pts = self._build_grid(project, data, opts)
|
||||||
|
if opts.show_undeformed and opts.deformation is not None and len(points):
|
||||||
|
self._build_undeformed_reference(project, data, original_points, node_row)
|
||||||
self._build_extrusions(project, data, opts)
|
self._build_extrusions(project, data, opts)
|
||||||
self._build_local_axes(project, data, opts)
|
self._build_local_axes(project, data, opts)
|
||||||
self._build_loads(project, data, opts)
|
self._build_loads(project, data, opts)
|
||||||
|
|
@ -333,21 +517,25 @@ class PlotlyTraceBuilder:
|
||||||
self._build_frames(project, data, opts, points, node_row)
|
self._build_frames(project, data, opts, points, node_row)
|
||||||
self._build_nodes(data, opts, points, node_ids)
|
self._build_nodes(data, opts, points, node_ids)
|
||||||
self._build_labels(project, data, opts, points, node_row)
|
self._build_labels(project, data, opts, points, node_row)
|
||||||
hover_trace = self._build_hover_marker(data)
|
|
||||||
|
|
||||||
candidates = [points] if len(points) else []
|
# Frame the structure, not the (often much larger) reference grid:
|
||||||
if grid_pts is not None:
|
# SAP-style "zoom extents" fits the model. The grid still draws, but
|
||||||
candidates.append(grid_pts)
|
# does not shrink the model into a corner of the viewport.
|
||||||
all_pts = np.vstack(candidates) if candidates else np.empty((0, 3))
|
if len(points):
|
||||||
center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0)
|
frame_pts = points
|
||||||
bounds = _padded_axis_bounds(all_pts)
|
elif grid_pts is not None:
|
||||||
|
frame_pts = grid_pts
|
||||||
|
else:
|
||||||
|
frame_pts = np.empty((0, 3))
|
||||||
|
center = tuple(np.mean(frame_pts, axis=0)) if len(frame_pts) else (0.0, 0.0, 0.0)
|
||||||
|
bounds = _padded_axis_bounds(frame_pts)
|
||||||
return Scene(
|
return Scene(
|
||||||
data=data,
|
data=data,
|
||||||
layout=self._layout(),
|
layout=self._layout(),
|
||||||
center=(float(center[0]), float(center[1]), float(center[2])),
|
center=(float(center[0]), float(center[1]), float(center[2])),
|
||||||
diagonal=_diag_of_bounds(bounds),
|
diagonal=_diag_of_bounds(bounds),
|
||||||
hover_trace=hover_trace,
|
|
||||||
axis_bounds=bounds,
|
axis_bounds=bounds,
|
||||||
|
data_bounds=_raw_axis_bounds(frame_pts),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── layout ───────────────────────────────────────────────────────
|
# ── layout ───────────────────────────────────────────────────────
|
||||||
|
|
@ -380,7 +568,10 @@ class PlotlyTraceBuilder:
|
||||||
"scene": {
|
"scene": {
|
||||||
"bgcolor": style.background_bottom,
|
"bgcolor": style.background_bottom,
|
||||||
"aspectmode": "data",
|
"aspectmode": "data",
|
||||||
"dragmode": "orbit",
|
# Turntable (plotly's CAD-style orbit) locks ``camera.up`` to
|
||||||
|
# +Z, so the horizon stays level. plotly's ``orbit`` mode
|
||||||
|
# rotates the up vector instead and tips the model over.
|
||||||
|
"dragmode": "turntable",
|
||||||
"xaxis": axis(style.fix_color, "X"),
|
"xaxis": axis(style.fix_color, "X"),
|
||||||
"yaxis": axis(style.load_color, "Y"),
|
"yaxis": axis(style.load_color, "Y"),
|
||||||
"zaxis": axis(style.truss_color, "Z"),
|
"zaxis": axis(style.truss_color, "Z"),
|
||||||
|
|
@ -530,12 +721,41 @@ class PlotlyTraceBuilder:
|
||||||
) -> None:
|
) -> None:
|
||||||
if not len(points):
|
if not len(points):
|
||||||
return
|
return
|
||||||
colors = [
|
scalars = opts.scalars
|
||||||
self._style.node_selected_color
|
if scalars is not None and len(scalars) == len(points):
|
||||||
if nid in opts.selection_nodes
|
low, high = _scalar_clim(scalars, opts.scalar_clim)
|
||||||
else self._style.node_color
|
marker: dict[str, Any] = {
|
||||||
for nid in node_ids
|
"color": [float(value) for value in scalars],
|
||||||
]
|
"colorscale": self._style.response_colorscale(),
|
||||||
|
"cmin": low,
|
||||||
|
"cmax": high,
|
||||||
|
"size": _NODE_MARKER_SIZE,
|
||||||
|
"line": {"color": "#4d4d4d", "width": 1},
|
||||||
|
"showscale": True,
|
||||||
|
"colorbar": {
|
||||||
|
"title": {"text": opts.scalar_label or "value", "side": "right"},
|
||||||
|
"thickness": 14,
|
||||||
|
"len": 0.6,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
customdata: list[Any] = [
|
||||||
|
[int(nid), float(value)] for nid, value in zip(node_ids, scalars, strict=True)
|
||||||
|
]
|
||||||
|
hovertemplate = "Node #%{customdata[0]}<br>%{customdata[1]:.4g}<extra></extra>"
|
||||||
|
else:
|
||||||
|
colors = [
|
||||||
|
self._style.node_selected_color
|
||||||
|
if nid in opts.selection_nodes
|
||||||
|
else self._style.node_color
|
||||||
|
for nid in node_ids
|
||||||
|
]
|
||||||
|
marker = {
|
||||||
|
"color": colors,
|
||||||
|
"size": _NODE_MARKER_SIZE,
|
||||||
|
"line": {"color": "#4d4d4d", "width": 1},
|
||||||
|
}
|
||||||
|
customdata = list(node_ids)
|
||||||
|
hovertemplate = "Node #%{customdata}<extra></extra>"
|
||||||
data.append(
|
data.append(
|
||||||
{
|
{
|
||||||
"type": "scatter3d",
|
"type": "scatter3d",
|
||||||
|
|
@ -543,16 +763,12 @@ class PlotlyTraceBuilder:
|
||||||
"x": [float(p[0]) for p in points],
|
"x": [float(p[0]) for p in points],
|
||||||
"y": [float(p[1]) for p in points],
|
"y": [float(p[1]) for p in points],
|
||||||
"z": [float(p[2]) for p in points],
|
"z": [float(p[2]) for p in points],
|
||||||
"marker": {
|
"marker": marker,
|
||||||
"color": colors,
|
"customdata": customdata,
|
||||||
"size": _NODE_MARKER_SIZE,
|
|
||||||
"line": {"color": "#4d4d4d", "width": 1},
|
|
||||||
},
|
|
||||||
"customdata": list(node_ids),
|
|
||||||
"meta": {"kind": "node"},
|
"meta": {"kind": "node"},
|
||||||
# Engineering-notation hover (opstool's trace recipe) so a
|
# Engineering-notation hover (opstool's trace recipe) so a
|
||||||
# hover identifies the entity instead of showing the raw id.
|
# hover identifies the entity instead of showing the raw id.
|
||||||
"hovertemplate": "Node #%{customdata}<extra></extra>",
|
"hovertemplate": hovertemplate,
|
||||||
"name": "nodes",
|
"name": "nodes",
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
|
|
@ -577,13 +793,16 @@ class PlotlyTraceBuilder:
|
||||||
"""
|
"""
|
||||||
if not len(points):
|
if not len(points):
|
||||||
return
|
return
|
||||||
|
scalar_values: np.ndarray | None = None
|
||||||
|
if opts.scalars is not None and len(opts.scalars) == len(points):
|
||||||
|
scalar_values = np.asarray(opts.scalars, dtype=float)
|
||||||
scale_colors = family_palette(self._style)
|
scale_colors = family_palette(self._style)
|
||||||
selected_index = len(scale_colors) - 1
|
selected_index = len(scale_colors) - 1
|
||||||
|
|
||||||
x: list[float | None] = []
|
x: list[float | None] = []
|
||||||
y: list[float | None] = []
|
y: list[float | None] = []
|
||||||
z: list[float | None] = []
|
z: list[float | None] = []
|
||||||
color_index: list[float] = []
|
color_value: list[float | None] = []
|
||||||
customdata: list[Any] = []
|
customdata: list[Any] = []
|
||||||
for el in project.elements:
|
for el in project.elements:
|
||||||
if not isinstance(el, _FRAME_CLASSES):
|
if not isinstance(el, _FRAME_CLASSES):
|
||||||
|
|
@ -592,18 +811,40 @@ class PlotlyTraceBuilder:
|
||||||
j = node_row.get(el.nodes[1])
|
j = node_row.get(el.nodes[1])
|
||||||
if i is None or j is None:
|
if i is None or j is None:
|
||||||
continue
|
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])
|
x.extend([float(points[i][0]), float(points[j][0]), None])
|
||||||
y.extend([float(points[i][1]), float(points[j][1]), None])
|
y.extend([float(points[i][1]), float(points[j][1]), None])
|
||||||
z.extend([float(points[i][2]), float(points[j][2]), None])
|
z.extend([float(points[i][2]), float(points[j][2]), None])
|
||||||
color_index.extend([index, index, index])
|
if scalar_values is not None:
|
||||||
|
color_value.extend([float(scalar_values[i]), float(scalar_values[j]), None])
|
||||||
|
else:
|
||||||
|
index = float(
|
||||||
|
selected_index if el.id in opts.selection_elements else element_family_index(el)
|
||||||
|
)
|
||||||
|
color_value.extend([index, index, index])
|
||||||
customdata.extend([el.id, el.id, None])
|
customdata.extend([el.id, el.id, None])
|
||||||
if not x:
|
if not x:
|
||||||
return
|
return
|
||||||
|
|
||||||
count = len(scale_colors)
|
if scalar_values is not None:
|
||||||
|
low, high = _scalar_clim(scalar_values, opts.scalar_clim)
|
||||||
|
line: dict[str, Any] = {
|
||||||
|
"color": color_value,
|
||||||
|
"colorscale": self._style.response_colorscale(),
|
||||||
|
"cmin": low,
|
||||||
|
"cmax": high,
|
||||||
|
"width": 4,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
count = len(scale_colors)
|
||||||
|
line = {
|
||||||
|
"color": color_value,
|
||||||
|
"colorscale": [
|
||||||
|
(index / (count - 1), color) for index, color in enumerate(scale_colors)
|
||||||
|
],
|
||||||
|
"cmin": 0,
|
||||||
|
"cmax": count - 1,
|
||||||
|
"width": 4,
|
||||||
|
}
|
||||||
data.append(
|
data.append(
|
||||||
{
|
{
|
||||||
"type": "scatter3d",
|
"type": "scatter3d",
|
||||||
|
|
@ -611,15 +852,7 @@ class PlotlyTraceBuilder:
|
||||||
"x": x,
|
"x": x,
|
||||||
"y": y,
|
"y": y,
|
||||||
"z": z,
|
"z": z,
|
||||||
"line": {
|
"line": 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,
|
"customdata": customdata,
|
||||||
"meta": {"kind": "element"},
|
"meta": {"kind": "element"},
|
||||||
"hovertemplate": "Element #%{customdata}<extra></extra>",
|
"hovertemplate": "Element #%{customdata}<extra></extra>",
|
||||||
|
|
@ -628,34 +861,103 @@ class PlotlyTraceBuilder:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _build_undeformed_reference(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
original_points: np.ndarray,
|
||||||
|
node_row: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
"""Faint grey wireframe of the undeformed shape (opstool ``show_origin``)."""
|
||||||
|
x: list[float | None] = []
|
||||||
|
y: list[float | None] = []
|
||||||
|
z: list[float | None] = []
|
||||||
|
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
|
||||||
|
x.extend([float(original_points[i][0]), float(original_points[j][0]), None])
|
||||||
|
y.extend([float(original_points[i][1]), float(original_points[j][1]), None])
|
||||||
|
z.extend([float(original_points[i][2]), float(original_points[j][2]), None])
|
||||||
|
if not x:
|
||||||
|
return
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"type": "scatter3d",
|
||||||
|
"mode": "lines",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"line": {"color": "#9aa3ad", "width": 1},
|
||||||
|
"opacity": 0.45,
|
||||||
|
"hoverinfo": "skip",
|
||||||
|
"name": "undeformed-reference",
|
||||||
|
"showlegend": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _build_supports(
|
def _build_supports(
|
||||||
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Oriented support glyphs for each restrained translation DOF.
|
||||||
|
|
||||||
|
Ported and modified from opstool's ``_get_bc_points_3d/_2d`` (GPL-3.0;
|
||||||
|
see ``NOTICE``): a fixed axis is drawn as a plate perpendicular to it,
|
||||||
|
a roller as a circle, so the restrained directions are legible instead
|
||||||
|
of a generic square/triangle marker.
|
||||||
|
"""
|
||||||
if not project.nodes:
|
if not project.nodes:
|
||||||
return
|
return
|
||||||
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
|
size = max(_diag_of_points(pts) * 0.03, 1e-6)
|
||||||
|
ndm = int(getattr(project, "ndm", 3))
|
||||||
|
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||||
|
markers: list[Any] = []
|
||||||
dof_idx = _dof_indices(project.ndf)
|
dof_idx = _dof_indices(project.ndf)
|
||||||
groups: dict[str, list[Any]] = {}
|
|
||||||
for node in project.nodes:
|
for node in project.nodes:
|
||||||
if not any(node.restraint[i] for i in dof_idx):
|
restraint = tuple(bool(flag) for flag in node.restraint)
|
||||||
|
if not any(restraint[index] for index in dof_idx):
|
||||||
continue
|
continue
|
||||||
kind = _classify_support(node.restraint, dof_idx)
|
loops = _support_loops(
|
||||||
groups.setdefault(kind, []).append(node)
|
(float(node.coords[0]), float(node.coords[1]), float(node.coords[2])),
|
||||||
for kind, nodes in groups.items():
|
restraint,
|
||||||
|
size,
|
||||||
|
ndm,
|
||||||
|
)
|
||||||
|
if loops:
|
||||||
|
for loop in loops:
|
||||||
|
for a, b in pairwise(loop):
|
||||||
|
segments.append((a, b))
|
||||||
|
else:
|
||||||
|
markers.append(node)
|
||||||
|
if segments:
|
||||||
|
data.append(
|
||||||
|
_line_trace(
|
||||||
|
segments,
|
||||||
|
color=self._style.support_color,
|
||||||
|
width=3,
|
||||||
|
name="supports",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if markers:
|
||||||
data.append(
|
data.append(
|
||||||
{
|
{
|
||||||
"type": "scatter3d",
|
"type": "scatter3d",
|
||||||
"mode": "markers",
|
"mode": "markers",
|
||||||
"x": [float(n.coords[0]) for n in nodes],
|
"x": [float(n.coords[0]) for n in markers],
|
||||||
"y": [float(n.coords[1]) for n in nodes],
|
"y": [float(n.coords[1]) for n in markers],
|
||||||
"z": [float(n.coords[2]) for n in nodes],
|
"z": [float(n.coords[2]) for n in markers],
|
||||||
"marker": {
|
"marker": {
|
||||||
"color": self._style.support_color,
|
"color": self._style.support_color,
|
||||||
"size": _SUPPORT_MARKER_SIZE,
|
"size": _SUPPORT_MARKER_SIZE,
|
||||||
"symbol": _SUPPORT_SYMBOLS[kind],
|
"symbol": "square",
|
||||||
"line": {"color": "#7f3f00", "width": 1},
|
"line": {"color": "#7f3f00", "width": 1},
|
||||||
},
|
},
|
||||||
"hoverinfo": "skip",
|
"hoverinfo": "skip",
|
||||||
"name": f"support-{kind}",
|
"name": "supports",
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -664,6 +966,12 @@ class PlotlyTraceBuilder:
|
||||||
def _build_loads(
|
def _build_loads(
|
||||||
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Arrow glyphs for nodal and distributed loads.
|
||||||
|
|
||||||
|
Arrow length is proportional to the load magnitude (opstool scales by
|
||||||
|
``|F| / max|F|``), so a 1 kN and a 10 kN load no longer look identical,
|
||||||
|
and each load pattern gets its own colour plus a hover readout.
|
||||||
|
"""
|
||||||
if not project.load_patterns or not project.nodes:
|
if not project.load_patterns or not project.nodes:
|
||||||
return
|
return
|
||||||
node_by_id = {n.id: n for n in project.nodes}
|
node_by_id = {n.id: n for n in project.nodes}
|
||||||
|
|
@ -671,22 +979,16 @@ class PlotlyTraceBuilder:
|
||||||
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
pts = np.array([n.coords for n in project.nodes], dtype=float)
|
||||||
scale = max(_diag_of_points(pts) * 0.05, 1e-6)
|
scale = max(_diag_of_points(pts) * 0.05, 1e-6)
|
||||||
|
|
||||||
nodal_x: list[float] = []
|
patterns = [
|
||||||
nodal_y: list[float] = []
|
pattern for pattern in project.load_patterns if isinstance(pattern, PlainLoadPattern)
|
||||||
nodal_z: list[float] = []
|
]
|
||||||
nodal_u: list[float] = []
|
if not patterns:
|
||||||
nodal_v: list[float] = []
|
return
|
||||||
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:
|
# (pattern index, tail, unit direction, magnitude, subject label)
|
||||||
if not isinstance(pattern, PlainLoadPattern):
|
nodal: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
|
||||||
continue
|
dist: list[tuple[int, np.ndarray, np.ndarray, float, str]] = []
|
||||||
|
for pat_idx, pattern in enumerate(patterns):
|
||||||
for nload in pattern.nodal_loads:
|
for nload in pattern.nodal_loads:
|
||||||
if not isinstance(nload, NodalLoad):
|
if not isinstance(nload, NodalLoad):
|
||||||
continue
|
continue
|
||||||
|
|
@ -697,15 +999,15 @@ class PlotlyTraceBuilder:
|
||||||
mag = float(np.linalg.norm(f))
|
mag = float(np.linalg.norm(f))
|
||||||
if mag < 1e-12:
|
if mag < 1e-12:
|
||||||
continue
|
continue
|
||||||
direction = f / mag
|
nodal.append(
|
||||||
tail = np.asarray(node.coords, dtype=float) - direction * scale
|
(
|
||||||
nodal_x.append(float(tail[0]))
|
pat_idx,
|
||||||
nodal_y.append(float(tail[1]))
|
np.asarray(node.coords, dtype=float),
|
||||||
nodal_z.append(float(tail[2]))
|
f / mag,
|
||||||
nodal_u.append(float(direction[0]))
|
mag,
|
||||||
nodal_v.append(float(direction[1]))
|
f"N{nload.node_id}",
|
||||||
nodal_w.append(float(direction[2]))
|
)
|
||||||
|
)
|
||||||
for eload in pattern.element_loads:
|
for eload in pattern.element_loads:
|
||||||
if not isinstance(eload, UniformElementLoad):
|
if not isinstance(eload, UniformElementLoad):
|
||||||
continue
|
continue
|
||||||
|
|
@ -734,45 +1036,60 @@ class PlotlyTraceBuilder:
|
||||||
if mag < 1e-12:
|
if mag < 1e-12:
|
||||||
continue
|
continue
|
||||||
direction = load_vec / mag
|
direction = load_vec / mag
|
||||||
n_arrows = 5
|
for k in range(5):
|
||||||
for k in range(n_arrows):
|
t = (k + 0.5) / 5.0
|
||||||
t = (k + 0.5) / n_arrows
|
dist.append((pat_idx, pi + t * axis, direction, mag, f"E{eload.element_id}"))
|
||||||
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:
|
max_mag = max((entry[3] for entry in (*nodal, *dist)), default=0.0)
|
||||||
data.append(
|
if max_mag <= 0.0:
|
||||||
_cone_trace(
|
return
|
||||||
nodal_x,
|
colors = self._pattern_colors(len(patterns))
|
||||||
nodal_y,
|
# With a single pattern there is nothing to distinguish, so keep the
|
||||||
nodal_z,
|
# per-type default tints (nodal vs element loads).
|
||||||
nodal_u,
|
single = len(patterns) == 1
|
||||||
nodal_v,
|
|
||||||
nodal_w,
|
def _arrow_len(mag: float) -> float:
|
||||||
color=self._style.nodal_load_color,
|
return max(scale * mag / max_mag, 0.15 * scale)
|
||||||
name="nodal-loads",
|
|
||||||
size=scale,
|
for pat_idx, pattern in enumerate(patterns):
|
||||||
|
label = getattr(pattern, "name", "") or f"Pattern {pat_idx + 1}"
|
||||||
|
for group, name, width, default in (
|
||||||
|
(nodal, "nodal-loads", 0.12, self._style.nodal_load_color),
|
||||||
|
(dist, "element-loads", 0.12, self._style.element_load_color),
|
||||||
|
):
|
||||||
|
entries = [entry for entry in group if entry[0] == pat_idx]
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
color = default if single else colors[pat_idx]
|
||||||
|
x, y, z, u, v, w, hovers = [], [], [], [], [], [], []
|
||||||
|
for _idx, tail, direction, mag, subject in entries:
|
||||||
|
length = _arrow_len(mag)
|
||||||
|
x.append(float(tail[0]))
|
||||||
|
y.append(float(tail[1]))
|
||||||
|
z.append(float(tail[2]))
|
||||||
|
u.append(float(direction[0] * length))
|
||||||
|
v.append(float(direction[1] * length))
|
||||||
|
w.append(float(direction[2] * length))
|
||||||
|
hovers.append(f"{label} · {subject}<br>|F| = {mag:.4g}")
|
||||||
|
data.append(
|
||||||
|
_cone_trace(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
u,
|
||||||
|
v,
|
||||||
|
w,
|
||||||
|
color=color,
|
||||||
|
name=name,
|
||||||
|
size=width,
|
||||||
|
customdata=hovers,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if dist_x:
|
def _pattern_colors(self, count: int) -> list[str]:
|
||||||
data.append(
|
"""One palette colour per load pattern (used only when count > 1)."""
|
||||||
_cone_trace(
|
palette = _PATTERN_PALETTE
|
||||||
dist_x,
|
return [palette[index % len(palette)] for index in range(count)]
|
||||||
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(
|
def _build_local_axes(
|
||||||
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
self, project: Project, data: list[dict[str, Any]], opts: SceneOptions
|
||||||
|
|
@ -818,9 +1135,10 @@ class PlotlyTraceBuilder:
|
||||||
bucket["x"].append(float(mid[0]))
|
bucket["x"].append(float(mid[0]))
|
||||||
bucket["y"].append(float(mid[1]))
|
bucket["y"].append(float(mid[1]))
|
||||||
bucket["z"].append(float(mid[2]))
|
bucket["z"].append(float(mid[2]))
|
||||||
bucket["u"].append(float(direction[0]))
|
# Cone length = (u, v, w) magnitude: scale to the model.
|
||||||
bucket["v"].append(float(direction[1]))
|
bucket["u"].append(float(direction[0] * cap))
|
||||||
bucket["w"].append(float(direction[2]))
|
bucket["v"].append(float(direction[1] * cap))
|
||||||
|
bucket["w"].append(float(direction[2] * cap))
|
||||||
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
|
for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")):
|
||||||
bucket = axes[key]
|
bucket = axes[key]
|
||||||
if bucket["x"]:
|
if bucket["x"]:
|
||||||
|
|
@ -834,7 +1152,7 @@ class PlotlyTraceBuilder:
|
||||||
bucket["w"],
|
bucket["w"],
|
||||||
color=color,
|
color=color,
|
||||||
name=f"local-{key}",
|
name=f"local-{key}",
|
||||||
size=cap,
|
size=0.12,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -951,28 +1269,6 @@ class PlotlyTraceBuilder:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@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(
|
def _cone_trace(
|
||||||
x: list[float],
|
x: list[float],
|
||||||
|
|
@ -984,9 +1280,17 @@ def _cone_trace(
|
||||||
*,
|
*,
|
||||||
color: str,
|
color: str,
|
||||||
name: str,
|
name: str,
|
||||||
size: float,
|
size: float = 0.4,
|
||||||
|
customdata: list[Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
"""A cone/arrow trace.
|
||||||
|
|
||||||
|
``sizemode`` stays ``"scaled"``: cone size is then proportional to the
|
||||||
|
vector norm times the unitless ``sizeref``, so every arrow keeps the same
|
||||||
|
aspect ratio. ``"absolute"`` interprets ``sizeref`` against the vector
|
||||||
|
norm in *normalized* scene units, which rendered fat fins on large models.
|
||||||
|
"""
|
||||||
|
trace: dict[str, Any] = {
|
||||||
"type": "cone",
|
"type": "cone",
|
||||||
"x": x,
|
"x": x,
|
||||||
"y": y,
|
"y": y,
|
||||||
|
|
@ -995,7 +1299,7 @@ def _cone_trace(
|
||||||
"v": v,
|
"v": v,
|
||||||
"w": w,
|
"w": w,
|
||||||
"anchor": "tail",
|
"anchor": "tail",
|
||||||
"sizemode": "absolute",
|
"sizemode": "scaled",
|
||||||
"sizeref": float(size),
|
"sizeref": float(size),
|
||||||
"colorscale": [[0, color], [1, color]],
|
"colorscale": [[0, color], [1, color]],
|
||||||
"showscale": False,
|
"showscale": False,
|
||||||
|
|
@ -1003,6 +1307,11 @@ def _cone_trace(
|
||||||
"name": name,
|
"name": name,
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
|
if customdata is not None:
|
||||||
|
trace["customdata"] = customdata
|
||||||
|
trace["hovertemplate"] = "%{customdata}<extra></extra>"
|
||||||
|
trace["hoverinfo"] = "text"
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
def _text_trace(
|
def _text_trace(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from otko.views.dialogs.locate_origin import (
|
||||||
from otko.views.dialogs.material_library import MaterialLibraryDialog
|
from otko.views.dialogs.material_library import MaterialLibraryDialog
|
||||||
from otko.views.dialogs.material_tester import MaterialTesterDialog
|
from otko.views.dialogs.material_tester import MaterialTesterDialog
|
||||||
from otko.views.dialogs.mirror import MirrorDialog
|
from otko.views.dialogs.mirror import MirrorDialog
|
||||||
|
from otko.views.dialogs.mouse_controls import MouseControlsDialog
|
||||||
from otko.views.dialogs.move import MoveDialog
|
from otko.views.dialogs.move import MoveDialog
|
||||||
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
||||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||||
|
|
@ -66,6 +67,7 @@ __all__ = [
|
||||||
"MaterialLibraryDialog",
|
"MaterialLibraryDialog",
|
||||||
"MaterialTesterDialog",
|
"MaterialTesterDialog",
|
||||||
"MirrorDialog",
|
"MirrorDialog",
|
||||||
|
"MouseControlsDialog",
|
||||||
"MoveDialog",
|
"MoveDialog",
|
||||||
"PathTimeSeriesDialog",
|
"PathTimeSeriesDialog",
|
||||||
"PatternLoadsDialog",
|
"PatternLoadsDialog",
|
||||||
|
|
|
||||||
68
src/otko/views/dialogs/mouse_controls.py
Normal file
68
src/otko/views/dialogs/mouse_controls.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Modeless Mouse Controls reference for the 3D viewport.
|
||||||
|
|
||||||
|
Both canvas backends share the VTK trackball convention: the PyVista canvas
|
||||||
|
uses it natively and the Plotly canvas re-implements it in JS, so the table
|
||||||
|
below holds whichever backend is active.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MOUSE_HTML = """<h3>Mouse Controls</h3>
|
||||||
|
<p>The 3D viewport follows the VTK trackball convention on both canvas
|
||||||
|
backends (<b>Options → Canvas Backend</b>).</p>
|
||||||
|
|
||||||
|
<h4>Navigate the view</h4>
|
||||||
|
<table cellpadding="3" cellspacing="0">
|
||||||
|
<tr><td><b>Rotate</b></td><td>Left-drag</td></tr>
|
||||||
|
<tr><td><b>Pan</b></td><td>Shift + left-drag, or middle-drag</td></tr>
|
||||||
|
<tr><td><b>Zoom</b></td><td>Right-drag, or mouse wheel</td></tr>
|
||||||
|
<tr><td><b>Spin</b> (roll)</td><td>Ctrl + left-drag</td></tr>
|
||||||
|
<tr><td><b>Zoom</b> (from key)</td><td>Ctrl + Shift + left-drag</td></tr>
|
||||||
|
<tr><td><b>Environment rotate</b></td><td>Shift + right-drag</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>Alt is not used for view navigation. Horizontal-wheel input rolls the
|
||||||
|
Plotly view; on PyVista it has no effect.</p>
|
||||||
|
|
||||||
|
<h4>Select</h4>
|
||||||
|
<table cellpadding="3" cellspacing="0">
|
||||||
|
<tr><td><b>Select</b></td><td>Left-click a node or element</td></tr>
|
||||||
|
<tr><td><b>Add / toggle</b></td><td>Ctrl+click or Shift+click</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>A drag rotates the view; only a press-and-release without movement counts
|
||||||
|
as a click, so panning never changes the selection.</p>
|
||||||
|
|
||||||
|
<h4>View presets</h4>
|
||||||
|
<table cellpadding="3" cellspacing="0">
|
||||||
|
<tr><td><b>Isometric</b></td><td>Ctrl+1</td></tr>
|
||||||
|
<tr><td><b>Top (XY)</b></td><td>Ctrl+2</td></tr>
|
||||||
|
<tr><td><b>Front (XZ)</b></td><td>Ctrl+3</td></tr>
|
||||||
|
<tr><td><b>Right (YZ)</b></td><td>Ctrl+4</td></tr>
|
||||||
|
<tr><td><b>Zoom Extents</b></td><td>Ctrl+E</td></tr>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MouseControlsDialog(QDialog):
|
||||||
|
"""Modeless reference for viewport mouse navigation and selection."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Mouse Controls")
|
||||||
|
self.setMinimumSize(460, 520)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
browser = QTextBrowser(self)
|
||||||
|
browser.setReadOnly(True)
|
||||||
|
browser.setOpenExternalLinks(False)
|
||||||
|
browser.setHtml(_MOUSE_HTML)
|
||||||
|
layout.addWidget(browser)
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
|
||||||
|
buttons.rejected.connect(self.close)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
@ -50,6 +50,10 @@ or <b>Assign → Frame</b>: <b>Section…</b>, <b>Material…</b>,
|
||||||
<b>Show Force Diagram…</b> and the other plot actions;
|
<b>Show Force Diagram…</b> and the other plot actions;
|
||||||
<b>Display → Show Undeformed Shape</b>, <b>Clear Display</b> or
|
<b>Display → Show Undeformed Shape</b>, <b>Clear Display</b> or
|
||||||
<b>Back to Model View</b> (Ctrl+Shift+B) to return to the model.</p>
|
<b>Back to Model View</b> (Ctrl+Shift+B) to return to the model.</p>
|
||||||
|
|
||||||
|
<h4>8 — Navigate the 3D view</h4>
|
||||||
|
<p>Left-drag rotates, Shift+left-drag (or middle-drag) pans, right-drag or the
|
||||||
|
wheel zooms, Ctrl+left-drag spins. Full list under <b>Help → Mouse Controls</b>.</p>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,11 +197,44 @@ class DockManager:
|
||||||
|
|
||||||
animator.frameChanged.connect(_apply)
|
animator.frameChanged.connect(_apply)
|
||||||
animator.closed.connect(self._on_back_to_model)
|
animator.closed.connect(self._on_back_to_model)
|
||||||
|
if self._canvas.capabilities.in_page_animation:
|
||||||
|
animator.playToggled.connect(
|
||||||
|
lambda playing: self._animate_mode_in_page(animator, playing)
|
||||||
|
)
|
||||||
animator.exportRequested.connect(
|
animator.exportRequested.connect(
|
||||||
lambda: self._on_export_mode_shape(animator, _apply),
|
lambda: self._on_export_mode_shape(animator, _apply),
|
||||||
)
|
)
|
||||||
# Animator emits an initial frame in its constructor; nothing to do.
|
# Animator emits an initial frame in its constructor; nothing to do.
|
||||||
|
|
||||||
|
def _animate_mode_in_page(self, animator: object, playing: bool) -> None:
|
||||||
|
"""Play the current mode shape with plotly's own frame animation."""
|
||||||
|
if not playing:
|
||||||
|
self._canvas.stop_animation()
|
||||||
|
return
|
||||||
|
if not isinstance(self._latest_results, ModalResults) or self._vm.project is None:
|
||||||
|
return
|
||||||
|
import math
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
n_frames = 36
|
||||||
|
period = max(float(animator.period_seconds()), 0.1) # type: ignore[attr-defined]
|
||||||
|
mode = int(animator.current_mode()) # type: ignore[attr-defined]
|
||||||
|
scale = float(animator.current_scale()) # type: ignore[attr-defined]
|
||||||
|
base = self._canvas.base_scene_options() # type: ignore[attr-defined]
|
||||||
|
options = []
|
||||||
|
for k in range(n_frames):
|
||||||
|
phase = math.sin(2.0 * math.pi * k / n_frames)
|
||||||
|
src = modal_to_deformation(
|
||||||
|
self._vm.project, self._latest_results, mode=mode, scale=scale, phase=phase
|
||||||
|
)
|
||||||
|
options.append(replace(base, deformation=src))
|
||||||
|
animator.set_in_page_mode(True) # type: ignore[attr-defined]
|
||||||
|
self._canvas.animate( # type: ignore[attr-defined]
|
||||||
|
options,
|
||||||
|
duration_ms=int(period * 1000 / n_frames),
|
||||||
|
loops=100,
|
||||||
|
)
|
||||||
|
|
||||||
def _on_export_mode_shape(self, animator, apply_callable) -> None: # type: ignore[no-untyped-def]
|
def _on_export_mode_shape(self, animator, apply_callable) -> None: # type: ignore[no-untyped-def]
|
||||||
"""Capture one period of the current mode shape to MP4/GIF.
|
"""Capture one period of the current mode shape to MP4/GIF.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class ModeShapeAnimator(QWidget):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
frameChanged = Signal(int, float, float)
|
frameChanged = Signal(int, float, float)
|
||||||
|
playToggled = Signal(bool)
|
||||||
closed = Signal()
|
closed = Signal()
|
||||||
exportRequested = Signal()
|
exportRequested = Signal()
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ class ModeShapeAnimator(QWidget):
|
||||||
self._n_modes = n_modes
|
self._n_modes = n_modes
|
||||||
self._freqs = frequencies_hz
|
self._freqs = frequencies_hz
|
||||||
self._t = 0.0
|
self._t = 0.0
|
||||||
|
self._in_page = False
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.timeout.connect(self._on_tick)
|
self._timer.timeout.connect(self._on_tick)
|
||||||
self._timer.setInterval(int(1000 / self._FPS))
|
self._timer.setInterval(int(1000 / self._FPS))
|
||||||
|
|
@ -111,6 +113,17 @@ class ModeShapeAnimator(QWidget):
|
||||||
def current_scale(self) -> float:
|
def current_scale(self) -> float:
|
||||||
return float(self._scale.value())
|
return float(self._scale.value())
|
||||||
|
|
||||||
|
def period_seconds(self) -> float:
|
||||||
|
return float(self._period.value())
|
||||||
|
|
||||||
|
def set_in_page_mode(self, on: bool) -> None:
|
||||||
|
"""While an in-page (plotly) animation plays, stop emitting frames.
|
||||||
|
|
||||||
|
The canvas animates itself; the Python timer keeps the scrubber in
|
||||||
|
sync but must not drive per-frame re-renders.
|
||||||
|
"""
|
||||||
|
self._in_page = bool(on)
|
||||||
|
|
||||||
def set_results(self, results: object | None) -> None:
|
def set_results(self, results: object | None) -> None:
|
||||||
"""Rebind to a new modal run while the dock stays open.
|
"""Rebind to a new modal run while the dock stays open.
|
||||||
|
|
||||||
|
|
@ -174,6 +187,8 @@ class ModeShapeAnimator(QWidget):
|
||||||
self._play_btn.setText("▶ Play")
|
self._play_btn.setText("▶ Play")
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
self._scrubber.setEnabled(True)
|
self._scrubber.setEnabled(True)
|
||||||
|
self._in_page = False
|
||||||
|
self.playToggled.emit(playing)
|
||||||
|
|
||||||
def _on_stop(self) -> None:
|
def _on_stop(self) -> None:
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
|
|
@ -201,6 +216,8 @@ class ModeShapeAnimator(QWidget):
|
||||||
self._scrubber.blockSignals(True)
|
self._scrubber.blockSignals(True)
|
||||||
self._scrubber.setValue(int(round(phase * 100)))
|
self._scrubber.setValue(int(round(phase * 100)))
|
||||||
self._scrubber.blockSignals(False)
|
self._scrubber.blockSignals(False)
|
||||||
|
if self._in_page:
|
||||||
|
return # the canvas is animating itself; don't re-render per tick
|
||||||
self.frameChanged.emit(self._mode_combo.currentData(), self._scale.value(), phase)
|
self.frameChanged.emit(self._mode_combo.currentData(), self._scale.value(), phase)
|
||||||
|
|
||||||
def _emit_static_frame(self) -> None:
|
def _emit_static_frame(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ class MainWindow(
|
||||||
# Re-apply the overlays whose state lives on the toolbar actions.
|
# 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_section_extrusions(self._act_show_extruded.isChecked())
|
||||||
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
|
self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked())
|
||||||
|
self._canvas.set_show_undeformed(self._act_show_reference.isChecked())
|
||||||
self._canvas.set_display_options(
|
self._canvas.set_display_options(
|
||||||
show_node_labels=self._show_node_labels,
|
show_node_labels=self._show_node_labels,
|
||||||
show_element_labels=self._show_element_labels,
|
show_element_labels=self._show_element_labels,
|
||||||
|
|
@ -308,6 +309,7 @@ class MainWindow(
|
||||||
self._act_quit.triggered.connect(self.close)
|
self._act_quit.triggered.connect(self.close)
|
||||||
self._act_about.triggered.connect(self._on_about)
|
self._act_about.triggered.connect(self._on_about)
|
||||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||||
|
self._act_mouse_controls.triggered.connect(self._on_mouse_controls)
|
||||||
self._act_set_units.triggered.connect(self._on_set_units)
|
self._act_set_units.triggered.connect(self._on_set_units)
|
||||||
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
||||||
|
|
||||||
|
|
@ -386,6 +388,9 @@ class MainWindow(
|
||||||
self._act_toggle_parallel.toggled.connect(self._on_toggle_parallel)
|
self._act_toggle_parallel.toggled.connect(self._on_toggle_parallel)
|
||||||
self._act_show_extruded.toggled.connect(self._canvas.set_show_section_extrusions)
|
self._act_show_extruded.toggled.connect(self._canvas.set_show_section_extrusions)
|
||||||
self._act_show_local_axes.toggled.connect(self._canvas.set_show_local_axes)
|
self._act_show_local_axes.toggled.connect(self._canvas.set_show_local_axes)
|
||||||
|
# Resolve the active canvas at signal time so a backend swap still
|
||||||
|
# routes to the canvas on screen.
|
||||||
|
self._act_show_reference.toggled.connect(lambda on: self._canvas.set_show_undeformed(on))
|
||||||
|
|
||||||
# ViewModel — projectChanged / modelMutated survive the split.
|
# ViewModel — projectChanged / modelMutated survive the split.
|
||||||
self._vm.projectChanged.connect(self._on_project_changed)
|
self._vm.projectChanged.connect(self._on_project_changed)
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,11 @@ class MenuBuilder:
|
||||||
"Exit any deformed / mode-shape / force-diagram view back to "
|
"Exit any deformed / mode-shape / force-diagram view back to "
|
||||||
"the model geometry. Result data is kept."
|
"the model geometry. Result data is kept."
|
||||||
)
|
)
|
||||||
|
self._act_show_reference = QAction("Show Undeformed &Reference", self, checkable=True)
|
||||||
|
self._act_show_reference.setToolTip(
|
||||||
|
"Faintly overlay the undeformed shape behind a deformed / mode-shape "
|
||||||
|
"view so displacements are easy to judge."
|
||||||
|
)
|
||||||
self._act_clear_display = QAction("&Clear Display", self)
|
self._act_clear_display = QAction("&Clear Display", self)
|
||||||
self._act_clear_display.setToolTip(
|
self._act_clear_display.setToolTip(
|
||||||
"Neutral canvas: undeformed geometry, no overlays, selection "
|
"Neutral canvas: undeformed geometry, no overlays, selection "
|
||||||
|
|
@ -191,6 +196,7 @@ class MenuBuilder:
|
||||||
self._act_about = QAction("&About OTKO…", self)
|
self._act_about = QAction("&About OTKO…", self)
|
||||||
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
||||||
self._act_quick_guide = QAction("&Quick Guide", self)
|
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||||
|
self._act_mouse_controls = QAction("&Mouse Controls…", self)
|
||||||
self._act_set_units = QAction("Set Display &Units…", self)
|
self._act_set_units = QAction("Set Display &Units…", self)
|
||||||
self._act_plot_properties = QAction("&Plot Properties…", self)
|
self._act_plot_properties = QAction("&Plot Properties…", self)
|
||||||
self._act_plot_properties.setToolTip(
|
self._act_plot_properties.setToolTip(
|
||||||
|
|
@ -481,6 +487,7 @@ class MenuBuilder:
|
||||||
m_display.addSeparator()
|
m_display.addSeparator()
|
||||||
m_display.addAction(self._act_back_to_model)
|
m_display.addAction(self._act_back_to_model)
|
||||||
m_display.addAction(self._act_show_undeformed)
|
m_display.addAction(self._act_show_undeformed)
|
||||||
|
m_display.addAction(self._act_show_reference)
|
||||||
m_display.addAction(self._act_clear_display)
|
m_display.addAction(self._act_clear_display)
|
||||||
|
|
||||||
m_view = mb.addMenu("&View")
|
m_view = mb.addMenu("&View")
|
||||||
|
|
@ -524,6 +531,7 @@ class MenuBuilder:
|
||||||
|
|
||||||
m_help = mb.addMenu("&Help")
|
m_help = mb.addMenu("&Help")
|
||||||
m_help.addAction(self._act_quick_guide)
|
m_help.addAction(self._act_quick_guide)
|
||||||
|
m_help.addAction(self._act_mouse_controls)
|
||||||
m_help.addAction(self._act_about)
|
m_help.addAction(self._act_about)
|
||||||
|
|
||||||
def _build_view_toolbar(self) -> None:
|
def _build_view_toolbar(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,22 @@ class RenderControls:
|
||||||
self._tear_down_post_dock()
|
self._tear_down_post_dock()
|
||||||
if self._diagram_renderer is not None:
|
if self._diagram_renderer is not None:
|
||||||
self._diagram_renderer.clear()
|
self._diagram_renderer.clear()
|
||||||
|
self._reset_reference_overlay()
|
||||||
|
stop_animation = getattr(self._canvas, "stop_animation", None)
|
||||||
|
if callable(stop_animation):
|
||||||
|
stop_animation()
|
||||||
self._canvas._renderer.set_mode(RendererMode.MODEL)
|
self._canvas._renderer.set_mode(RendererMode.MODEL)
|
||||||
self._canvas.render()
|
self._canvas.render()
|
||||||
|
|
||||||
|
def _reset_reference_overlay(self) -> None:
|
||||||
|
"""Drop the undeformed-reference overlay and uncheck its action."""
|
||||||
|
self._act_show_reference.blockSignals(True)
|
||||||
|
try:
|
||||||
|
self._act_show_reference.setChecked(False)
|
||||||
|
finally:
|
||||||
|
self._act_show_reference.blockSignals(False)
|
||||||
|
self._canvas.set_show_undeformed(False)
|
||||||
|
|
||||||
def _on_clear_display(self) -> None:
|
def _on_clear_display(self) -> None:
|
||||||
"""Clear Display: neutral canvas — undeformed MODEL, no selection.
|
"""Clear Display: neutral canvas — undeformed MODEL, no selection.
|
||||||
|
|
||||||
|
|
@ -513,6 +526,7 @@ class RenderControls:
|
||||||
# Undeformed exits any post view (dock, renderer mode or diagram
|
# Undeformed exits any post view (dock, renderer mode or diagram
|
||||||
# overlay); Clear just needs a project — both keep result data.
|
# overlay); Clear just needs a project — both keep result data.
|
||||||
self._act_show_undeformed.setEnabled(has_project and self._in_post_view())
|
self._act_show_undeformed.setEnabled(has_project and self._in_post_view())
|
||||||
|
self._act_show_reference.setEnabled(has_project and self._in_post_view())
|
||||||
self._act_clear_display.setEnabled(has_project)
|
self._act_clear_display.setEnabled(has_project)
|
||||||
|
|
||||||
def _log(self, message: str) -> None:
|
def _log(self, message: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,21 @@ def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignor
|
||||||
assert not window.isVisible()
|
assert not window.isVisible()
|
||||||
assert window.close() is True
|
assert window.close() is True
|
||||||
assert window._vm.is_dirty # unchanged: the prompt was skipped
|
assert window._vm.is_dirty # unchanged: the prompt was skipped
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_mouse_controls_dialog_is_wired_and_reused(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
from otko.views.dialogs.mouse_controls import MouseControlsDialog
|
||||||
|
from otko.views.main_window import MainWindow
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
window._on_mouse_controls()
|
||||||
|
dialog = window._mouse_controls_dialog
|
||||||
|
assert isinstance(dialog, MouseControlsDialog)
|
||||||
|
assert dialog.windowTitle() == "Mouse Controls"
|
||||||
|
|
||||||
|
# Raising the action again reuses the same modeless window.
|
||||||
|
window._on_mouse_controls()
|
||||||
|
assert window._mouse_controls_dialog is dialog
|
||||||
|
|
|
||||||
166
tests/gui/test_plotly_gestures.py
Normal file
166
tests/gui/test_plotly_gestures.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""Regression tests for the Plotly canvas mouse-gesture layer.
|
||||||
|
|
||||||
|
plotly's built-in drag bindings (shift+left rotates, ctrl+left pans, middle
|
||||||
|
zooms, right pans) disagree with the PyVista backend's VTK trackball style, so
|
||||||
|
the Plotly canvas re-implements them in JS to match VTK: shift+left pan,
|
||||||
|
ctrl+left spin, middle pan, right zoom.
|
||||||
|
|
||||||
|
The page runs offscreen under QtWebEngine, where gl-plot3d's render loop is
|
||||||
|
throttled and camera animations do not advance, so these tests spy on the
|
||||||
|
gl-plot3d camera's ``view.pan`` / ``view.rotate`` calls and assert the mapping
|
||||||
|
(and that plotly's own handler is suppressed for the gesture) rather than the
|
||||||
|
resulting camera position. A full-camera smoke check is unnecessary here;
|
||||||
|
plotly owns the maths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
pytest.importorskip("plotly")
|
||||||
|
|
||||||
|
from otko.services import load_project
|
||||||
|
|
||||||
|
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
||||||
|
|
||||||
|
#: Install spies on the live camera, run one synthetic drag, report the calls.
|
||||||
|
#: ``keyBindingMode`` distinguishes our layer (false) from plotly's native
|
||||||
|
#: handler ('rotate'), which calls the same ``view`` methods.
|
||||||
|
_SPY_DRAG = """(function(){
|
||||||
|
var gd = document.getElementById('plot');
|
||||||
|
var s = gd._fullLayout.scene._scene;
|
||||||
|
var cam = s.camera;
|
||||||
|
var el = s.glplot.canvas;
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
var x0 = r.left + r.width/2, y0 = r.top + r.height/2;
|
||||||
|
var calls = [];
|
||||||
|
var origPan = cam.view.pan;
|
||||||
|
var origRotate = cam.view.rotate;
|
||||||
|
cam.view.pan = function (t, dx, dy, dz) {
|
||||||
|
calls.push(['pan', cam.keyBindingMode, dx, dy, dz]);
|
||||||
|
};
|
||||||
|
cam.view.rotate = function (t, p, y, ro) {
|
||||||
|
calls.push(['rotate', cam.keyBindingMode, p, y, ro]);
|
||||||
|
};
|
||||||
|
function ev(t, x, y, b) {
|
||||||
|
el.dispatchEvent(new MouseEvent(t, {
|
||||||
|
bubbles: true, cancelable: true, view: window,
|
||||||
|
clientX: x, clientY: y, button: __BUTTON__, buttons: b,
|
||||||
|
shiftKey: __SHIFT__, ctrlKey: __CTRL__, altKey: __ALT__
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
ev('mousedown', x0, y0, __BUTTONS__);
|
||||||
|
ev('mousemove', x0 + 60, y0 + 40, __BUTTONS__);
|
||||||
|
ev('mouseup', x0 + 60, y0 + 40, 0);
|
||||||
|
cam.view.pan = origPan;
|
||||||
|
cam.view.rotate = origRotate;
|
||||||
|
return JSON.stringify({calls: calls, kbm: cam.keyBindingMode});
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
|
||||||
|
def _js_bool(value: bool) -> str:
|
||||||
|
return "true" if value else "false"
|
||||||
|
|
||||||
|
|
||||||
|
def _open_canvas(qtbot): # type: ignore[no-untyped-def]
|
||||||
|
from otko.views.canvas_plotly import PlotlyCanvas
|
||||||
|
|
||||||
|
canvas = PlotlyCanvas()
|
||||||
|
qtbot.addWidget(canvas)
|
||||||
|
canvas.show_project(load_project(EXAMPLES / "basic_truss.osmodel"))
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
def _spy_drag(
|
||||||
|
canvas, # type: ignore[no-untyped-def]
|
||||||
|
qtbot,
|
||||||
|
*,
|
||||||
|
button: int,
|
||||||
|
buttons: int,
|
||||||
|
shift: bool = False,
|
||||||
|
ctrl: bool = False,
|
||||||
|
alt: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
script = (
|
||||||
|
_SPY_DRAG.replace("__BUTTON__", str(button))
|
||||||
|
.replace("__BUTTONS__", str(buttons))
|
||||||
|
.replace("__SHIFT__", _js_bool(shift))
|
||||||
|
.replace("__CTRL__", _js_bool(ctrl))
|
||||||
|
.replace("__ALT__", _js_bool(alt))
|
||||||
|
)
|
||||||
|
raw = _run_js(canvas, qtbot, script)
|
||||||
|
assert isinstance(raw, str)
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _ours(calls: list[list[Any]], kind: str) -> list[list[Any]]:
|
||||||
|
return [c for c in calls if c[0] == kind and c[1] is False]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_gesture_bindings_match_vtk(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""One page for all gestures: each QWebEngineView costs a WebGL context."""
|
||||||
|
canvas = _open_canvas(qtbot)
|
||||||
|
|
||||||
|
# Shift+left pans (not plotly's rotate).
|
||||||
|
result = _spy_drag(canvas, qtbot, button=0, buttons=1, shift=True)
|
||||||
|
pans = _ours(result["calls"], "pan")
|
||||||
|
assert pans and pans[0][3] != 0, result
|
||||||
|
assert not _ours(result["calls"], "rotate"), result
|
||||||
|
assert result["kbm"] == "rotate", "binding must be restored after the drag"
|
||||||
|
|
||||||
|
# Middle drag pans, not zooms.
|
||||||
|
result = _spy_drag(canvas, qtbot, button=1, buttons=4)
|
||||||
|
pans = _ours(result["calls"], "pan")
|
||||||
|
assert pans and pans[0][3] != 0 and pans[0][4] == 0, result
|
||||||
|
assert result["kbm"] == "rotate", result
|
||||||
|
|
||||||
|
# Right drag dollies (z pan), not plotly's pan.
|
||||||
|
result = _spy_drag(canvas, qtbot, button=2, buttons=2)
|
||||||
|
zooms = [c for c in _ours(result["calls"], "pan") if c[2] == 0 and c[3] == 0]
|
||||||
|
assert zooms and zooms[0][4] != 0, result
|
||||||
|
assert result["kbm"] == "rotate", result
|
||||||
|
|
||||||
|
# Ctrl+left spins (pure roll).
|
||||||
|
result = _spy_drag(canvas, qtbot, button=0, buttons=1, ctrl=True)
|
||||||
|
rolls = [c for c in _ours(result["calls"], "rotate") if c[2] == 0 and c[3] == 0]
|
||||||
|
assert rolls and rolls[0][4] != 0, result
|
||||||
|
assert result["kbm"] == "rotate", result
|
||||||
|
|
||||||
|
# Alt is not a VTK modifier: it falls back to a plain rotate.
|
||||||
|
result = _spy_drag(canvas, qtbot, button=0, buttons=1, alt=True)
|
||||||
|
rots = [c for c in _ours(result["calls"], "rotate") if c[4] == 0]
|
||||||
|
assert rots and (rots[0][2] != 0 or rots[0][3] != 0), result
|
||||||
|
assert result["kbm"] == "rotate", result
|
||||||
|
|
||||||
|
# Plain left drag stays on plotly's native path (keyBindingMode 'rotate').
|
||||||
|
result = _spy_drag(canvas, qtbot, button=0, buttons=1)
|
||||||
|
assert not _ours(result["calls"], "pan"), result
|
||||||
|
assert not _ours(result["calls"], "rotate"), result
|
||||||
|
assert any(c[1] == "rotate" for c in result["calls"]), result
|
||||||
|
assert result["kbm"] == "rotate", result
|
||||||
|
|
||||||
|
# In-page animation entry points are exposed to Python.
|
||||||
|
assert (
|
||||||
|
_run_js(
|
||||||
|
canvas,
|
||||||
|
qtbot,
|
||||||
|
"typeof window.otkoAnimate === 'function' && "
|
||||||
|
"typeof window.otkoStopAnimation === 'function'",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Regression tests for the Plotly hover/snap-marker contract.
|
"""Regression tests for the Plotly snap-hover marker.
|
||||||
|
|
||||||
``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves.
|
``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured
|
||||||
Touching the plot on each one made it redraw per mouse move, re-firing hover
|
in-page), so the marker used to lag far behind the cursor and stall orbiting.
|
||||||
until the stack blew (``RangeError: Maximum call stack size exceeded``) and the
|
It is now a pointer-events-none DOM overlay positioned by projecting the snapped
|
||||||
canvas froze. The marker is therefore coalesced and only touched when its
|
world point through gl-plot3d's own camera matrices, which costs microseconds
|
||||||
shown/hidden state actually changes.
|
and issues no plotly calls at all.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -35,10 +35,12 @@ _CHURN_PROBE = """(function(){
|
||||||
} finally { Plotly.restyle = original; }
|
} finally { Plotly.restyle = original; }
|
||||||
})()"""
|
})()"""
|
||||||
|
|
||||||
_HOVER_MARKER_X = (
|
_MARKER_STATE = """(function(){
|
||||||
"JSON.stringify((document.getElementById('plot').data"
|
var marker = document.getElementById('otko-snap-marker');
|
||||||
".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
|
if (!marker) return 'missing';
|
||||||
)
|
return JSON.stringify({ display: marker.style.display, left: marker.style.left,
|
||||||
|
top: marker.style.top });
|
||||||
|
})()"""
|
||||||
|
|
||||||
_EMIT_SNAP_HOVER = """(function(){
|
_EMIT_SNAP_HOVER = """(function(){
|
||||||
var gd = document.getElementById('plot');
|
var gd = document.getElementById('plot');
|
||||||
|
|
@ -48,6 +50,8 @@ _EMIT_SNAP_HOVER = """(function(){
|
||||||
return true;
|
return true;
|
||||||
})()"""
|
})()"""
|
||||||
|
|
||||||
|
_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true"
|
||||||
|
|
||||||
|
|
||||||
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
||||||
from otko.views.canvas_plotly import PlotlyCanvas
|
from otko.views.canvas_plotly import PlotlyCanvas
|
||||||
|
|
@ -69,7 +73,7 @@ def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[n
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
|
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
"""The unfixed version restyled once per hover/unhover event."""
|
"""The original handler restyled on every hover/unhover event."""
|
||||||
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
||||||
|
|
||||||
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
||||||
|
|
@ -78,16 +82,45 @@ def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
|
def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
||||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||||
|
|
||||||
|
# Nothing shows while snapping is off (the marker belongs to the draw tools).
|
||||||
|
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
||||||
|
qtbot.wait(150)
|
||||||
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||||
|
|
||||||
canvas.set_snap_preview_enabled(True)
|
canvas.set_snap_preview_enabled(True)
|
||||||
qtbot.wait(100)
|
qtbot.wait(150)
|
||||||
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
||||||
qtbot.wait(250)
|
qtbot.wait(200)
|
||||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
|
shown = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
||||||
|
assert shown["display"] == "block", "snap target not shown"
|
||||||
|
assert float(shown["left"].rstrip("px")) > 0
|
||||||
|
assert float(shown["top"].rstrip("px")) > 0
|
||||||
|
|
||||||
_run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})")
|
# Orbiting moves the target on screen without any plotly redraw. A top
|
||||||
qtbot.wait(250)
|
# view is used because a point near the scene centre projects to nearly
|
||||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared"
|
# the same pixel from opposite diagonal views.
|
||||||
|
_run_js(
|
||||||
|
canvas,
|
||||||
|
qtbot,
|
||||||
|
"Plotly.relayout('plot',{'scene.camera.eye':{x:0,y:0,z:40},"
|
||||||
|
"'scene.camera.up':{x:0,y:1,z:0}})",
|
||||||
|
)
|
||||||
|
qtbot.wait(600)
|
||||||
|
moved = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
||||||
|
assert moved["display"] == "block"
|
||||||
|
assert (moved["left"], moved["top"]) != (shown["left"], shown["top"])
|
||||||
|
|
||||||
|
_run_js(canvas, qtbot, _EMIT_UNHOVER)
|
||||||
|
qtbot.wait(200)
|
||||||
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_state(raw: object) -> dict:
|
||||||
|
import json
|
||||||
|
|
||||||
|
assert isinstance(raw, str) and raw != "missing", f"marker element missing: {raw!r}"
|
||||||
|
return json.loads(raw)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,14 @@ def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignor
|
||||||
assert "camera" in scene
|
assert "camera" in scene
|
||||||
assert "range" in scene["xaxis"]
|
assert "range" in scene["xaxis"]
|
||||||
assert scene["xaxis"]["autorange"] is False
|
assert scene["xaxis"]["autorange"] is False
|
||||||
|
# plotly's camera lives in normalized scene units: the model is centred on
|
||||||
|
# the origin and the eye is only a few units out (not a data-unit distance,
|
||||||
|
# which parked the camera hundreds of units away and shrank the model).
|
||||||
|
camera = scene["camera"]
|
||||||
|
assert camera["center"] == {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||||
|
assert (
|
||||||
|
camera["eye"]["x"] ** 2 + camera["eye"]["y"] ** 2 + camera["eye"]["z"] ** 2
|
||||||
|
) ** 0.5 < 20.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
@ -121,3 +129,67 @@ def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-un
|
||||||
("warning", "[web] plot.html:8 careful"),
|
("warning", "[web] plot.html:8 careful"),
|
||||||
("info", "[web] plotly:9 hello"),
|
("info", "[web] plotly:9 hello"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_deformed_push_colours_by_scalar_and_ghosts_the_reference(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""The canvas wiring drives the builder's contour + ghost overlays."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from otko.services.deformation import DeformationSource
|
||||||
|
from otko.views.canvas3d.model_renderer import RendererMode
|
||||||
|
|
||||||
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
||||||
|
project = load_project(EXAMPLES / "cantilever.osmodel")
|
||||||
|
canvas.set_project(project)
|
||||||
|
ids = [node.id for node in project.nodes]
|
||||||
|
source = DeformationSource(
|
||||||
|
displacements=np.zeros((len(ids), 3)) + np.linspace(0, 1, len(ids))[:, None],
|
||||||
|
node_id_to_row={nid: i for i, nid in enumerate(ids)},
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.set_mode(RendererMode.DEFORMED, source)
|
||||||
|
canvas.set_show_undeformed(True)
|
||||||
|
calls.clear()
|
||||||
|
canvas.render()
|
||||||
|
|
||||||
|
payload, _preserve = _parse_call(calls[-1])
|
||||||
|
names = {trace.get("name") for trace in payload["data"]}
|
||||||
|
assert "undeformed-reference" in names
|
||||||
|
nodes = next(t for t in payload["data"] if t.get("name") == "nodes")
|
||||||
|
assert nodes["marker"]["showscale"] is True
|
||||||
|
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_animation_push_carries_prebuilt_frames(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""``animate`` must hand plotly a frame list, not drive Python re-renders."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from otko.services.deformation import DeformationSource
|
||||||
|
from otko.views.canvas_plotly.trace_builder import SceneOptions
|
||||||
|
|
||||||
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
||||||
|
project = load_project(EXAMPLES / "cantilever.osmodel")
|
||||||
|
canvas.set_project(project)
|
||||||
|
canvas._scene = canvas._builder.build(project, SceneOptions())
|
||||||
|
|
||||||
|
ids = [node.id for node in project.nodes]
|
||||||
|
options = [
|
||||||
|
SceneOptions(
|
||||||
|
deformation=DeformationSource(
|
||||||
|
displacements=np.full((len(ids), 3), float(k)),
|
||||||
|
node_id_to_row={nid: i for i, nid in enumerate(ids)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for k in (1.0, 2.0, 3.0)
|
||||||
|
]
|
||||||
|
canvas.animate(options, duration_ms=25, loops=4)
|
||||||
|
|
||||||
|
assert calls and calls[-1].startswith("window.otkoAnimate(")
|
||||||
|
body = calls[-1][len("window.otkoAnimate(") :]
|
||||||
|
literal, end = json.JSONDecoder().raw_decode(body)
|
||||||
|
payload = json.loads(literal)
|
||||||
|
assert len(payload["frames"]) == 3
|
||||||
|
assert payload["frames"][0]["data"], "frames must carry trace data"
|
||||||
|
assert body[end:].startswith(", 25, 4, true")
|
||||||
|
|
|
||||||
61
tests/gui/test_undeformed_reference.py
Normal file
61
tests/gui/test_undeformed_reference.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Smoke tests for the undeformed-reference overlay on both backends."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
|
from otko.core import ElasticBeamColumn, ElasticSection, Node, Project
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_project() -> Project:
|
||||||
|
b, h = 0.30, 0.50
|
||||||
|
return Project(
|
||||||
|
nodes=[
|
||||||
|
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, True, True, True, True)),
|
||||||
|
Node(id=2, coords=(6.0, 0.0, 0.0)),
|
||||||
|
],
|
||||||
|
sections=[
|
||||||
|
ElasticSection(
|
||||||
|
id=1,
|
||||||
|
name="Rect",
|
||||||
|
E=200e9,
|
||||||
|
A=b * h,
|
||||||
|
Iz=b * h**3 / 12.0,
|
||||||
|
Iy=h * b**3 / 12.0,
|
||||||
|
G=80e9,
|
||||||
|
J=1e-6,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_pyvista_undeformed_reference_overlay(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
from otko.services.deformation import DeformationSource
|
||||||
|
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||||
|
from otko.views.canvas3d.model_renderer import RendererMode
|
||||||
|
|
||||||
|
canvas = ModelCanvas()
|
||||||
|
qtbot.addWidget(canvas)
|
||||||
|
canvas.show_project(_frame_project())
|
||||||
|
|
||||||
|
# In MODEL mode there is nothing to reference — the toggle must be a no-op.
|
||||||
|
canvas.set_show_undeformed(True)
|
||||||
|
assert canvas._renderer._undeformed_actor is None
|
||||||
|
|
||||||
|
# A deformed shape gets a ghost wireframe, removed again on toggle off.
|
||||||
|
src = DeformationSource(
|
||||||
|
displacements=np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]]),
|
||||||
|
node_id_to_row={1: 0, 2: 1},
|
||||||
|
)
|
||||||
|
canvas._renderer.set_mode(RendererMode.DEFORMED, src)
|
||||||
|
canvas.render()
|
||||||
|
canvas.set_show_undeformed(True)
|
||||||
|
assert canvas._renderer._undeformed_actor is not None
|
||||||
|
|
||||||
|
canvas.set_show_undeformed(False)
|
||||||
|
assert canvas._renderer._undeformed_actor is None
|
||||||
|
|
@ -13,6 +13,7 @@ from otko.views.canvas3d.style import RenderStyle
|
||||||
from otko.views.canvas_plotly.trace_builder import (
|
from otko.views.canvas_plotly.trace_builder import (
|
||||||
PlotlyTraceBuilder,
|
PlotlyTraceBuilder,
|
||||||
SceneOptions,
|
SceneOptions,
|
||||||
|
framed_camera_distance,
|
||||||
)
|
)
|
||||||
|
|
||||||
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
||||||
|
|
@ -26,10 +27,6 @@ def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def]
|
||||||
return [trace for trace in scene.data if trace.get("name") == name]
|
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:
|
def test_builds_grid_nodes_and_frames() -> None:
|
||||||
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions())
|
||||||
names = {trace.get("name") for trace in scene.data}
|
names = {trace.get("name") for trace in scene.data}
|
||||||
|
|
@ -37,8 +34,35 @@ def test_builds_grid_nodes_and_frames() -> None:
|
||||||
assert "nodes" in names
|
assert "nodes" in names
|
||||||
assert "elements" in names
|
assert "elements" in names
|
||||||
assert scene.diagonal > 0
|
assert scene.diagonal > 0
|
||||||
# The hover-snap marker is always present so JS can restyle it.
|
# The snap marker is a DOM overlay, not a trace (a gl3d restyle costs
|
||||||
assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover"
|
# ~90 ms, which made a trace marker lag behind the cursor).
|
||||||
|
assert all(trace.get("meta", {}).get("kind") != "hover" for trace in scene.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_bounds_are_unpadded_model_extents() -> None:
|
||||||
|
"""Camera framing uses raw model bounds, so they must exclude the padding."""
|
||||||
|
scene = PlotlyTraceBuilder().build(_load("cantilever"), SceneOptions())
|
||||||
|
assert scene.data_bounds["x"] == pytest.approx((0.0, 5.0))
|
||||||
|
assert scene.data_bounds["y"] == pytest.approx((0.0, 0.0))
|
||||||
|
assert scene.data_bounds["z"] == pytest.approx((0.0, 0.0))
|
||||||
|
# Padded frame bounds stay wider than the raw model bounds.
|
||||||
|
assert scene.axis_bounds["x"][0] < scene.data_bounds["x"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_framed_camera_distance_is_scale_invariant() -> None:
|
||||||
|
"""plotly's camera is in normalized scene units, so framing must not grow
|
||||||
|
with the model's data-unit size (the old bug rendered models as specks)."""
|
||||||
|
project = _load("space_frame_3d")
|
||||||
|
small = PlotlyTraceBuilder().build(project, SceneOptions())
|
||||||
|
scaled = project.model_copy(deep=True)
|
||||||
|
for node in scaled.nodes:
|
||||||
|
x, y, z = node.coords
|
||||||
|
node.coords = (x * 1000.0, y * 1000.0, z * 1000.0)
|
||||||
|
large = PlotlyTraceBuilder().build(scaled, SceneOptions())
|
||||||
|
|
||||||
|
assert framed_camera_distance(large) == pytest.approx(framed_camera_distance(small), rel=1e-9)
|
||||||
|
# A normalized eye distance stays small (plotly's default eye is 1.25).
|
||||||
|
assert 1.0 < framed_camera_distance(small) < 20.0
|
||||||
|
|
||||||
|
|
||||||
def test_nodes_carry_ids_as_customdata() -> None:
|
def test_nodes_carry_ids_as_customdata() -> None:
|
||||||
|
|
@ -185,17 +209,19 @@ def test_payload_is_json_serialisable() -> None:
|
||||||
payload = json.dumps(scene.to_payload())
|
payload = json.dumps(scene.to_payload())
|
||||||
assert '"data"' in payload and '"layout"' in payload
|
assert '"data"' in payload and '"layout"' in payload
|
||||||
assert scene.layout["scene"]["aspectmode"] == "data"
|
assert scene.layout["scene"]["aspectmode"] == "data"
|
||||||
|
# Turntable keeps camera.up pinned to +Z (a level horizon); plotly's
|
||||||
|
# ``orbit`` would rotate the up vector and tip the model over.
|
||||||
|
assert scene.layout["scene"]["dragmode"] == "turntable"
|
||||||
|
|
||||||
|
|
||||||
def test_empty_project_yields_only_the_hover_marker() -> None:
|
def test_empty_project_yields_no_traces() -> None:
|
||||||
scene = PlotlyTraceBuilder().build(None, SceneOptions())
|
scene = PlotlyTraceBuilder().build(None, SceneOptions())
|
||||||
assert scene.data == []
|
assert scene.data == []
|
||||||
assert scene.hover_trace == -1
|
|
||||||
|
|
||||||
from otko.core import Project
|
from otko.core import Project
|
||||||
|
|
||||||
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
|
empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions())
|
||||||
assert _kinds(empty) == ["hover"]
|
assert empty.data == []
|
||||||
|
|
||||||
|
|
||||||
def test_frame_trace_meta_marks_elements_pickable() -> None:
|
def test_frame_trace_meta_marks_elements_pickable() -> None:
|
||||||
|
|
@ -260,3 +286,86 @@ def test_axis_outline_flag_toggles_grid_and_ticks() -> None:
|
||||||
assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
|
assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True
|
||||||
# The coloured axis lines stay visible either way (orientation cue).
|
# The coloured axis lines stay visible either way (orientation cue).
|
||||||
assert off_axis["visible"] is True and on_axis["visible"] is True
|
assert off_axis["visible"] is True and on_axis["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDeformation:
|
||||||
|
"""Minimal ``DeformationSource`` stand-in (keeps this test pyvista-free)."""
|
||||||
|
|
||||||
|
def __init__(self, rise: float = 0.0) -> None:
|
||||||
|
self._rise = rise
|
||||||
|
|
||||||
|
def shifted(self, points: np.ndarray, node_ids: list[int]) -> np.ndarray:
|
||||||
|
out = points.copy()
|
||||||
|
out[:, 2] += self._rise
|
||||||
|
return out
|
||||||
|
|
||||||
|
def magnitudes(self, node_ids: list[int]) -> np.ndarray:
|
||||||
|
return np.linspace(0.0, 1.0, len(node_ids)) if node_ids else np.zeros(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_loads_scale_with_magnitude_and_colour_by_pattern() -> None:
|
||||||
|
scene = PlotlyTraceBuilder().build(_load("space_frame_3d"), SceneOptions())
|
||||||
|
cones = [trace for trace in scene.data if trace.get("name") == "nodal-loads"]
|
||||||
|
assert len(cones) == 2, "one cone trace per load pattern"
|
||||||
|
lengths = sorted(
|
||||||
|
float(np.hypot(np.hypot(u, v), w))
|
||||||
|
for trace in cones
|
||||||
|
for u, v, w in zip(trace["u"], trace["v"], trace["w"], strict=True)
|
||||||
|
)
|
||||||
|
# 2.5e4 vs 5e4 kN → the second arrow is twice as long.
|
||||||
|
assert lengths[0] > 0.0
|
||||||
|
assert lengths[-1] == pytest.approx(2.0 * lengths[0], rel=1e-6)
|
||||||
|
assert cones[0]["colorscale"][0][1] != cones[1]["colorscale"][0][1]
|
||||||
|
assert "Pattern" in cones[0]["customdata"][0] or "|F|" in cones[0]["customdata"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_supports_use_dof_glyphs_not_markers() -> None:
|
||||||
|
for name in ("space_frame_3d", "basic_truss"):
|
||||||
|
scene = PlotlyTraceBuilder().build(_load(name), SceneOptions())
|
||||||
|
(supports,) = _traces(scene, "supports")
|
||||||
|
assert supports["type"] == "scatter3d"
|
||||||
|
assert supports["mode"] == "lines"
|
||||||
|
assert len(supports["x"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalar_colouring_adds_arrays_and_colorbar() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scalars = np.linspace(0.0, 3.0, len(project.nodes))
|
||||||
|
scene = PlotlyTraceBuilder().build(project, SceneOptions(scalars=scalars, scalar_label="|u|"))
|
||||||
|
|
||||||
|
(nodes,) = _traces(scene, "nodes")
|
||||||
|
assert isinstance(nodes["marker"]["color"], list)
|
||||||
|
assert nodes["marker"]["showscale"] is True
|
||||||
|
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
||||||
|
assert nodes["marker"]["cmin"] == pytest.approx(0.0)
|
||||||
|
assert nodes["marker"]["cmax"] == pytest.approx(3.0)
|
||||||
|
|
||||||
|
(frames,) = _traces(scene, "elements")
|
||||||
|
assert isinstance(frames["line"]["color"], list)
|
||||||
|
assert frames["line"]["cmin"] == pytest.approx(0.0)
|
||||||
|
# Scalar mode overrides the family palette.
|
||||||
|
assert "showscale" not in frames["line"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deformation_auto_colours_by_magnitude() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
scene = PlotlyTraceBuilder().build(
|
||||||
|
project, SceneOptions(deformation=_FakeDeformation(rise=0.5))
|
||||||
|
)
|
||||||
|
(nodes,) = _traces(scene, "nodes")
|
||||||
|
assert isinstance(nodes["marker"]["color"], list)
|
||||||
|
assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|"
|
||||||
|
|
||||||
|
|
||||||
|
def test_undeformed_reference_overlay_is_opt_in() -> None:
|
||||||
|
project = _load("cantilever")
|
||||||
|
deformation = _FakeDeformation(rise=0.5)
|
||||||
|
without = PlotlyTraceBuilder().build(project, SceneOptions(deformation=deformation))
|
||||||
|
assert not _traces(without, "undeformed-reference")
|
||||||
|
|
||||||
|
with_ghost = PlotlyTraceBuilder().build(
|
||||||
|
project, SceneOptions(deformation=deformation, show_undeformed=True)
|
||||||
|
)
|
||||||
|
(ghost,) = _traces(with_ghost, "undeformed-reference")
|
||||||
|
assert ghost["mode"] == "lines"
|
||||||
|
assert ghost["opacity"] < 1.0
|
||||||
|
|
|
||||||
307
vis_improvement.md
Normal file
307
vis_improvement.md
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
# Plotly visualisation improvements — handoff / working brief
|
||||||
|
|
||||||
|
Status: **implemented and verified; uncommitted** on branch `fix/plotly-orbit-lag`.
|
||||||
|
This file is a self-contained brief so a fresh LLM (or human) can continue the
|
||||||
|
work without re-deriving context. Read it top to bottom before touching code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objective
|
||||||
|
|
||||||
|
Make otko's **Plotly canvas** (`src/otko/views/canvas_plotly/`) feel and look
|
||||||
|
like opstool's plotly visualisation, which the user considers the gold
|
||||||
|
standard. Earlier in this session we already fixed the two foundational bugs:
|
||||||
|
|
||||||
|
1. **Camera framing** — `layout.scene.camera` is *not* in data units; it lives
|
||||||
|
in gl-plot3d's normalized scene space. We now compute a normalized eye
|
||||||
|
distance (`framed_camera_distance`) and frame on the **model** bounds
|
||||||
|
(grid excluded).
|
||||||
|
2. **Orbit / horizon** — scene uses `dragmode: "turntable"` (Z-up), and a JS
|
||||||
|
gesture layer matches the PyVista/VTK mouse bindings.
|
||||||
|
|
||||||
|
With those done, the user asked for 5 opstool-inspired improvements. **All 5
|
||||||
|
were selected and implemented in this session.** This document records what
|
||||||
|
landed, design decisions, and what still needs doing (verification, docs,
|
||||||
|
possible polish).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reference: opstool source (read-only clone)
|
||||||
|
|
||||||
|
Local clone (do **not** edit):
|
||||||
|
|
||||||
|
```
|
||||||
|
.slim/clonedeps/repos/yexiang92__opstool/opstool/vis/plotly/
|
||||||
|
```
|
||||||
|
|
||||||
|
Key files and what to read:
|
||||||
|
|
||||||
|
| File | Relevant content |
|
||||||
|
| --- | --- |
|
||||||
|
| `plot_utils.py` | `PLOT_ARGS_DEFAULT` (colours, sizes), `_plot_points_cmap` (~679), `_plot_lines_cmap` (~747), `_plot_unstru_cmap` (~837), `_make_lines_plotly` (~628) |
|
||||||
|
| `plot_resp_base.py` | `_get_plotly_dim_scene` (~220, camera/eye/2D recipe), `_make_lines_arrows` (~361), `_plot_bc` (~305), `_get_bc_points_3d/_2d` (~438–527) |
|
||||||
|
| `vis_model.py` | `plot_node_load` (~462), `plot_ele_load` (~515), `plot_beam_local_axes` (~406), `update_fig` (~633, theme/title) |
|
||||||
|
| `vis_nodal_resp.py` | `_create_mesh` (scalar contour + `show_origin` undeformed ghost ~57–169), `plot_anim` (~248), `_update_antimate_layout` / `_update_slider_layout` (~94, ~140) |
|
||||||
|
| `vis_frame_resp.py` | frame force contour + animation methods |
|
||||||
|
|
||||||
|
Important facts learned from the opstool source (do not re-litigate):
|
||||||
|
|
||||||
|
- plotly `scatter3d.marker` **and** `scatter3d.line` accept `coloraxis`,
|
||||||
|
`colorscale`, `cmin`, `cmax`, `showscale`, `colorbar`, and array `color`.
|
||||||
|
`layout.coloraxis` exists (plotly.py 7.1.0; it is a valid layout key even
|
||||||
|
though `Layout()._valid_props` doesn't list it directly).
|
||||||
|
- `mesh3d` supports `intensity`/`colorscale` but **not** `coloraxis`.
|
||||||
|
- opstool's contour colouring uses a shared `coloraxis` + `cmin/cmax` from the
|
||||||
|
response peak, with a `colorbar` carrying the component/unit title.
|
||||||
|
- opstool scales load arrows by `|F| * (min+max bound)/20 / max|F|` and tints
|
||||||
|
each load pattern (matplotlib `winter` / `turbo_r`).
|
||||||
|
- opstool draws supports as oriented line loops (plates/circles/triangles) per
|
||||||
|
restrained translation DOF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What was implemented (the 5 changes)
|
||||||
|
|
||||||
|
### #1 Scalar contour colouring + colorbar
|
||||||
|
|
||||||
|
**Where:** `src/otko/views/canvas_plotly/trace_builder.py`
|
||||||
|
|
||||||
|
- `SceneOptions` gained `scalars`, `scalar_label`, `scalar_clim`.
|
||||||
|
- `build()` auto-derives scalars when a deformation source exposes
|
||||||
|
`magnitudes(node_ids)` (see `DeformationSource.magnitudes` in
|
||||||
|
`src/otko/services/deformation.py`), label `"|u|"`.
|
||||||
|
- `_build_nodes` colours `marker.color` by scalar array with
|
||||||
|
`colorscale=self._style.response_colorscale()`, `cmin/cmax`,
|
||||||
|
`showscale=True`, and a `colorbar` titled `scalar_label`; hover shows the
|
||||||
|
value.
|
||||||
|
- `_build_frames` colours `line.color` per endpoint by scalar array.
|
||||||
|
- Helper `_scalar_clim()`.
|
||||||
|
|
||||||
|
**Effect:** deformed/modal views now show a displacement-magnitude contour with
|
||||||
|
a colourbar (verified by screenshot).
|
||||||
|
|
||||||
|
### #2 Undeformed "ghost" reference overlay
|
||||||
|
|
||||||
|
**Where:** `trace_builder.py` (`_build_undeformed_reference`),
|
||||||
|
`SceneOptions.show_undeformed`; `plotly_canvas.py`
|
||||||
|
(`set_show_undeformed`, `_show_undeformed`, applied in `_push_scene`);
|
||||||
|
`render_controls.py`; `menu_builder.py` (`_act_show_reference`, checkable,
|
||||||
|
Display menu); `main_window.py` (wiring + re-apply on canvas swap);
|
||||||
|
`canvas_base.py` (protocol method).
|
||||||
|
|
||||||
|
- Plotly: faint grey line trace behind the deformed shape.
|
||||||
|
- PyVista: `model_renderer.py` `_refresh_undeformed_overlay()` builds a grey
|
||||||
|
`pv.PolyData` wireframe from `_node_original_points` + frame connectivity;
|
||||||
|
`model_canvas.py` `set_show_undeformed`. (This made the previously duplicated
|
||||||
|
`DeformationSource` class in `model_renderer.py` redundant — it now imports
|
||||||
|
the one from `otko.services.deformation`.)
|
||||||
|
- **Display → Show Undeformed Reference** is enabled only in a post view and is
|
||||||
|
auto-cleared when returning to the model (`render_controls._reset_reference_overlay`).
|
||||||
|
|
||||||
|
### #3 Load magnitude scaling + per-pattern colours + hover
|
||||||
|
|
||||||
|
**Where:** `trace_builder.py` `_build_loads`, `_pattern_colors`,
|
||||||
|
`_PATTERN_PALETTE`, `_cone_trace` (now takes `customdata` + hovertemplate).
|
||||||
|
|
||||||
|
- Arrow length is proportional to `|F| / max|F|`, clamped to `[0.15·scale, scale]`
|
||||||
|
where `scale = 0.05 · model diagonal`. Previously all arrows were the same
|
||||||
|
length regardless of magnitude.
|
||||||
|
- One cone trace per load pattern, tinted from `_PATTERN_PALETTE`. With a
|
||||||
|
single pattern (nothing to distinguish) the per-type style tint is kept
|
||||||
|
(`nodal_load_color` / `element_load_color`).
|
||||||
|
- Hover: `"<pattern> · <N/E id><br>|F| = <mag>"` via `customdata`.
|
||||||
|
- Cone sizing uses `sizemode: "scaled"` with a small unitless `sizeref` (~0.12).
|
||||||
|
`"absolute"` interprets `sizeref` against the vector norm in *normalized*
|
||||||
|
scene units, which rendered oversized "fins" that hid the model on large
|
||||||
|
models (see the `snip4.png` report). `_cone_trace` docstring records this.
|
||||||
|
|
||||||
|
### #4 DOF-accurate support glyphs
|
||||||
|
|
||||||
|
**Where:** `trace_builder.py` `_build_supports`, `_support_loops`.
|
||||||
|
|
||||||
|
- Ported/modified from opstool `_get_bc_points_3d` / `_get_bc_points_2d`
|
||||||
|
(GPL-3.0). Each restrained translation axis → a plate perpendicular to it;
|
||||||
|
2D roller → circle; 2D ux&uy → triangle. Rotation-only supports fall back to
|
||||||
|
a square marker.
|
||||||
|
- Emits one `scatter3d` lines trace named `"supports"`.
|
||||||
|
- **NOTICE updated** to record this GPL port (already done).
|
||||||
|
|
||||||
|
### #5 In-page plotly frame animation
|
||||||
|
|
||||||
|
**Where:**
|
||||||
|
- `html.py`: `window.otkoAnimate(payloadJson, durationMs, loops, preserveView)`
|
||||||
|
and `window.otkoStopAnimation()`. Builds a name list, repeats it `loops`
|
||||||
|
times, `Plotly.react` → `addFrames` → `animate` (with `mergeView` to preserve
|
||||||
|
camera/ranges).
|
||||||
|
- `plotly_canvas.py`: `animate(options, duration_ms=40, loops=100)`,
|
||||||
|
`stop_animation()`, `base_scene_options()`;
|
||||||
|
`CanvasCapabilities(in_page_animation=True)`.
|
||||||
|
- `canvas_base.py`: `CanvasCapabilities.in_page_animation` (default False).
|
||||||
|
- `docks/mode_shape_animator.py`: new `playToggled(bool)` signal,
|
||||||
|
`period_seconds()`, `set_in_page_mode(bool)`; `_on_tick` updates the scrubber
|
||||||
|
but does **not** emit frames while `_in_page`.
|
||||||
|
- `dock_manager.py`: `_animate_mode_in_page()` builds 36 phase frames from
|
||||||
|
`modal_to_deformation` and calls `canvas.animate(...)` when the capability is
|
||||||
|
present; play/pause map to animate/stop.
|
||||||
|
- `render_controls._on_show_undeformed` calls `stop_animation()` if present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Files changed (all uncommitted)
|
||||||
|
|
||||||
|
```
|
||||||
|
M AGENTS.md
|
||||||
|
M NOTICE
|
||||||
|
M docs/QUICK_GUIDE.md
|
||||||
|
M src/otko/services/deformation.py # magnitudes()
|
||||||
|
M src/otko/views/action_handlers.py # (earlier) Mouse Controls dialog
|
||||||
|
M src/otko/views/canvas3d/model_canvas.py # set_show_undeformed
|
||||||
|
M src/otko/views/canvas3d/model_renderer.py # ghost overlay; import DeformationSource
|
||||||
|
M src/otko/views/canvas_base.py # protocol + capability
|
||||||
|
M src/otko/views/canvas_plotly/html.py # gestures, otkoAnimate
|
||||||
|
M src/otko/views/canvas_plotly/plotly_canvas.py # show_undeformed, animate
|
||||||
|
M src/otko/views/canvas_plotly/trace_builder.py # #1–#4
|
||||||
|
M src/otko/views/dialogs/__init__.py # MouseControlsDialog export
|
||||||
|
M src/otko/views/dialogs/quick_guide.py # nav hint
|
||||||
|
M src/otko/views/dock_manager.py # in-page animation
|
||||||
|
M src/otko/views/docks/mode_shape_animator.py # playToggled / in-page
|
||||||
|
M src/otko/views/main_window.py # reference toggle wiring
|
||||||
|
M src/otko/views/menu_builder.py # _act_show_reference
|
||||||
|
M src/otko/views/render_controls.py # enable/reset reference
|
||||||
|
M tests/gui/test_main_window.py
|
||||||
|
M tests/gui/test_plotly_hover.py
|
||||||
|
M tests/gui/test_plotly_view_preservation.py # animation push + deformed push tests
|
||||||
|
M tests/unit/test_plotly_trace_builder.py # #1–#4 tests
|
||||||
|
?? src/otko/views/dialogs/mouse_controls.py # earlier: Help dialog
|
||||||
|
?? tests/gui/test_plotly_gestures.py # earlier: gestures
|
||||||
|
?? tests/gui/test_undeformed_reference.py # PyVista ghost overlay
|
||||||
|
?? vis_improvement.md # this brief
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tests already added and passing
|
||||||
|
|
||||||
|
- `tests/unit/test_plotly_trace_builder.py`
|
||||||
|
- `test_loads_scale_with_magnitude_and_colour_by_pattern`
|
||||||
|
- `test_supports_use_dof_glyphs_not_markers`
|
||||||
|
- `test_scalar_colouring_adds_arrays_and_colorbar`
|
||||||
|
- `test_deformation_auto_colours_by_magnitude`
|
||||||
|
- `test_undeformed_reference_overlay_is_opt_in`
|
||||||
|
- (`_FakeDeformation` stand-in keeps the unit test free of pyvista/Qt)
|
||||||
|
- `tests/gui/test_plotly_view_preservation.py`
|
||||||
|
- `test_animation_push_carries_prebuilt_frames` — parses the emitted
|
||||||
|
`window.otkoAnimate(...)` payload with `json.JSONDecoder().raw_decode`.
|
||||||
|
- `tests/gui/test_plotly_gestures.py` — asserts `window.otkoAnimate` /
|
||||||
|
`window.otkoStopAnimation` exist in the page.
|
||||||
|
|
||||||
|
Last full gate (after all of the above, except the final two tests below):
|
||||||
|
`pytest -m "not slow"` → **100% pass, only pre-existing skips**; `ruff check`
|
||||||
|
→ the pre-existing 15 findings only; `mypy core/services` → the 3 pre-existing
|
||||||
|
environment errors.
|
||||||
|
|
||||||
|
Additional tests added during the follow-up verification pass:
|
||||||
|
|
||||||
|
- `tests/gui/test_undeformed_reference.py` — exercises the **PyVista** ghost
|
||||||
|
overlay (`_refresh_undeformed_overlay`) in MODEL vs DEFORMED mode and toggles
|
||||||
|
it off.
|
||||||
|
- `tests/gui/test_plotly_view_preservation.py::test_deformed_push_colours_by_scalar_and_ghosts_the_reference`
|
||||||
|
— canvas wiring: a DEFORMED push with `show_undeformed` carries the
|
||||||
|
`undeformed-reference` trace and a node colourbar titled `|u|`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What remains / known risks (DO THIS NEXT)
|
||||||
|
|
||||||
|
1. ~~Run the full gate.~~ **Done** — all green (see §5). Note: `pytest`
|
||||||
|
segfaults at interpreter teardown in this env (QtWebEngine "profile still
|
||||||
|
not deleted") **on the clean baseline too**; it is pre-existing, not caused
|
||||||
|
by these changes. CI (xvfb) should be fine.
|
||||||
|
2. **Manually eyeball in-page animation on a real display (only remaining
|
||||||
|
item).** Offscreen QtWebEngine throttles `requestAnimationFrame`, so
|
||||||
|
`Plotly.animate`'s promise may not resolve in tests. A dispatch probe
|
||||||
|
confirmed `addFrames(n)` and `animate(sequence)` are called with the right
|
||||||
|
sizes, but end-to-end playback (loop, pause, camera preservation) has **not**
|
||||||
|
been seen on-screen. Launch `python -m otko`, open a model with a modal
|
||||||
|
result, **Display → Animate Mode Shape → Play** on the Plotly backend and
|
||||||
|
confirm looping + Pause + Back-to-model. `loops=100` × 36 frames = 3600
|
||||||
|
frame names; reduce if it stutters.
|
||||||
|
3. **Animation stop path — verified from source.** `Plotly.animate(gd, [],
|
||||||
|
{mode:'immediate'})` calls `discardExistingFrames()` (plotly.js
|
||||||
|
`plot_api.js` `animate`), so `otkoStopAnimation` does interrupt. Interrupt
|
||||||
|
rejects the previous animate promise with no reason; the JS error handler
|
||||||
|
only logs truthy errors to avoid console noise.
|
||||||
|
4. **PyVista ghost overlay — tested** (see §5). Still worth a glance that it
|
||||||
|
does not interfere with node picking on-screen, but the actor is
|
||||||
|
`pickable=False`.
|
||||||
|
5. **Docs — done** for the new features (`docs/QUICK_GUIDE.md` §2/§3,
|
||||||
|
`AGENTS.md`, `NOTICE`).
|
||||||
|
6. **Optional polish (opstool parity not yet done)**
|
||||||
|
- scene title with model stats + `font.family` (opstool `update_fig`);
|
||||||
|
- **2D auto-scene for planar models — evaluated and deliberately skipped.**
|
||||||
|
Turntable's `updateFx` forces `camera.up = [0,0,1]`, so a top view's
|
||||||
|
screen-up is arbitrary (a planar XY truss renders rotated 90°: verified
|
||||||
|
via screenshot). Fixing it needs per-preset up handling that fights
|
||||||
|
turntable; not worth the risk.
|
||||||
|
- Max/Min response annotations (`show_max_min`);
|
||||||
|
- response component selector (currently only `|u|` magnitude);
|
||||||
|
- smoothing/interpolated beam displacement (opstool `interpolate_beam_disp`);
|
||||||
|
- colourbar units (currently label only, no unit string).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Architecture / constraints to respect
|
||||||
|
|
||||||
|
- Layering: `views → viewmodels → services → core`. `core` = stdlib+numpy+
|
||||||
|
pydantic; `services` may use h5py/openseespy; `views` = Qt/PyVista/plotly.
|
||||||
|
`trace_builder.py` is **pure** (no Qt, no pyvista, no plotly import) and is
|
||||||
|
unit-tested headless — keep it that way.
|
||||||
|
- Both canvas backends implement the `CanvasBackend` protocol
|
||||||
|
(`views/canvas_base.py`). If you add a consumer-facing method, add it to
|
||||||
|
**both** canvases and (ideally) the protocol.
|
||||||
|
- opstool is GPL-3.0; any ported code must be recorded in `NOTICE` (already
|
||||||
|
done for `_support_loops` and the colour/load recipes).
|
||||||
|
- Never commit `.osmodel` by hand (not relevant here).
|
||||||
|
- Do not commit changes unless the user asks. Conventional Commits style.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Useful commands / environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python lives in the repo venv (system pytest lacks otko on sys.path)
|
||||||
|
.venv/bin/python -c "import otko"
|
||||||
|
.venv/bin/pytest tests/unit/test_plotly_trace_builder.py -q
|
||||||
|
.venv/bin/pytest tests/gui/test_plotly_gestures.py -q # opens QtWebEngine
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendering screenshots for quick visual checks (used during development):
|
||||||
|
use in-page `Plotly.toImage('plot', {format:'png', width:900, height:640})`
|
||||||
|
(a promise; poll `window.__img` from Python and base64-decode). Offscreen
|
||||||
|
`qtbot` widgets do not need `show()`; WebGL works but rAF is throttled.
|
||||||
|
|
||||||
|
Quick smoke to inspect trace shapes without Qt:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from otko.services import load_project
|
||||||
|
from otko.views.canvas_plotly.trace_builder import PlotlyTraceBuilder, SceneOptions
|
||||||
|
scene = PlotlyTraceBuilder().build(load_project("examples/space_frame_3d.osmodel"), SceneOptions())
|
||||||
|
for t in scene.data:
|
||||||
|
print(t.get("name"), t["type"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. One-paragraph recap for a fresh LLM
|
||||||
|
|
||||||
|
The Plotly canvas now frames correctly (normalized camera, turntable, model
|
||||||
|
bounds), has VTK-parity mouse gestures, and five opstool-inspired upgrades:
|
||||||
|
scalar response colouring with a colourbar, an undeformed reference overlay,
|
||||||
|
magnitude-scaled per-pattern load arrows with hover, DOF-accurate support
|
||||||
|
glyphs, and in-page plotly frame animation. Everything is implemented and
|
||||||
|
covered by focused unit/GUI tests; the remaining work is running the full gate,
|
||||||
|
eyeballing the animation and PyVista ghost on a real display, and finishing
|
||||||
|
docs. Start by reading `trace_builder.py` (`SceneOptions`, `build`,
|
||||||
|
`_build_nodes`, `_build_frames`, `_build_supports`, `_build_loads`),
|
||||||
|
`canvas_plotly/html.py` (gestures + `otkoAnimate`), and
|
||||||
|
`canvas_plotly/plotly_canvas.py` (`_push_scene`, `_camera_dict`, `animate`).
|
||||||
Loading…
Reference in a new issue