diff --git a/AGENTS.md b/AGENTS.md index 2ca6f86..babac38 100644 Binary files a/AGENTS.md and b/AGENTS.md differ diff --git a/NOTICE b/NOTICE index 7264cda..24b2dd1 100644 --- a/NOTICE +++ b/NOTICE @@ -51,11 +51,8 @@ The canvas element colour palette in `src/otko/views/canvas3d/style.py` (per-family element colours and the diverging response colour scale) and the resulting diagram colouring in `views/canvas3d/diagram_renderer.py` were adapted from the `opstool` project, which is distributed under the **GNU -General Public License v3.0**. The support (boundary-condition) glyph -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. +General Public License v3.0**. opstool is Copyright © Yexiang Yan and +contributors. In accordance with GPLv3 §5(a)/(b) this notice records that the material was modified and adapted for OTKO. Combining the GPLv3-covered material with diff --git a/docs/QUICK_GUIDE.md b/docs/QUICK_GUIDE.md index a6ff736..0bc4b8c 100644 --- a/docs/QUICK_GUIDE.md +++ b/docs/QUICK_GUIDE.md @@ -66,9 +66,7 @@ correctly. periods, frequencies, and participation factors per mode. 4. **Display → Animate Mode Shape** to view each mode. Use the mode selector and the animation controls, and **Export…** if you want a - 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. + video of the mode shape. 5. Modal results also feed the response-spectrum case: define a response spectrum, then run the SRSS or CQC combination and open **Display → Show Response Spectrum**. @@ -79,20 +77,12 @@ 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 actions to inspect the model visually: -- **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 Deformed Shape** — with a scale slider. - **Display → Show Force Diagram…** — axial (P), shear (V2/V3), moment (M2/M3) diagrams. - **Display → Show Pushover Curve**, **Show Time-History**, **Show 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, export a script: @@ -132,23 +122,6 @@ 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 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 - [`architecture.md`](architecture.md) — MVVM layering and command order. diff --git a/src/otko/services/deformation.py b/src/otko/services/deformation.py index 084e2ab..8c60f7b 100644 --- a/src/otko/services/deformation.py +++ b/src/otko/services/deformation.py @@ -36,19 +36,6 @@ class DeformationSource: out[i] += self.scale * self.displacements[row] 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( project: Project, diff --git a/src/otko/views/action_handlers.py b/src/otko/views/action_handlers.py index f699036..4352b00 100644 --- a/src/otko/views/action_handlers.py +++ b/src/otko/views/action_handlers.py @@ -53,7 +53,6 @@ from otko.views.dialogs import ( MaterialLibraryDialog, MaterialTesterDialog, MirrorDialog, - MouseControlsDialog, MoveDialog, PathTimeSeriesDialog, PatternLoadsDialog, @@ -988,19 +987,6 @@ class ActionHandlers: dlg.raise_() 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: """Options → Set Display Units — SAP2000 parity. diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py index b6301a6..3e00425 100644 --- a/src/otko/views/canvas3d/model_canvas.py +++ b/src/otko/views/canvas3d/model_canvas.py @@ -342,11 +342,6 @@ class ModelCanvas(QtInteractor): # type: ignore[misc] self._renderer.set_show_local_axes(enabled) 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: """Swap the visual style and repaint the scene. diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 70babee..12c88cb 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -12,6 +12,7 @@ from __future__ import annotations import contextlib import enum +from dataclasses import dataclass from typing import Any import numpy as np @@ -32,7 +33,6 @@ from otko.core import ( ZeroLengthElement, ZeroLengthSectionElement, ) -from otko.services.deformation import DeformationSource from otko.views.canvas3d.style import ( SELECTED_STATE, RenderStyle, @@ -165,6 +165,23 @@ def _dof_indices(ndf: int) -> tuple[int, ...]: 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: """Glyphed-PolyData renderer with mode-aware deformation support.""" @@ -202,8 +219,6 @@ class ModelRenderer: self._hover_actor: Any = None # single yellow-ring snap marker self._show_section_extrusions: 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 # 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. @@ -267,13 +282,6 @@ class ModelRenderer: if self._project is not None: 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: """Swap the visual style and rebuild the scene it colours. @@ -1106,39 +1114,8 @@ class ModelRenderer: if self._frame_pd is not None: self._frame_pd.points = new_pts self._frame_pd.Modified() - self._refresh_undeformed_overlay() 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 ───────────────────────────────────────────────────── def _teardown_all(self) -> None: self._clear_label_actors() @@ -1149,10 +1126,6 @@ class ModelRenderer: for a in self._aux_actors: with contextlib.suppress(Exception): 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._frame_actor = None self._aux_actors.clear() diff --git a/src/otko/views/canvas_base.py b/src/otko/views/canvas_base.py index 544985f..0352961 100644 --- a/src/otko/views/canvas_base.py +++ b/src/otko/views/canvas_base.py @@ -45,9 +45,6 @@ class CanvasCapabilities: labels: bool = True #: Off-screen frame capture (mode-shape / time-history video export). 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): @@ -90,8 +87,6 @@ class CanvasBackend(Protocol): 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_default_selection_enabled(self, enabled: bool) -> None: ... diff --git a/src/otko/views/canvas_plotly/html.py b/src/otko/views/canvas_plotly/html.py index 80b4a88..46e145e 100644 --- a/src/otko/views/canvas_plotly/html.py +++ b/src/otko/views/canvas_plotly/html.py @@ -13,21 +13,9 @@ The JS side exposes three entry points to Python (called via - ``otkoSetCamera(cameraJson)`` — apply a camera alone (view presets, parallel-projection toggle). - ``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: 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 @@ -50,12 +38,6 @@ _PAGE = """ @@ -67,6 +49,7 @@ _PAGE = """ var bridge = null; var snapEnabled = false; var handlersReady = false; + var hoverIndex = -1; var config = { responsive: true, @@ -86,78 +69,44 @@ _PAGE = """ return data && data.meta ? data.meta.kind : null; } - // --- snap-target marker ------------------------------------------------ - // A pointer-events-none DOM dot positioned by projecting the snapped world - // point through gl-plot3d's own camera matrices. It deliberately is *not* a - // plotly trace: Plotly.restyle on a gl3d plot costs ~90 ms even for a single - // trace, so a trace marker lagged far behind the cursor and stalled orbiting. - // A projection plus one style write is a few microseconds. - var snapPoint = null; - var snapMarker = null; - - function snapMarkerElement() { - if (!snapMarker) { - snapMarker = document.createElement('div'); - snapMarker.id = 'otko-snap-marker'; - document.body.appendChild(snapMarker); - } - return snapMarker; - } - - function mat4Multiply(a, b) { - var out = new Array(16); - for (var col = 0; col < 4; col++) { - for (var row = 0; row < 4; row++) { - var sum = 0; - for (var k = 0; k < 4; k++) { sum += a[k * 4 + row] * b[col * 4 + k]; } - out[col * 4 + row] = sum; - } - } - return out; - } - - function projectToClient(point) { + function findHover() { 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 + 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; } } - var rect = canvas.getBoundingClientRect(); - return [rect.left + localX, rect.top + localY]; + // A freshly pushed figure has an empty hover marker again. + hoverShown = false; + hoverPoint = null; } - 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'; + // --- snap-target marker ------------------------------------------------ + // Updated synchronously but only when the visual state actually changes. + // Restyling on every hover/unhover made the plot redraw per mouse move, + // which re-fired hover and ended in a runaway redraw loop (RangeError: + // Maximum call stack size exceeded, frozen canvas). Timers were avoided + // deliberately: WebEngine throttles them to ~1 s when the page is not + // compositing, which made the preview lag. + var hoverShown = false; + var hoverPoint = null; + + function samePoint(a, b) { + return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; } - function refreshSnapMarker() { - if (snapPoint) setSnapMarker(snapPoint); + function setHoverMarker(point) { + var want = snapEnabled ? point : null; + if (samePoint(want, hoverPoint) && !!want === hoverShown) return; + if (!want && !hoverShown) { hoverPoint = null; return; } + hoverPoint = want; + hoverShown = !!want; + if (hoverIndex < 0) return; + var x = want ? [want[0]] : []; + var y = want ? [want[1]] : []; + var z = want ? [want[2]] : []; + Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]); } // --- view preservation ------------------------------------------------- @@ -229,8 +178,8 @@ _PAGE = """ mergeView(fig, job.preserve ? currentView() : null); Plotly.react('plot', fig.data, fig.layout, config).then(function () { + findHover(); installHandlers(); - logRendererOnce(); }, function (err) { if (window.console) console.error('otko: react failed', err); }).then(function () { @@ -239,24 +188,6 @@ _PAGE = """ }); } - // 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) { pendingUpdate = { payload: payloadJson, preserve: !!preserveView }; runUpdate(); @@ -287,166 +218,26 @@ _PAGE = """ var pts = ev.points || []; if (!pts.length) return; var p = pts[0]; - if (kindOf(p) !== 'snap') { setSnapMarker(null); return; } + if (kindOf(p) !== 'snap') { setHoverMarker(null); return; } var c = p.customdata; - if (c) setSnapMarker([c[0], c[1], c[2]]); + if (c) setHoverMarker([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 () { // Nothing can be visible unless snapping is armed, so stay out of the // redraw path entirely for ordinary mouse movement. if (!snapEnabled) return; - setSnapMarker(null); + setHoverMarker(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) { 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) { snapEnabled = !!on; - if (!snapEnabled) setSnapMarker(null); + if (!snapEnabled) setHoverMarker(null); }; })(); diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index 1ea3306..ddecb15 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -38,7 +38,6 @@ from otko.views.canvas_plotly.trace_builder import ( PlotlyTraceBuilder, Scene, SceneOptions, - framed_camera_distance, ) #: View preset directions (unit-ish vectors from the scene centre to the eye). @@ -150,9 +149,7 @@ class PlotlyCanvas(QWidget): #: Force diagrams and off-screen video capture are not implemented on #: this backend yet (both are PyVista-specific today). - capabilities = CanvasCapabilities( - diagrams=False, animation_export=False, in_page_animation=True - ) + capabilities = CanvasCapabilities(diagrams=False, animation_export=False) def __init__( self, @@ -173,7 +170,6 @@ class PlotlyCanvas(QWidget): self._parallel = False self._view_preset = "iso" self._snap_enabled = False - self._show_undeformed = False self._default_selection_enabled = True self._working_plane: tuple[str, float] | None = None self._camera = _CameraShim(self) @@ -312,45 +308,6 @@ class PlotlyCanvas(QWidget): self._options = replace(self._options, show_local_axes=bool(enabled)) 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: """Swap the visual style and repaint the figure. @@ -387,10 +344,7 @@ class PlotlyCanvas(QWidget): ``reset_camera``) sends the computed framing. Style changes are non-framing: they re-colour in place. """ - self._scene = self._builder.build( - self._project, - replace(self._options, show_undeformed=self._show_undeformed), - ) + self._scene = self._builder.build(self._project, self._options) if not self._ready: # Nothing to push yet; crucially this must come *before* the # framing flag is consumed, or the framing scheduled before @@ -409,31 +363,20 @@ class PlotlyCanvas(QWidget): self._eval(f"window.otkoUpdate({json.dumps(payload)}, {_js_bool(not re_framed)})") def _camera_dict(self, scene: Scene) -> dict[str, Any]: - """A framing camera in plotly's normalized scene coordinates. - - 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) + cx, cy, cz = scene.center + distance = max(scene.diagonal, 1e-6) * 1.6 direction = _VIEW_DIRECTIONS.get(self._view_preset, _VIEW_DIRECTIONS["iso"]) norm = math.sqrt(sum(component * component for component in direction)) or 1.0 eye = ( - direction[0] / norm * distance, - direction[1] / norm * distance, - direction[2] / norm * distance, + cx + direction[0] / norm * distance, + cy + direction[1] / norm * distance, + cz + direction[2] / norm * distance, ) - # Turntable dragmode pins up to +Z; the turntable controller derives a - # 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) + # Looking straight down the Z axis needs a non-degenerate up vector. + up = (0.0, 1.0, 0.0) if self._view_preset == "xy" else (0.0, 0.0, 1.0) return { "eye": {"x": eye[0], "y": eye[1], "z": eye[2]}, - "center": {"x": 0.0, "y": 0.0, "z": 0.0}, + "center": {"x": cx, "y": cy, "z": cz}, "up": {"x": up[0], "y": up[1], "z": up[2]}, "projection": {"type": "orthographic" if self._parallel else "perspective"}, } diff --git a/src/otko/views/canvas_plotly/trace_builder.py b/src/otko/views/canvas_plotly/trace_builder.py index 37c0577..fa5fc94 100644 --- a/src/otko/views/canvas_plotly/trace_builder.py +++ b/src/otko/views/canvas_plotly/trace_builder.py @@ -24,9 +24,7 @@ tagged ``meta={"kind": "node" | "element" | "snap"}`` and the JS side reads from __future__ import annotations -import math -from dataclasses import dataclass, field, replace -from itertools import pairwise +from dataclasses import dataclass, field from typing import Any import numpy as np @@ -78,7 +76,7 @@ _BOX_TRIS = ( (1, 6, 5), ) -#: Support kind → plotly 3D marker symbol (fallback for rotation-only supports). +#: Support kind → plotly 3D marker symbol. _SUPPORT_SYMBOLS = { "fix": "square", "pin": "triangle-up", @@ -90,18 +88,6 @@ _NODE_MARKER_SIZE = 7.0 _SUPPORT_MARKER_SIZE = 11.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) class SceneOptions: @@ -116,13 +102,6 @@ class SceneOptions: show_element_labels: bool = False show_extrusions: 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 @@ -133,11 +112,10 @@ class Scene: layout: dict[str, Any] center: tuple[float, float, float] = (0.0, 0.0, 0.0) diagonal: float = 1.0 + #: Trace index (in ``data``) of the empty hover-snap marker, or -1. + hover_trace: int = -1 #: Padded ``(min, max)`` per axis, used to frame the camera deterministically. axis_bounds: dict[str, tuple[float, float]] = field(default_factory=dict) - #: 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]: """Figure dict without the camera — camera is owned by the widget.""" @@ -237,54 +215,6 @@ def _diag_of_bounds(bounds: dict[str, tuple[float, float]]) -> float: 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]: """Local (y, z) basis — mirrors ``ModelRenderer._frame_basis``.""" x = x_local / float(np.linalg.norm(x_local)) @@ -334,104 +264,6 @@ def _classify_support(restraint: tuple[bool, ...], dof_idx: tuple[int, ...]) -> 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( segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], *, @@ -488,28 +320,12 @@ class PlotlyTraceBuilder: nodes = list(project.nodes) node_ids = [n.id for n in nodes] points = np.array([n.coords for n in nodes], dtype=float) if nodes else np.empty((0, 3)) - original_points = points.copy() if opts.deformation is not None and len(points): points = np.asarray(opts.deformation.shifted(points, node_ids), dtype=float) node_row = {nid: i for i, nid in enumerate(node_ids)} - # 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]] = [] 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_local_axes(project, data, opts) self._build_loads(project, data, opts) @@ -517,25 +333,21 @@ class PlotlyTraceBuilder: self._build_frames(project, data, opts, points, node_row) self._build_nodes(data, opts, points, node_ids) self._build_labels(project, data, opts, points, node_row) + hover_trace = self._build_hover_marker(data) - # Frame the structure, not the (often much larger) reference grid: - # SAP-style "zoom extents" fits the model. The grid still draws, but - # does not shrink the model into a corner of the viewport. - if len(points): - frame_pts = points - 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) + candidates = [points] if len(points) else [] + if grid_pts is not None: + candidates.append(grid_pts) + all_pts = np.vstack(candidates) if candidates else np.empty((0, 3)) + center = tuple(np.mean(all_pts, axis=0)) if len(all_pts) else (0.0, 0.0, 0.0) + bounds = _padded_axis_bounds(all_pts) return Scene( data=data, layout=self._layout(), center=(float(center[0]), float(center[1]), float(center[2])), diagonal=_diag_of_bounds(bounds), + hover_trace=hover_trace, axis_bounds=bounds, - data_bounds=_raw_axis_bounds(frame_pts), ) # ── layout ─────────────────────────────────────────────────────── @@ -568,10 +380,7 @@ class PlotlyTraceBuilder: "scene": { "bgcolor": style.background_bottom, "aspectmode": "data", - # 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", + "dragmode": "orbit", "xaxis": axis(style.fix_color, "X"), "yaxis": axis(style.load_color, "Y"), "zaxis": axis(style.truss_color, "Z"), @@ -721,41 +530,12 @@ class PlotlyTraceBuilder: ) -> None: if not len(points): return - scalars = opts.scalars - if scalars is not None and len(scalars) == len(points): - low, high = _scalar_clim(scalars, opts.scalar_clim) - marker: dict[str, Any] = { - "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]}
%{customdata[1]:.4g}" - 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}" + colors = [ + self._style.node_selected_color + if nid in opts.selection_nodes + else self._style.node_color + for nid in node_ids + ] data.append( { "type": "scatter3d", @@ -763,12 +543,16 @@ class PlotlyTraceBuilder: "x": [float(p[0]) for p in points], "y": [float(p[1]) for p in points], "z": [float(p[2]) for p in points], - "marker": marker, - "customdata": customdata, + "marker": { + "color": colors, + "size": _NODE_MARKER_SIZE, + "line": {"color": "#4d4d4d", "width": 1}, + }, + "customdata": list(node_ids), "meta": {"kind": "node"}, # Engineering-notation hover (opstool's trace recipe) so a # hover identifies the entity instead of showing the raw id. - "hovertemplate": hovertemplate, + "hovertemplate": "Node #%{customdata}", "name": "nodes", "showlegend": False, } @@ -793,16 +577,13 @@ class PlotlyTraceBuilder: """ if not len(points): 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) selected_index = len(scale_colors) - 1 x: list[float | None] = [] y: list[float | None] = [] z: list[float | None] = [] - color_value: list[float | None] = [] + color_index: list[float] = [] customdata: list[Any] = [] for el in project.elements: if not isinstance(el, _FRAME_CLASSES): @@ -811,40 +592,18 @@ class PlotlyTraceBuilder: j = node_row.get(el.nodes[1]) if i is None or j is None: continue + index = float( + selected_index if el.id in opts.selection_elements else element_family_index(el) + ) x.extend([float(points[i][0]), float(points[j][0]), None]) y.extend([float(points[i][1]), float(points[j][1]), None]) z.extend([float(points[i][2]), float(points[j][2]), None]) - 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]) + color_index.extend([index, index, index]) customdata.extend([el.id, el.id, None]) if not x: return - 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, - } + count = len(scale_colors) data.append( { "type": "scatter3d", @@ -852,7 +611,15 @@ class PlotlyTraceBuilder: "x": x, "y": y, "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, "meta": {"kind": "element"}, "hovertemplate": "Element #%{customdata}", @@ -861,103 +628,34 @@ 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( self, project: Project, data: list[dict[str, Any]], opts: SceneOptions ) -> 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: 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) + groups: dict[str, list[Any]] = {} for node in project.nodes: - restraint = tuple(bool(flag) for flag in node.restraint) - if not any(restraint[index] for index in dof_idx): + if not any(node.restraint[i] for i in dof_idx): continue - loops = _support_loops( - (float(node.coords[0]), float(node.coords[1]), float(node.coords[2])), - 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: + kind = _classify_support(node.restraint, dof_idx) + groups.setdefault(kind, []).append(node) + for kind, nodes in groups.items(): data.append( { "type": "scatter3d", "mode": "markers", - "x": [float(n.coords[0]) for n in markers], - "y": [float(n.coords[1]) for n in markers], - "z": [float(n.coords[2]) for n in markers], + "x": [float(n.coords[0]) for n in nodes], + "y": [float(n.coords[1]) for n in nodes], + "z": [float(n.coords[2]) for n in nodes], "marker": { "color": self._style.support_color, "size": _SUPPORT_MARKER_SIZE, - "symbol": "square", + "symbol": _SUPPORT_SYMBOLS[kind], "line": {"color": "#7f3f00", "width": 1}, }, "hoverinfo": "skip", - "name": "supports", + "name": f"support-{kind}", "showlegend": False, } ) @@ -966,12 +664,6 @@ class PlotlyTraceBuilder: def _build_loads( self, project: Project, data: list[dict[str, Any]], opts: SceneOptions ) -> 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: return node_by_id = {n.id: n for n in project.nodes} @@ -979,16 +671,22 @@ class PlotlyTraceBuilder: pts = np.array([n.coords for n in project.nodes], dtype=float) scale = max(_diag_of_points(pts) * 0.05, 1e-6) - patterns = [ - pattern for pattern in project.load_patterns if isinstance(pattern, PlainLoadPattern) - ] - if not patterns: - return + nodal_x: list[float] = [] + nodal_y: list[float] = [] + nodal_z: list[float] = [] + nodal_u: list[float] = [] + nodal_v: list[float] = [] + nodal_w: list[float] = [] + dist_x: list[float] = [] + dist_y: list[float] = [] + dist_z: list[float] = [] + dist_u: list[float] = [] + dist_v: list[float] = [] + dist_w: list[float] = [] - # (pattern index, tail, unit direction, magnitude, subject label) - nodal: list[tuple[int, np.ndarray, np.ndarray, float, str]] = [] - dist: list[tuple[int, np.ndarray, np.ndarray, float, str]] = [] - for pat_idx, pattern in enumerate(patterns): + for pattern in project.load_patterns: + if not isinstance(pattern, PlainLoadPattern): + continue for nload in pattern.nodal_loads: if not isinstance(nload, NodalLoad): continue @@ -999,15 +697,15 @@ class PlotlyTraceBuilder: mag = float(np.linalg.norm(f)) if mag < 1e-12: continue - nodal.append( - ( - pat_idx, - np.asarray(node.coords, dtype=float), - f / mag, - mag, - f"N{nload.node_id}", - ) - ) + direction = f / mag + tail = np.asarray(node.coords, dtype=float) - direction * scale + nodal_x.append(float(tail[0])) + nodal_y.append(float(tail[1])) + nodal_z.append(float(tail[2])) + nodal_u.append(float(direction[0])) + nodal_v.append(float(direction[1])) + nodal_w.append(float(direction[2])) + for eload in pattern.element_loads: if not isinstance(eload, UniformElementLoad): continue @@ -1036,60 +734,45 @@ class PlotlyTraceBuilder: if mag < 1e-12: continue direction = load_vec / mag - for k in range(5): - t = (k + 0.5) / 5.0 - dist.append((pat_idx, pi + t * axis, direction, mag, f"E{eload.element_id}")) + n_arrows = 5 + for k in range(n_arrows): + t = (k + 0.5) / n_arrows + tail = pi + t * axis - direction * (0.4 * scale) + dist_x.append(float(tail[0])) + dist_y.append(float(tail[1])) + dist_z.append(float(tail[2])) + dist_u.append(float(direction[0])) + dist_v.append(float(direction[1])) + dist_w.append(float(direction[2])) - max_mag = max((entry[3] for entry in (*nodal, *dist)), default=0.0) - if max_mag <= 0.0: - return - colors = self._pattern_colors(len(patterns)) - # With a single pattern there is nothing to distinguish, so keep the - # per-type default tints (nodal vs element loads). - single = len(patterns) == 1 - - def _arrow_len(mag: float) -> float: - return max(scale * mag / max_mag, 0.15 * 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}
|F| = {mag:.4g}") - data.append( - _cone_trace( - x, - y, - z, - u, - v, - w, - color=color, - name=name, - size=width, - customdata=hovers, - ) + if nodal_x: + data.append( + _cone_trace( + nodal_x, + nodal_y, + nodal_z, + nodal_u, + nodal_v, + nodal_w, + color=self._style.nodal_load_color, + name="nodal-loads", + size=scale, ) - - def _pattern_colors(self, count: int) -> list[str]: - """One palette colour per load pattern (used only when count > 1).""" - palette = _PATTERN_PALETTE - return [palette[index % len(palette)] for index in range(count)] + ) + if dist_x: + data.append( + _cone_trace( + dist_x, + dist_y, + dist_z, + dist_u, + dist_v, + dist_w, + color=self._style.element_load_color, + name="element-loads", + size=0.6 * scale, + ) + ) def _build_local_axes( self, project: Project, data: list[dict[str, Any]], opts: SceneOptions @@ -1135,10 +818,9 @@ class PlotlyTraceBuilder: bucket["x"].append(float(mid[0])) bucket["y"].append(float(mid[1])) bucket["z"].append(float(mid[2])) - # Cone length = (u, v, w) magnitude: scale to the model. - bucket["u"].append(float(direction[0] * cap)) - bucket["v"].append(float(direction[1] * cap)) - bucket["w"].append(float(direction[2] * cap)) + bucket["u"].append(float(direction[0])) + bucket["v"].append(float(direction[1])) + bucket["w"].append(float(direction[2])) for key, color in (("x", "#ff0000"), ("y", "#00bf00"), ("z", "#3366ff")): bucket = axes[key] if bucket["x"]: @@ -1152,7 +834,7 @@ class PlotlyTraceBuilder: bucket["w"], color=color, name=f"local-{key}", - size=0.12, + size=cap, ) ) @@ -1269,6 +951,28 @@ 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( x: list[float], @@ -1280,17 +984,9 @@ def _cone_trace( *, color: str, name: str, - size: float = 0.4, - customdata: list[Any] | None = None, + size: float, ) -> dict[str, Any]: - """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] = { + return { "type": "cone", "x": x, "y": y, @@ -1299,7 +995,7 @@ def _cone_trace( "v": v, "w": w, "anchor": "tail", - "sizemode": "scaled", + "sizemode": "absolute", "sizeref": float(size), "colorscale": [[0, color], [1, color]], "showscale": False, @@ -1307,11 +1003,6 @@ def _cone_trace( "name": name, "showlegend": False, } - if customdata is not None: - trace["customdata"] = customdata - trace["hovertemplate"] = "%{customdata}" - trace["hoverinfo"] = "text" - return trace def _text_trace( diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py index 59bfb83..e4dff02 100644 --- a/src/otko/views/dialogs/__init__.py +++ b/src/otko/views/dialogs/__init__.py @@ -30,7 +30,6 @@ from otko.views.dialogs.locate_origin import ( from otko.views.dialogs.material_library import MaterialLibraryDialog from otko.views.dialogs.material_tester import MaterialTesterDialog 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.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.pattern_loads import PatternLoadsDialog @@ -67,7 +66,6 @@ __all__ = [ "MaterialLibraryDialog", "MaterialTesterDialog", "MirrorDialog", - "MouseControlsDialog", "MoveDialog", "PathTimeSeriesDialog", "PatternLoadsDialog", diff --git a/src/otko/views/dialogs/mouse_controls.py b/src/otko/views/dialogs/mouse_controls.py deleted file mode 100644 index c3e9340..0000000 --- a/src/otko/views/dialogs/mouse_controls.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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 = """

Mouse Controls

-

The 3D viewport follows the VTK trackball convention on both canvas -backends (Options → Canvas Backend).

- -

Navigate the view

- - - - - - - -
RotateLeft-drag
PanShift + left-drag, or middle-drag
ZoomRight-drag, or mouse wheel
Spin (roll)Ctrl + left-drag
Zoom (from key)Ctrl + Shift + left-drag
Environment rotateShift + right-drag
-

Alt is not used for view navigation. Horizontal-wheel input rolls the -Plotly view; on PyVista it has no effect.

- -

Select

- - - -
SelectLeft-click a node or element
Add / toggleCtrl+click or Shift+click
-

A drag rotates the view; only a press-and-release without movement counts -as a click, so panning never changes the selection.

- -

View presets

- - - - - - -
IsometricCtrl+1
Top (XY)Ctrl+2
Front (XZ)Ctrl+3
Right (YZ)Ctrl+4
Zoom ExtentsCtrl+E
-""" - - -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) diff --git a/src/otko/views/dialogs/quick_guide.py b/src/otko/views/dialogs/quick_guide.py index 3e1af3e..9b0c424 100644 --- a/src/otko/views/dialogs/quick_guide.py +++ b/src/otko/views/dialogs/quick_guide.py @@ -50,10 +50,6 @@ or Assign → Frame: Section…, Material…, Show Force Diagram… and the other plot actions; Display → Show Undeformed Shape, Clear Display or Back to Model View (Ctrl+Shift+B) to return to the model.

- -

8 — Navigate the 3D view

-

Left-drag rotates, Shift+left-drag (or middle-drag) pans, right-drag or the -wheel zooms, Ctrl+left-drag spins. Full list under Help → Mouse Controls.

""" diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index 118da67..cd34449 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -197,44 +197,11 @@ class DockManager: animator.frameChanged.connect(_apply) 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( lambda: self._on_export_mode_shape(animator, _apply), ) # 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] """Capture one period of the current mode shape to MP4/GIF. diff --git a/src/otko/views/docks/mode_shape_animator.py b/src/otko/views/docks/mode_shape_animator.py index df40c93..4ecfc89 100644 --- a/src/otko/views/docks/mode_shape_animator.py +++ b/src/otko/views/docks/mode_shape_animator.py @@ -28,7 +28,6 @@ class ModeShapeAnimator(QWidget): """ frameChanged = Signal(int, float, float) - playToggled = Signal(bool) closed = Signal() exportRequested = Signal() @@ -42,7 +41,6 @@ class ModeShapeAnimator(QWidget): self._n_modes = n_modes self._freqs = frequencies_hz self._t = 0.0 - self._in_page = False self._timer = QTimer(self) self._timer.timeout.connect(self._on_tick) self._timer.setInterval(int(1000 / self._FPS)) @@ -113,17 +111,6 @@ class ModeShapeAnimator(QWidget): def current_scale(self) -> float: 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: """Rebind to a new modal run while the dock stays open. @@ -187,8 +174,6 @@ class ModeShapeAnimator(QWidget): self._play_btn.setText("▶ Play") self._timer.stop() self._scrubber.setEnabled(True) - self._in_page = False - self.playToggled.emit(playing) def _on_stop(self) -> None: self._timer.stop() @@ -216,8 +201,6 @@ class ModeShapeAnimator(QWidget): self._scrubber.blockSignals(True) self._scrubber.setValue(int(round(phase * 100))) 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) def _emit_static_frame(self) -> None: diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index 6e7a054..5241869 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -230,7 +230,6 @@ class MainWindow( # Re-apply the overlays whose state lives on the toolbar actions. self._canvas.set_show_section_extrusions(self._act_show_extruded.isChecked()) self._canvas.set_show_local_axes(self._act_show_local_axes.isChecked()) - self._canvas.set_show_undeformed(self._act_show_reference.isChecked()) self._canvas.set_display_options( show_node_labels=self._show_node_labels, show_element_labels=self._show_element_labels, @@ -309,7 +308,6 @@ class MainWindow( self._act_quit.triggered.connect(self.close) self._act_about.triggered.connect(self._on_about) 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_plot_properties.triggered.connect(self._on_plot_properties) @@ -388,9 +386,6 @@ class MainWindow( 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_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. self._vm.projectChanged.connect(self._on_project_changed) diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index bd8fc69..5e7a271 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -141,11 +141,6 @@ class MenuBuilder: "Exit any deformed / mode-shape / force-diagram view back to " "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.setToolTip( "Neutral canvas: undeformed geometry, no overlays, selection " @@ -196,7 +191,6 @@ class MenuBuilder: self._act_about = QAction("&About OTKO…", self) # No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run. 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_plot_properties = QAction("&Plot Properties…", self) self._act_plot_properties.setToolTip( @@ -487,7 +481,6 @@ class MenuBuilder: m_display.addSeparator() m_display.addAction(self._act_back_to_model) m_display.addAction(self._act_show_undeformed) - m_display.addAction(self._act_show_reference) m_display.addAction(self._act_clear_display) m_view = mb.addMenu("&View") @@ -531,7 +524,6 @@ class MenuBuilder: m_help = mb.addMenu("&Help") m_help.addAction(self._act_quick_guide) - m_help.addAction(self._act_mouse_controls) m_help.addAction(self._act_about) def _build_view_toolbar(self) -> None: diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index 01886d3..06db479 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -84,22 +84,9 @@ class RenderControls: self._tear_down_post_dock() if self._diagram_renderer is not None: 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.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: """Clear Display: neutral canvas — undeformed MODEL, no selection. @@ -526,7 +513,6 @@ class RenderControls: # Undeformed exits any post view (dock, renderer mode or diagram # 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_reference.setEnabled(has_project and self._in_post_view()) self._act_clear_display.setEnabled(has_project) def _log(self, message: str) -> None: diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index 9a8d023..a57a00f 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -39,21 +39,3 @@ def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignor assert not window.isVisible() assert window.close() is True 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 diff --git a/tests/gui/test_plotly_gestures.py b/tests/gui/test_plotly_gestures.py deleted file mode 100644 index e15d287..0000000 --- a/tests/gui/test_plotly_gestures.py +++ /dev/null @@ -1,166 +0,0 @@ -"""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 - ) diff --git a/tests/gui/test_plotly_hover.py b/tests/gui/test_plotly_hover.py index f1ff273..78d5160 100644 --- a/tests/gui/test_plotly_hover.py +++ b/tests/gui/test_plotly_hover.py @@ -1,10 +1,10 @@ -"""Regression tests for the Plotly snap-hover marker. +"""Regression tests for the Plotly hover/snap-marker contract. -``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured -in-page), so the marker used to lag far behind the cursor and stall orbiting. -It is now a pointer-events-none DOM overlay positioned by projecting the snapped -world point through gl-plot3d's own camera matrices, which costs microseconds -and issues no plotly calls at all. +``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves. +Touching the plot on each one made it redraw per mouse move, re-firing hover +until the stack blew (``RangeError: Maximum call stack size exceeded``) and the +canvas froze. The marker is therefore coalesced and only touched when its +shown/hidden state actually changes. """ from __future__ import annotations @@ -35,12 +35,10 @@ _CHURN_PROBE = """(function(){ } finally { Plotly.restyle = original; } })()""" -_MARKER_STATE = """(function(){ - var marker = document.getElementById('otko-snap-marker'); - if (!marker) return 'missing'; - return JSON.stringify({ display: marker.style.display, left: marker.style.left, - top: marker.style.top }); -})()""" +_HOVER_MARKER_X = ( + "JSON.stringify((document.getElementById('plot').data" + ".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)" +) _EMIT_SNAP_HOVER = """(function(){ var gd = document.getElementById('plot'); @@ -50,8 +48,6 @@ _EMIT_SNAP_HOVER = """(function(){ return true; })()""" -_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true" - def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def] from otko.views.canvas_plotly import PlotlyCanvas @@ -73,7 +69,7 @@ def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[n @pytest.mark.gui def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def] - """The original handler restyled on every hover/unhover event.""" + """The unfixed version restyled once per hover/unhover event.""" canvas = _open_canvas(qtbot, "space_frame_3d.osmodel") restyles = _run_js(canvas, qtbot, _CHURN_PROBE) @@ -82,45 +78,16 @@ def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore @pytest.mark.gui -def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def] +def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def] canvas = _open_canvas(qtbot, "basic_truss.osmodel") - 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" + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]" canvas.set_snap_preview_enabled(True) - qtbot.wait(150) + qtbot.wait(100) _run_js(canvas, qtbot, _EMIT_SNAP_HOVER) - qtbot.wait(200) - 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 + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown" - # Orbiting moves the target on screen without any plotly redraw. A top - # view is used because a point near the scene centre projects to nearly - # 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) + _run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})") + qtbot.wait(250) + assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared" diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py index 4bed58a..97ef3ee 100644 --- a/tests/gui/test_plotly_view_preservation.py +++ b/tests/gui/test_plotly_view_preservation.py @@ -55,14 +55,6 @@ def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignor assert "camera" in scene assert "range" in scene["xaxis"] 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 @@ -129,67 +121,3 @@ def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-un ("warning", "[web] plot.html:8 careful"), ("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") diff --git a/tests/gui/test_undeformed_reference.py b/tests/gui/test_undeformed_reference.py deleted file mode 100644 index 0277548..0000000 --- a/tests/gui/test_undeformed_reference.py +++ /dev/null @@ -1,61 +0,0 @@ -"""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 diff --git a/tests/unit/test_plotly_trace_builder.py b/tests/unit/test_plotly_trace_builder.py index a33eb8e..45b2b21 100644 --- a/tests/unit/test_plotly_trace_builder.py +++ b/tests/unit/test_plotly_trace_builder.py @@ -13,7 +13,6 @@ from otko.views.canvas3d.style import RenderStyle from otko.views.canvas_plotly.trace_builder import ( PlotlyTraceBuilder, SceneOptions, - framed_camera_distance, ) EXAMPLES = Path(__file__).resolve().parents[2] / "examples" @@ -27,6 +26,10 @@ def _traces(scene, name: str) -> list[dict]: # type: ignore[no-untyped-def] return [trace for trace in scene.data if trace.get("name") == name] +def _kinds(scene) -> list[str]: # type: ignore[no-untyped-def] + return [trace.get("meta", {}).get("kind", trace["type"]) for trace in scene.data] + + def test_builds_grid_nodes_and_frames() -> None: scene = PlotlyTraceBuilder().build(_load("basic_truss"), SceneOptions()) names = {trace.get("name") for trace in scene.data} @@ -34,35 +37,8 @@ def test_builds_grid_nodes_and_frames() -> None: assert "nodes" in names assert "elements" in names assert scene.diagonal > 0 - # The snap marker is a DOM overlay, not a trace (a gl3d restyle costs - # ~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 + # The hover-snap marker is always present so JS can restyle it. + assert scene.data[scene.hover_trace]["meta"]["kind"] == "hover" def test_nodes_carry_ids_as_customdata() -> None: @@ -209,19 +185,17 @@ def test_payload_is_json_serialisable() -> None: payload = json.dumps(scene.to_payload()) assert '"data"' in payload and '"layout"' in payload 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_no_traces() -> None: +def test_empty_project_yields_only_the_hover_marker() -> None: scene = PlotlyTraceBuilder().build(None, SceneOptions()) assert scene.data == [] + assert scene.hover_trace == -1 from otko.core import Project empty = PlotlyTraceBuilder().build(Project(ndm=3, ndf=6), SceneOptions()) - assert empty.data == [] + assert _kinds(empty) == ["hover"] def test_frame_trace_meta_marks_elements_pickable() -> None: @@ -286,86 +260,3 @@ def test_axis_outline_flag_toggles_grid_and_ticks() -> None: assert on_axis["showgrid"] is True and on_axis["showticklabels"] is True # The coloured axis lines stay visible either way (orientation cue). assert off_axis["visible"] is True and on_axis["visible"] is True - - -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 diff --git a/vis_improvement.md b/vis_improvement.md deleted file mode 100644 index d1ef9a7..0000000 --- a/vis_improvement.md +++ /dev/null @@ -1,307 +0,0 @@ -# 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: `" ·
|F| = "` 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`).