Orbiting and the snap preview felt far behind the cursor. Measured in the page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a one-trace figure, so every marker update stalled the scene and the marker trailed the mouse; each one also queued another redraw while the user was dragging. The marker is now a pointer-events-none div positioned by projecting the snapped world point through glplot.cameraParams (validated: the camera centre lands at the canvas centre), updated with one style write and re-projected on plotly_relayout so it stays glued to the target during orbit. No plotly calls at all on the hover path - 200 mouse-move events now cost 0 restyles - and the empty hover trace is gone from the figure. Also logs the WebGL renderer once (via the console bridge) since hardware acceleration decides how smooth orbit feels and is otherwise invisible.
6.7 KiB
AGENTS.md — otko
Pre-alpha SAP2000-style desktop GUI for OpenSeesPy. Python 3.10+; Windows requires 3.12+ (openseespywin==3.8.0.0 has no 3.11 wheel). Solver pinned: openseespy==3.8.0.0.
Install
pip install -e ".[gui,dev]" # desktop: Qt + PyVista + plotly.js backend + dev tools
pip install -e . # headless: core + services only, no Qt (scripts, notebooks, web backends)
python -m otko # launch GUI (src/otko/app.py:run)
scipy/pandas in [gui] extras are phantom deps (not imported as of 2026-06) — do not add imports expecting them.
Architecture (enforced in review — see PR template)
Strict one-way MVVM + services: views → viewmodels → services → core.
core/(entities:project.py,geometry/,materials/,sections/,loads/,analysis/,catalog/): stdlib + numpy + pydantic only. No Qt, no openseespy. Period.services/(opensees_runner.py,persistence.py,results.py, ...): may use core + h5py + openseespy. No Qt.views/: PySide6/pyvistaqt only. No directimport openseespy— go through a service.views/canvas3d/(PyVista/VTK, default) andviews/canvas_plotly/(plotly.js in aQWebEngineView) are two backends for the same central 3D view. Both satisfy theCanvasBackendprotocol inviews/canvas_base.py, share oneSelectionStateowned byMainWindow, and are swapped live via Options → Canvas Backend (persisted inQSettingsundercanvas/backend).MainWindow._activate_canvaskeeps both widgets in aQStackedWidget— never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared byCanvasCapabilities(e.g. Plotly has no force-diagram overlay or video export yet); gate UI oncanvas.capabilities, never on the backend name.canvas_plotly/trace_builder.pyis pure (no Qt, no pyvista) and unit-tested headless.PlotlyCanvaspushes withPlotly.react, which resets scene attributes the layout omits: data-only pushes passpreserveViewsohtml.pycarries the live camera and padded axis ranges forward, and never re-sendscene.camera. The snap-hover marker is apointer-events-noneDOM overlay positioned by projecting the target throughglplot.cameraParams— not a trace, because a gl3dPlotly.restylecosts ~90 ms per call (measured, even for one trace), which made the marker lag behind the cursor and stall orbiting. Regression tests:tests/gui/test_plotly_view_preservation.py,tests/gui/test_plotly_hover.py.views/canvas3d/style.py(RenderStyle) is the single source of truth for colours/sizes on both backends; renderers must read it rather than hard-coding a colour. TheSTYLE_FIELDSsubset is user-editable via Options → Plot Properties… (views/dialogs/plot_properties.py), previews live throughMainWindow.set_plot_style(..., persist=False), and persists as JSON inQSettingsunderplot/props.viewmodels/bridges core↔Qt (signals,QUndoStack);commands/holdsQUndoCommandsubclasses.- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (
AnalysisWorkerin QThread, cancel viaisInterruptionRequested(), results cross threads as lightweightResultsHandleto HDF5).
Runner emits OpenSeesPy commands in fixed order (docs/architecture.md): wipe → model → node → fix → material → section → geomTransf → element → timeSeries → pattern/load → recorder → system/numberer/... → analyze. Never reorder.
Verify (in this order)
ruff check src tests
ruff format src tests # line-length 100, E501 ignored
mypy src/otko/core src/otko/services
pytest -m "not slow" # CI gate: lint → this, on 3.10/3.11/3.12 × ubuntu/windows/macos
Focused runs: pytest tests/unit (pure logic, ms), pytest tests/gui -k <name> (pytest-qt, needs display), pytest tests/integration -k <name> (real OpenSeesPy runs). Single test: pytest tests/unit/test_project.py::test_name -q. Markers: gui, slow. Linux GUI tests need xvfb-run -a pytest ... plus system Qt libs (see ci.yml apt list).
Notes: tests/conftest.py auto-ops.wipe()s the OpenSees domain between tests (lazy import so core tests stay Qt/OpenSees-free). Coverage omits views/. Commit style: Conventional Commits (feat:, fix:, ...). pre-commit install runs ruff + mypy (mypy hook scoped to core|services|viewmodels).
Git & Forgejo
Remote is a self-hosted Forgejo, SSH-only:
git remote add origin ssh://git@smill-home.ddns.net/smill/otko.git
- Web UI / HTTPS clone:
https://smill-home.ddns.net/forgejo/smill/otko.git(note the/forgejosubpath — SSH URLs don't have it). - Forgejo SSH runs through the system sshd on port 22 (
SSH_PORT = 22in the server'sapp.ini). If the site ever advertises a:2222SSH URL again, that config regressed — push/pull with port 22 anyway and flag it. - Auth is by SSH key (Forgejo → Settings → SSH Keys). Test:
ssh -T git@smill-home.ddns.netshould answerHi there, <user>! ... Forgejo does not provide shell access. - Default branch
main. Work on short-lived branches (fix/<topic>,docs/<topic>), open a PR againstmain. Never commit directly tomainfrom an agent session. - Repo root is
otko/itself. (On the dev laptop there is also an empty, commit-less parent repo atSync/coding/— ignore it; all git work happens insideotko/.)
Examples & persistence
examples/*.pyare source of truth;examples/*.osmodelare generated artifacts (checked in). Never hand-edit.osmodel— change the script and regen:python examples/cantilever.py(each script saves, reloads, asserts clean round-trip).- Projects persist as single Pydantic-validated JSON
.osmodel(diffable); analysis output goes to*.osresults.h5(HDF5, one group per case). - Quick smoke: open
examples/cantilever.osmodel→ runTip-Load→ M3 peaks 50 kN·m at fixed end.
Cloned Dependency Source
Read-only dependency source repositories are available under
.slim/clonedeps/repos/ for inspection. Do not edit these clones. The
structured manifest is .slim/clonedeps.json.
.slim/clonedeps/repos/yexiang92__opstool/—yexiang92/opstoolatv1.0.26; the OpenSeesPy pre/post-processor whose PyVista and Plotly visualization settings (opstool/vis/{pyvista,plotly}/plot_utils.py,plot_resp_base.py,vis_model.py) are the reference for otko's canvas look-and-feel. GPL-3.0, and GPLv3 §13 explicitly permits combining it with otko's AGPL-3.0. Any code actually ported from it must keep the opstool copyright notice and record that it was modified (GPLv3 §5a/b) — add that entry toNOTICEwhen the port lands.