Compare commits
7 Commits
4dad413d29
...
720bd81031
| Author | SHA1 | Date | |
|---|---|---|---|
| 720bd81031 | |||
| b8dc5e925b | |||
| fa2703968e | |||
| bcab2790bf | |||
| 3daee6d407 | |||
| 12781854ca | |||
| 6effe74172 |
@@ -25,9 +25,17 @@ apps/studio/preference.json
|
||||
shared/tests/_scratch/
|
||||
shared/tests/_scratch_*/
|
||||
|
||||
# Working artefacts: screenshots and probe output from 3D/UI diagnosis, and assets parked
|
||||
# out of the shipping path. None of these are inputs to a build or a release.
|
||||
.snap/
|
||||
_parked_stale_assets/
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Agent scratch: one directory per task, see D:\orisys_ws\AGENTS.md
|
||||
.scratch/
|
||||
|
||||
+23
-2
@@ -112,6 +112,17 @@ calls them:
|
||||
|
||||
- **Motion is refused** while the bus is not initialised, while the axis is
|
||||
unknown, or on a non-finite target — with a reason, never a silent no-op.
|
||||
- **A pose move is refused before the first axis moves** if any part of it is wrong:
|
||||
a target past the P0 limit — millimetres for x/y/z, **degrees** for yaw/pitch, so a
|
||||
400° stage turn cannot pass a 100 mm check — an axis mapping that is incomplete,
|
||||
points outside the drive range, or claims one drive twice, or a rotary role on an
|
||||
axis with no user-units-per-degree calibration. A sequence that stopped halfway
|
||||
would leave the machine in a pose nobody simulated, so every check runs first.
|
||||
- **A move is stepped, not looped.** The drives are commanded one at a time from the
|
||||
poll snapshots; a step that stalls (idle but short of its target), times out or
|
||||
raises an alarm aborts the rest. That is also what makes **stop** work — it is an
|
||||
ordinary command, so it runs at the next poll tick, and the queue is cancelled
|
||||
before it is sent so no later axis is commanded.
|
||||
- **Homing needs a configured mode.** The vendor example pre-fills `17`, which
|
||||
is one specific drive's mode; AutoCal starts at `0` and refuses, so the
|
||||
operator chooses for the drive in front of them instead of inheriting a number
|
||||
@@ -157,13 +168,22 @@ between threads. The UI talks to it through signals only.
|
||||
Both work with no display, no controller and no DLL:
|
||||
|
||||
```powershell
|
||||
python .\apps\autocal\tools\panel_check.py # 170 layout/theme/locale/gating checks
|
||||
python .\apps\autocal\tools\panel_check.py # layout/theme/locale/gating checks
|
||||
python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png
|
||||
python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab
|
||||
python .\apps\autocal\tools\panel_snapshot.py --setup-dialog bus --out setup.png
|
||||
python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
|
||||
python -m pytest apps\autocal\tests # offscreen suite
|
||||
```
|
||||
|
||||
Connection and bus initialisation are used once per machine start-up, so they live
|
||||
in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail:
|
||||
the rail carries what is read while working — the axis readout and the collapsed
|
||||
SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the
|
||||
connection/bus/init chips (each opens the tab that explains it) or from
|
||||
文件 → 设备设置, and the strip keeps reporting the live state whether it is open or
|
||||
shut.
|
||||
|
||||
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
|
||||
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
|
||||
the pane is empty in those shots. It needs the real platform plugin and a visible
|
||||
@@ -175,7 +195,8 @@ composited in. What the view draws, and what each colour means, is in
|
||||
proposed single-page layout with the shared theme tokens and imports nothing
|
||||
from the app, so the layout can be reviewed and marked up without building the
|
||||
panels. It draws the full page plus previews of the other workspace tabs in one
|
||||
canvas.
|
||||
canvas, and `--page setup` draws the device-setup dialog over the page it opens
|
||||
from.
|
||||
|
||||
`panel_check.py` builds the real window in both themes at 1440×900 and
|
||||
1920×1080, asserts that no widget escapes its parent, that motion is
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"device_setup": "Device setup…",
|
||||
"open_bas": "Download BAS init program…",
|
||||
"reveal_config": "Show config in Explorer…",
|
||||
"quit": "Quit",
|
||||
@@ -27,6 +28,45 @@
|
||||
"help": "Help",
|
||||
"about": "About AutoCal"
|
||||
},
|
||||
"setup": {
|
||||
"open": "Device setup",
|
||||
"title": "Device setup",
|
||||
"hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result, and this window can stay open while you work.",
|
||||
"tab_connection": "Connection",
|
||||
"tab_bus": "Bus initialisation",
|
||||
"tab_axes": "Axis mapping",
|
||||
"axes_hint": "The bus reports axis numbers, not what each one moves. The mapping below comes from the bench wiring, and it must be complete and free of duplicates before anything moves.",
|
||||
"role_x": "X (table)",
|
||||
"role_y": "Y (table)",
|
||||
"role_z": "Z (press)",
|
||||
"role_yaw": "yaw (rotation)",
|
||||
"role_pitch": "pitch (tilt)",
|
||||
"role_axis": "Drive",
|
||||
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
|
||||
"close": "Close",
|
||||
"chip_hint": "Open device setup — {name}"
|
||||
},
|
||||
"move": {
|
||||
"execute": "Execute",
|
||||
"execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.",
|
||||
"stop": "Stop",
|
||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||
"return_initial": "Return to zero",
|
||||
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
||||
"confirm_title": "Confirm motion",
|
||||
"confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
||||
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
|
||||
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
||||
"cancelled": "Cancelled; nothing was commanded",
|
||||
"confirm_check": "Confirm before moving",
|
||||
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
|
||||
"no_point": "No calibration point is selected",
|
||||
"busy": "A move is already running",
|
||||
"refused": "Motion refused",
|
||||
"done": "Move finished",
|
||||
"failed": "Move failed",
|
||||
"stopped": "Stopped; the remaining steps were cancelled"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Controller connection",
|
||||
"mode": "Connection",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"menu": {
|
||||
"file": "文件",
|
||||
"device_setup": "设备设置…",
|
||||
"open_bas": "下载 BAS 初始化程序…",
|
||||
"reveal_config": "在资源管理器中显示配置…",
|
||||
"quit": "退出",
|
||||
@@ -27,6 +28,45 @@
|
||||
"help": "帮助",
|
||||
"about": "关于 AutoCal"
|
||||
},
|
||||
"setup": {
|
||||
"open": "设备设置",
|
||||
"title": "设备设置",
|
||||
"hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"tab_connection": "连接",
|
||||
"tab_bus": "总线初始化",
|
||||
"tab_axes": "轴映射",
|
||||
"axes_hint": "总线只报告轴号,不报告它推的是哪个方向。这里的对应关系来自实际接线,运动前必须完整且不重复。",
|
||||
"role_x": "X(工作台)",
|
||||
"role_y": "Y(工作台)",
|
||||
"role_z": "Z(压轴)",
|
||||
"role_yaw": "yaw(回转)",
|
||||
"role_pitch": "pitch(俯仰)",
|
||||
"role_axis": "驱动器",
|
||||
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
|
||||
"close": "关闭",
|
||||
"chip_hint": "点击打开设备设置 — {name}"
|
||||
},
|
||||
"move": {
|
||||
"execute": "执行动作",
|
||||
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
|
||||
"stop": "停止",
|
||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||
"return_initial": "回初始位",
|
||||
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
||||
"confirm_title": "确认运动",
|
||||
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
||||
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
||||
"cancelled": "已取消,未发出任何运动",
|
||||
"confirm_check": "移动前确认",
|
||||
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
|
||||
"no_point": "未选中标定点,无法执行",
|
||||
"busy": "已有运动在执行",
|
||||
"refused": "运动被拒绝",
|
||||
"done": "运动完成",
|
||||
"failed": "运动失败",
|
||||
"stopped": "已停止,后续动作已取消"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接控制器",
|
||||
"mode": "连接方式",
|
||||
|
||||
@@ -61,6 +61,30 @@ which can put the face on the far side of the body from the default camera. The
|
||||
axis is drawn with depth testing **off** for that reason — it is a reference line,
|
||||
and hiding it behind the body it is meant to align with is the opposite of useful.
|
||||
|
||||
## Running the pose on the machine
|
||||
|
||||
Three buttons, and where each one lives is deliberate:
|
||||
|
||||
| Button | Where | What it does |
|
||||
| --- | --- | --- |
|
||||
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. |
|
||||
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
|
||||
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
|
||||
|
||||
**移动前确认** decides whether the moving buttons ask first. The prompt lists the steps
|
||||
with their numbers and the drive each one uses, and says what is *not* being moved.
|
||||
|
||||
The order is the model's own: the kinematics composes `R_pitch · R_yaw`, so yaw acts on
|
||||
the body first and the machine yaws first — every intermediate pose is one the preview
|
||||
drew. Table X/Y last, because the translation is what the rotations are resolved
|
||||
against.
|
||||
|
||||
Each drive is commanded on its own and the next goes out only once the previous has
|
||||
arrived, which is what keeps the move interruptible; a step that stops short, times out
|
||||
or alarms aborts the rest rather than finishing a sequence nobody has verified. What is
|
||||
checked *before* the first axis moves — the mapping, the P0 limits, the rotary
|
||||
calibration — is in `../README.md` under **Safety model**.
|
||||
|
||||
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
|
||||
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
|
||||
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
|
||||
|
||||
@@ -67,6 +67,11 @@ or timeout → read `BUS_TYPE`, `Bus_TotalAxisnum`, and the node count. Step 2 i
|
||||
conditional because a controller usually still has the program in ROM from the
|
||||
last session, and a download is a bus re-scan the operator did not ask for.
|
||||
|
||||
Both controls live in the **device setup dialog** (设备设置), opened from the status
|
||||
strip's button, its connection/bus/init chips, or 文件 → 设备设置: they are used once
|
||||
per machine start-up, so the rail keeps only what is read while working. The strip
|
||||
continues to report connection, bus and init state while the dialog is open or shut.
|
||||
|
||||
**Re-initialising without a download:** `STOPTASK <task>` then
|
||||
`RUN "<file>",<task>`, on the task from `bas_task` in `preference.json`
|
||||
(default 4 — the task the vendor reference uses; stopping another task restarts
|
||||
@@ -231,6 +236,17 @@ measured time per point.
|
||||
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||
guard violation.
|
||||
|
||||
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
|
||||
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and
|
||||
does **not** command the press axis, so it cannot reach the sensor. The order is the
|
||||
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator
|
||||
watched in the preview. Before it moves it checks the mapping, the target against the
|
||||
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration —
|
||||
all in `motion/service.py`, so a later caller cannot walk around them — and the drives
|
||||
are commanded one at a time, each waiting for the previous to arrive, with a stall or
|
||||
an alarm aborting the rest. 停止 next to it is the same command as the strip's
|
||||
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Run + acquire
|
||||
|
||||
@@ -66,14 +66,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from . import REQUIRES_SHARED_API
|
||||
from . import __version__ as app_version
|
||||
from .motion import ConnectionSpec, DemoTransport
|
||||
from .preferences import load_preferences
|
||||
from .ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -16,7 +16,22 @@ from .models import (
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import DEFAULT_BAS_TASK, PLATFORM_AXES, MotionService, clamp_bas_task
|
||||
from .pose_move import (
|
||||
MovePlan,
|
||||
MoveStep,
|
||||
PoseMover,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from .service import (
|
||||
AXIS_ROLES,
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
MotionService,
|
||||
clamp_bas_task,
|
||||
normalize_axis_roles,
|
||||
)
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
@@ -33,7 +48,9 @@ from .units import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AXIS_ROLES",
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_AXIS_ROLES",
|
||||
"DEFAULT_BAS_TASK",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
@@ -55,12 +72,18 @@ __all__ = [
|
||||
"MotionPoller",
|
||||
"MotionResult",
|
||||
"MotionService",
|
||||
"MovePlan",
|
||||
"MoveStep",
|
||||
"NativeTransport",
|
||||
"PoseMover",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"clamp_bas_task",
|
||||
"deg_to_user_units",
|
||||
"mm_to_user_units",
|
||||
"normalize_axis_roles",
|
||||
"plan_pose_move",
|
||||
"plan_return_to_initial",
|
||||
"safe_float",
|
||||
"units_per_degree",
|
||||
"user_units_to_deg",
|
||||
|
||||
@@ -438,7 +438,7 @@ class PlatformStack:
|
||||
|
||||
def alignment_branches(
|
||||
self, unit: np.ndarray, tool: np.ndarray
|
||||
) -> list["Branch"]:
|
||||
) -> list[Branch]:
|
||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||
|
||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
||||
@@ -583,7 +583,7 @@ class PlatformStack:
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list["Branch"]) -> "Branch":
|
||||
def select_branch(branches: list[Branch]) -> Branch:
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
@@ -596,7 +596,7 @@ class PlatformStack:
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: "Branch") -> tuple:
|
||||
def rank(branch: Branch) -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
@@ -1051,7 +1051,7 @@ class PlatformStack:
|
||||
def _better_pose(
|
||||
candidate: tuple[float, float, float],
|
||||
best: tuple[float, float, float],
|
||||
stack: "PlatformStack",
|
||||
stack: PlatformStack,
|
||||
) -> bool:
|
||||
"""Rank two ``(residual, yaw, pitch)`` candidates.
|
||||
|
||||
|
||||
@@ -173,14 +173,14 @@ class MotionResult:
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def success(cls, message: str = "") -> "MotionResult":
|
||||
def success(cls, message: str = "") -> MotionResult:
|
||||
return cls(ok=True, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def refused(cls, message: str) -> "MotionResult":
|
||||
def refused(cls, message: str) -> MotionResult:
|
||||
"""The command was not sent: a guard rejected it before any motion."""
|
||||
return cls(ok=False, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, code: int, message: str) -> "MotionResult":
|
||||
def failed(cls, code: int, message: str) -> MotionResult:
|
||||
return cls(ok=False, code=int(code), message=message)
|
||||
|
||||
@@ -21,7 +21,8 @@ Contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QMetaObject,
|
||||
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
|
||||
# closes immediately and leaves the platform coasting.
|
||||
service.stop_all()
|
||||
service.disconnect()
|
||||
return None
|
||||
|
||||
return self.submit(_disconnect)
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Moving the platform to a simulated pose, one axis at a time.
|
||||
|
||||
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
||||
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
||||
matters:
|
||||
|
||||
* The **service** owns the guards: connected, bus initialised, mapping sane, targets
|
||||
finite, inside the P0 limits, rotary axes calibrated. Nothing here bypasses them.
|
||||
* The **worker thread** owns the controller calls. One command per poll tick is how
|
||||
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
||||
is possible at all: a mover that sat in a loop inside one command could not be
|
||||
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
||||
* **This module** owns the order and the waiting. It watches the snapshots the poller
|
||||
already produces, issues the next axis when the current one has arrived, and gives
|
||||
up on its own if a step stops making progress.
|
||||
|
||||
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
||||
command, so it runs at the next tick and stops the axis that is moving *now*.
|
||||
|
||||
Why the order is what it is: the model composes ``R_pitch · R_yaw``, i.e. the yaw acts
|
||||
on the body first. Commanding yaw before pitch keeps every intermediate pose on the
|
||||
path the operator watched in the preview. Table X/Y last, because the table
|
||||
translation is what the rotations are resolved against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .models import MotionResult, Snapshot
|
||||
from .service import LINEAR_ROLES, MotionService
|
||||
from .units import user_units_to_deg
|
||||
|
||||
#: How close an axis must be to its target before the next one starts, per unit:
|
||||
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
|
||||
#: 0.2 µm on the bench (``units=10``, 5000 pulses/mm), so 0.01 mm is 50 units —
|
||||
#: loose enough that a drive which reports its final position one tick late does not
|
||||
#: stall the sequence, tight enough that a missed target is caught.
|
||||
SETTLE_MM = 0.01
|
||||
SETTLE_DEG = 0.01
|
||||
|
||||
#: Per-step patience. A step that is neither arriving nor moving this long is
|
||||
#: treated as stuck: reported and abandoned, with no further axes commanded.
|
||||
STEP_TIMEOUT_S = 20.0
|
||||
|
||||
#: How long a step may report itself idle while still short of its target before it
|
||||
#: counts as failed — a drive that has stopped without arriving will never arrive.
|
||||
STALL_GRACE_S = 2.0
|
||||
|
||||
#: The order a role move is issued in, for the reason in the module docstring.
|
||||
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||
|
||||
#: The order "return to the initial state" retracts in: the press axis first, so the
|
||||
#: tool is clear of the sensor before anything rotates, then the stages, then the
|
||||
#: table. Returning in one coordinated swoop would move five axes through poses
|
||||
#: nobody has simulated.
|
||||
RETURN_ORDER: tuple[str, ...] = ("z", "pitch", "yaw", "x", "y")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoveStep:
|
||||
"""One axis, one target, in that role's own unit."""
|
||||
|
||||
role: str
|
||||
axis: int
|
||||
target: float
|
||||
units: float
|
||||
unit: str
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.role} {self.target:+.4f} {self.unit}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MovePlan:
|
||||
"""A sequence of steps, and what it is for."""
|
||||
|
||||
steps: tuple[MoveStep, ...]
|
||||
title: str
|
||||
#: Roles deliberately left alone. Reported, because "z did not move" is
|
||||
#: information the operator needs, not an omission.
|
||||
held: tuple[str, ...] = ()
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [step.label() for step in self.steps]
|
||||
text = " · ".join(parts) if parts else "-"
|
||||
if self.held:
|
||||
text += f" · {', '.join(self.held)} 不动作"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveState:
|
||||
"""What the mover is doing, for the panel to show."""
|
||||
|
||||
plan: MovePlan | None = None
|
||||
index: int = 0
|
||||
started_at: float = 0.0
|
||||
finished: bool = False
|
||||
failure: str = ""
|
||||
cancelled: bool = False
|
||||
_step_started_at: float = field(default=0.0)
|
||||
_idle_since: float | None = None
|
||||
|
||||
@property
|
||||
def step(self) -> MoveStep | None:
|
||||
if self.plan is None or not 0 <= self.index < len(self.plan.steps):
|
||||
return None
|
||||
return self.plan.steps[self.index]
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.plan.steps) if self.plan else 0
|
||||
|
||||
|
||||
def plan_pose_move(
|
||||
service: MotionService,
|
||||
targets: Mapping[str, float],
|
||||
*,
|
||||
title: str = "",
|
||||
hold: Sequence[str] = (),
|
||||
order: Sequence[str] = MOVE_ORDER,
|
||||
) -> MovePlan:
|
||||
"""Turn per-role targets into a stepped plan, in ``order``.
|
||||
|
||||
``targets`` is in each role's own unit. Roles absent from it are not commanded —
|
||||
that is how "z does not move" is expressed: not by a target equal to its current
|
||||
position, which would still command it.
|
||||
"""
|
||||
steps: list[MoveStep] = []
|
||||
for role in order:
|
||||
if role not in targets:
|
||||
continue
|
||||
axis = service.role_axis(role)
|
||||
if axis is None:
|
||||
continue
|
||||
value = float(targets[role])
|
||||
steps.append(
|
||||
MoveStep(
|
||||
role=role,
|
||||
axis=axis,
|
||||
target=value,
|
||||
units=service.to_units(role, value),
|
||||
unit="mm" if role in LINEAR_ROLES else "°",
|
||||
)
|
||||
)
|
||||
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
|
||||
|
||||
|
||||
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
||||
"""Every axis to zero, press axis first, in :data:`RETURN_ORDER`."""
|
||||
return plan_pose_move(
|
||||
service,
|
||||
{role: 0.0 for role in RETURN_ORDER},
|
||||
order=RETURN_ORDER,
|
||||
)
|
||||
|
||||
|
||||
class PoseMover(QObject):
|
||||
"""Issues a :class:`MovePlan` one step at a time, off the poller's snapshots."""
|
||||
|
||||
started = Signal(object)
|
||||
stepStarted = Signal(object)
|
||||
finished = Signal(object)
|
||||
failed = Signal(str)
|
||||
cancelled = Signal()
|
||||
|
||||
def __init__(self, service: MotionService, submit, parent: QObject | None = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
service: the motion service, for unit conversion and per-axis guards.
|
||||
submit: the poller's ``submit``, taking a ``service -> result`` callable.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._service = service
|
||||
self._submit = submit
|
||||
self.state = MoveState()
|
||||
|
||||
# ------------------------------------------------------------------ control
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
plan = self.state.plan
|
||||
return bool(
|
||||
plan is not None
|
||||
and not self.state.finished
|
||||
and not self.state.failure
|
||||
and not self.state.cancelled
|
||||
)
|
||||
|
||||
def start(self, plan: MovePlan, *, now: float | None = None) -> MotionResult:
|
||||
"""Validate and begin. Refusal is returned, not raised."""
|
||||
if self.running:
|
||||
return MotionResult.refused("已有运动在执行 / a move is already running")
|
||||
refusal = self._service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not plan.steps:
|
||||
return MotionResult.refused("没有要移动的轴 / nothing to move")
|
||||
|
||||
stamp = time.monotonic() if now is None else now
|
||||
self.state = MoveState(plan=plan, started_at=stamp, _step_started_at=stamp)
|
||||
self.started.emit(plan)
|
||||
self._issue_current()
|
||||
return MotionResult.success(plan.describe())
|
||||
|
||||
def cancel(self, reason: str = "") -> None:
|
||||
"""Abandon the queue. The axis already moving is stopped by the caller.
|
||||
|
||||
Deliberately does not stop anything itself: stopping is a controller command
|
||||
with its own guard, and the window issues it as one. What this guarantees is
|
||||
that no *further* axis is commanded after the stop.
|
||||
"""
|
||||
if self.state.plan is None or self.state.finished:
|
||||
return
|
||||
self.state.cancelled = True
|
||||
self.state.failure = reason
|
||||
self.state.plan = None
|
||||
self.cancelled.emit()
|
||||
|
||||
def _issue_current(self) -> None:
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
self.state._step_started_at = time.monotonic()
|
||||
self.state._idle_since = None
|
||||
if not step.units == step.units: # NaN: no calibration for this role
|
||||
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||
return
|
||||
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
||||
self.stepStarted.emit(step)
|
||||
|
||||
def _complete(self) -> None:
|
||||
self.state.finished = True
|
||||
plan = self.state.plan
|
||||
self.state.plan = None
|
||||
self.finished.emit(plan)
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self.state.failure = message
|
||||
self.state.plan = None
|
||||
self.failed.emit(message)
|
||||
|
||||
# ---------------------------------------------------------------- progress
|
||||
|
||||
def on_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Advance the plan from a poll. Called on every refresh."""
|
||||
if not self.running:
|
||||
return
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self.state._step_started_at > STEP_TIMEOUT_S:
|
||||
self._fail(
|
||||
f"{step.role} 等待超时 / timed out waiting for {step.role}"
|
||||
)
|
||||
return
|
||||
|
||||
state = snapshot.axis(step.axis)
|
||||
if state is None:
|
||||
return
|
||||
if state.error or not state.read_ok:
|
||||
self._fail(f"{step.role}: {state.error or 'read failed'}")
|
||||
return
|
||||
if state.status.alarm:
|
||||
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
||||
return
|
||||
|
||||
# A linear role reads in millimetres, a rotary one in degrees; the service
|
||||
# converts lengths, the units module converts calibrated angles.
|
||||
if step.unit == "mm":
|
||||
travelled = self._service.to_mm(step.axis, state.dpos)
|
||||
else:
|
||||
travelled = user_units_to_deg(step.axis, state.dpos)
|
||||
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
||||
|
||||
if not state.idle:
|
||||
self.state._idle_since = None
|
||||
return
|
||||
if abs(travelled - step.target) <= tolerance:
|
||||
self.state.index += 1
|
||||
self._issue_current()
|
||||
return
|
||||
# Idle, but not there. A drive that has stopped short will not resume, so
|
||||
# this is a failure rather than something to keep waiting for.
|
||||
if self.state._idle_since is None:
|
||||
self.state._idle_since = now
|
||||
elif now - self.state._idle_since > STALL_GRACE_S:
|
||||
self._fail(
|
||||
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
|
||||
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||
)
|
||||
|
||||
def progress_text(self) -> str:
|
||||
"""A one-line status for the panel."""
|
||||
if self.state.failure:
|
||||
return f"✗ {self.state.failure}"
|
||||
if self.state.finished:
|
||||
return "✓ 已完成 / done"
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
return ""
|
||||
return (
|
||||
f"{self.state.index + 1}/{self.state.total} "
|
||||
f"{step.role} → {step.target:+.4f} {step.unit}"
|
||||
)
|
||||
@@ -5,18 +5,19 @@ without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
|
||||
it from a worker thread; the UI only ever calls these methods.
|
||||
|
||||
Guards live here rather than in the UI on purpose. A motion command is refused
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input —
|
||||
and :meth:`MotionService.jog` and friends return that reason as a
|
||||
``MotionResult`` instead of silently doing nothing.
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input, a
|
||||
target past the P0 travel limit, an axis mapping that does not add up — and
|
||||
:meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
|
||||
instead of silently doing nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
@@ -34,13 +35,14 @@ from .models import (
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
||||
from .transport import NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
deg_to_user_units,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
units_per_degree,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
@@ -49,6 +51,32 @@ logger = logging.getLogger(__name__)
|
||||
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||
PLATFORM_AXES = 5
|
||||
|
||||
#: The platform directions a drive can be, in the order the model's frames are
|
||||
#: named. A drive's *number* says nothing about what it moves: the bus reports five
|
||||
#: identical CiA-402 axes, and whether drive 2 tilts the sensor or lifts the table is
|
||||
#: a property of the bench's wiring. This is the mapping the whole "move the platform
|
||||
#: to the simulated pose" path rests on, so it is configuration with a page in the
|
||||
#: device setup rather than a constant.
|
||||
AXIS_ROLES: tuple[str, ...] = ("x", "y", "z", "yaw", "pitch")
|
||||
|
||||
#: Roles whose target is a length, and roles whose target is an angle. A degree
|
||||
#: target checked against a millimetre limit is not a limit at all.
|
||||
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
|
||||
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
|
||||
|
||||
#: Default mapping, from the bench: drives 0-4 are x, y, pitch, yaw, z.
|
||||
#:
|
||||
#: Deliberately *not* the same as the model's own order. It is written the way the
|
||||
#: machine is wired, so reading it against the terminal strip is a check rather than
|
||||
#: a translation.
|
||||
DEFAULT_AXIS_ROLES: dict[str, int] = {
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"pitch": 2,
|
||||
"yaw": 3,
|
||||
"z": 4,
|
||||
}
|
||||
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
@@ -78,6 +106,27 @@ def clamp_bas_task(value: object, fallback: int = DEFAULT_BAS_TASK) -> int:
|
||||
return task if low <= task <= high else fallback
|
||||
|
||||
|
||||
def normalize_axis_roles(roles: Mapping[str, int] | None) -> dict[str, int]:
|
||||
"""A clean role -> drive mapping, falling back to the bench default.
|
||||
|
||||
Unusable entries are dropped rather than coerced: a role mapped to a drive the
|
||||
file did not contain is a configuration the app should report through
|
||||
:meth:`MotionService.roles_problem`, not silently repair.
|
||||
"""
|
||||
if roles is None:
|
||||
return dict(DEFAULT_AXIS_ROLES)
|
||||
clean: dict[str, int] = {}
|
||||
for role in AXIS_ROLES:
|
||||
value = roles.get(role)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
clean[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return clean
|
||||
|
||||
|
||||
def _online_failure(reply: str) -> str:
|
||||
"""The controller's own refusal text from an online command, or ``""``.
|
||||
|
||||
@@ -107,6 +156,8 @@ class MotionService:
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
bas_task: int = DEFAULT_BAS_TASK,
|
||||
axis_roles: Mapping[str, int] | None = None,
|
||||
limits: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
self._transport: ZmcTransport = transport or NativeTransport()
|
||||
self.axis_count = int(axis_count)
|
||||
@@ -114,6 +165,14 @@ class MotionService:
|
||||
self._pulses_per_mm = pulses_per_mm
|
||||
self.language = language
|
||||
self.bas_task = clamp_bas_task(bas_task)
|
||||
# Which drive is which platform direction. The app cannot infer this from a
|
||||
# CiA-402 axis: the bus reports numbers. It is configuration, so it arrives
|
||||
# from the preference file and can be corrected in the device setup.
|
||||
self._axis_roles: dict[str, int] = normalize_axis_roles(axis_roles)
|
||||
# The mm limits from workflow P0. They live here rather than in the UI because
|
||||
# this is where motion is allowed or refused, and a limit the UI enforces is a
|
||||
# limit a later caller can walk around.
|
||||
self._limits: dict[str, float] = dict(limits or {})
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
@@ -151,6 +210,152 @@ class MotionService:
|
||||
def params(self, axis: int) -> AxisParams:
|
||||
return self._params.get(axis, AxisParams())
|
||||
|
||||
# --------------------------------------------------------------- axis roles
|
||||
|
||||
@property
|
||||
def axis_roles(self) -> dict[str, int]:
|
||||
"""Drive index per platform direction, as configured."""
|
||||
return dict(self._axis_roles)
|
||||
|
||||
def set_axis_roles(self, roles: Mapping[str, int] | None) -> None:
|
||||
"""Replace the mapping. Called when the device setup is edited."""
|
||||
self._axis_roles = normalize_axis_roles(roles)
|
||||
|
||||
def set_limits(self, limits: Mapping[str, float] | None) -> None:
|
||||
"""Replace the P0 limits. Called when the preference file is reloaded."""
|
||||
self._limits = dict(limits or {})
|
||||
|
||||
def role_axis(self, role: str) -> int | None:
|
||||
"""The drive that moves ``role``, or ``None`` if it is not configured."""
|
||||
return self._axis_roles.get(role)
|
||||
|
||||
def roles_problem(self) -> str:
|
||||
"""Why the mapping cannot be used, or ``""`` when it is sound.
|
||||
|
||||
A duplicated drive is the dangerous case — two roles claiming axis 3 means
|
||||
one of them silently does not move, and the pose would be wrong rather than
|
||||
refused — so it is reported rather than tolerated.
|
||||
"""
|
||||
missing = [role for role in AXIS_ROLES if role not in self._axis_roles]
|
||||
if missing:
|
||||
return self._text(
|
||||
f"轴映射未配置:{', '.join(missing)}",
|
||||
f"Axis mapping is not configured for: {', '.join(missing)}",
|
||||
)
|
||||
used: dict[int, str] = {}
|
||||
for role in AXIS_ROLES:
|
||||
axis = self._axis_roles[role]
|
||||
if not self._known_axis(axis):
|
||||
return self._text(
|
||||
f"{role} 指向轴 {axis},超出 0-{self.axis_count - 1}",
|
||||
f"{role} points at axis {axis}, outside 0-{self.axis_count - 1}",
|
||||
)
|
||||
if axis in used:
|
||||
return self._text(
|
||||
f"轴 {axis} 同时是 {used[axis]} 和 {role}",
|
||||
f"Axis {axis} is claimed by both {used[axis]} and {role}",
|
||||
)
|
||||
used[axis] = role
|
||||
return ""
|
||||
|
||||
@property
|
||||
def travel_limit_mm(self) -> float:
|
||||
"""The P0 travel limit. ``0`` means "not set", never "unlimited"."""
|
||||
return safe_float(self._limits.get("max_travel_mm"), 0.0)
|
||||
|
||||
def limit_for(self, role: str) -> float:
|
||||
"""The P0 limit for a role, in that role's own unit.
|
||||
|
||||
Linear roles are limited in millimetres and rotary roles in degrees: one
|
||||
number cannot bound both, and treating a degree target as millimetres is how
|
||||
a 180° move passes a 100 mm check.
|
||||
"""
|
||||
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
|
||||
return safe_float(self._limits.get(key), 0.0)
|
||||
|
||||
def units_problem(self, role: str, axis: int) -> str:
|
||||
"""Why a role cannot be commanded in its own unit, or ``""``."""
|
||||
if role in LINEAR_ROLES:
|
||||
return ""
|
||||
scale = units_per_degree(axis)
|
||||
if not math.isfinite(scale) or scale <= 0:
|
||||
return self._text(
|
||||
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
|
||||
f"{role} uses axis {axis}, which has no user-units-per-degree "
|
||||
f"calibration",
|
||||
)
|
||||
return ""
|
||||
|
||||
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
|
||||
"""Refuse a coordinated pose move, or return ``None`` to allow it.
|
||||
|
||||
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
|
||||
degrees for yaw / pitch.
|
||||
|
||||
Every reason a move can be wrong is checked *before* the first axis is
|
||||
commanded. A pose move is several coordinated moves, and discovering at axis
|
||||
three that axis four is out of range would leave the machine stopped
|
||||
somewhere that is neither the start nor the target.
|
||||
"""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._bus.initialized:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线未初始化完成,禁止运动",
|
||||
"Bus is not initialised; motion is refused",
|
||||
)
|
||||
)
|
||||
problem = self.roles_problem()
|
||||
if problem:
|
||||
return MotionResult.refused(problem)
|
||||
if not targets:
|
||||
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
|
||||
for role, value in targets.items():
|
||||
if role not in AXIS_ROLES:
|
||||
return MotionResult.refused(
|
||||
self._text(f"未知方向 {role}", f"Unknown direction {role}")
|
||||
)
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
return MotionResult.refused(
|
||||
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
|
||||
)
|
||||
axis = self._axis_roles[role]
|
||||
units_problem = self.units_problem(role, axis)
|
||||
if units_problem:
|
||||
return MotionResult.refused(units_problem)
|
||||
for role, value in targets.items():
|
||||
limit = self.limit_for(role)
|
||||
unit = "mm" if role in LINEAR_ROLES else "°"
|
||||
if limit <= 0:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 的上限未设置(P0),禁止运动",
|
||||
f"The {role} limit is not set (P0); motion is refused",
|
||||
)
|
||||
)
|
||||
if abs(float(value)) > limit:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 目标 {float(value):.3f}{unit} 超出上限 {limit:.3f}{unit}",
|
||||
f"{role} target {float(value):.3f}{unit} is past the "
|
||||
f"{limit:.3f}{unit} limit",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def to_units(self, role: str, value: float) -> float:
|
||||
"""Controller user units for a role target, in that role's own unit."""
|
||||
axis = self._axis_roles.get(role)
|
||||
if axis is None:
|
||||
return math.nan
|
||||
if role in LINEAR_ROLES:
|
||||
units = self.params(axis).units or self._units_per_mm
|
||||
return mm_to_user_units(
|
||||
value, units=units, pulses_per_mm=self._pulses_per_mm
|
||||
)
|
||||
return deg_to_user_units(axis, value)
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self.language = language
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any
|
||||
from orisys_app_shared import prefs as _prefs
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from .motion.service import DEFAULT_BAS_TASK, PLATFORM_AXES, clamp_bas_task
|
||||
from .motion.service import (
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
clamp_bas_task,
|
||||
)
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
@@ -81,6 +86,10 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
|
||||
``0`` means "not set" for the ones where a real value is required, and the
|
||||
app reports that at the P0 gate rather than treating it as unlimited.
|
||||
|
||||
Lengths are millimetres and angles degrees, because the roles they bound are:
|
||||
one number cannot bound both, and a degree target checked against a millimetre
|
||||
limit is not a check.
|
||||
"""
|
||||
return {
|
||||
"max_travel_mm": 100.0,
|
||||
@@ -90,6 +99,11 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
#: How far the axis may descend looking for contact before the run is
|
||||
#: aborted (workflow P7, no-contact abort).
|
||||
"contact_search_mm": 3.0,
|
||||
#: Stage envelope, from the confirmed bench values: the yaw stage turns a
|
||||
#: half-turn either way, the pitch stage a full turn. These are the limits a
|
||||
#: pose move is checked against before any axis is commanded.
|
||||
"max_yaw_deg": 180.0,
|
||||
"max_pitch_deg": 360.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +118,10 @@ def _default_sensor_setup() -> dict[str, Any]:
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
#: Which axis presses, and the probe tip offset from the platform's
|
||||
#: reference point. Empty offset means "not set", which blocks P5.
|
||||
"press_axis": 2,
|
||||
#: The probe tip's offset from the platform's reference point. Empty offset
|
||||
#: means "not set", which blocks P5. Which axis presses is **not** here: it
|
||||
#: is ``axis_roles["z"]``, because one fact written twice is one fact that
|
||||
#: eventually disagrees.
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": False,
|
||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
||||
@@ -173,6 +188,19 @@ def default_preferences() -> dict[str, Any]:
|
||||
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||
#: P0: limits enforced from P2 onward.
|
||||
"safety_limits": _default_safety_limits(),
|
||||
#: Which drive is which platform direction, from the bench wiring: drives
|
||||
#: 0-4 are x, y, pitch, yaw, z. There is no way to read this off the bus —
|
||||
#: five CiA-402 axes report numbers, not names — so it is configuration, and
|
||||
#: the pose-move path refuses to run until it is complete and free of
|
||||
#: duplicates. It is also the *only* place "which axis presses" is written:
|
||||
#: the sensor setup used to carry a second copy, which is how the two would
|
||||
#: have drifted apart.
|
||||
"axis_roles": dict(DEFAULT_AXIS_ROLES),
|
||||
#: Ask before a pose move. On by default — the first moves on a machine are
|
||||
#: the ones worth reading — and turnable off once the numbers are trusted,
|
||||
#: because a prompt that cannot be dismissed unread is one that gets clicked
|
||||
#: through without being read.
|
||||
"move_confirm": True,
|
||||
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
@@ -206,9 +234,26 @@ def default_preferences() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
#: Preference blocks whose *keys* are added to over time. Stored preferences are
|
||||
#: merged one level deep, so a block saved by an older build replaces the whole
|
||||
#: default block and any key added since reads as absent. For a P0 limit that is not
|
||||
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
||||
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
||||
#: what was chosen and supplies only what was never there.
|
||||
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
||||
prefs = _prefs.load_preferences(default_preferences())
|
||||
defaults = default_preferences()
|
||||
prefs = _prefs.load_preferences(defaults)
|
||||
for name in _MERGED_BLOCKS:
|
||||
stored = prefs.get(name)
|
||||
if not isinstance(stored, dict):
|
||||
continue
|
||||
block = dict(defaults.get(name) or {})
|
||||
block.update(stored)
|
||||
prefs[name] = block
|
||||
# A stored document from an older build may miss a newly added axis block.
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
|
||||
prefs["finger_tool_xy_mm"] = tool
|
||||
# A hand-edited task number must not reach the controller as typed.
|
||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
||||
prefs = _migrate_axis_roles(prefs)
|
||||
return prefs
|
||||
|
||||
|
||||
def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Complete the axis mapping, and take the old duplicate press axis out.
|
||||
|
||||
``sensor_setup.press_axis`` named the pressing drive on its own. That is
|
||||
``axis_roles["z"]`` now — the same fact — and a stored value is moved there when
|
||||
the mapping has not been set, so a bench that had it configured keeps working.
|
||||
The old key is removed either way: leaving it would keep a second answer to
|
||||
"which axis presses" in the file.
|
||||
"""
|
||||
roles = prefs.get("axis_roles")
|
||||
roles = dict(roles) if isinstance(roles, dict) else {}
|
||||
sensor = prefs.get("sensor_setup")
|
||||
if isinstance(sensor, dict):
|
||||
legacy = sensor.pop("press_axis", None)
|
||||
if legacy is not None and "z" not in roles:
|
||||
try:
|
||||
roles["z"] = int(legacy)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
merged = dict(DEFAULT_AXIS_ROLES)
|
||||
# A stored mapping wins where it is usable; the default fills what it does not
|
||||
# cover, so an old document that only named the press axis still comes up with a
|
||||
# complete mapping rather than a refusal.
|
||||
for role, value in roles.items():
|
||||
try:
|
||||
merged[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
prefs["axis_roles"] = merged
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
|
||||
|
||||
from .export_bundle import BundleError, SensorBundle, load_bundle
|
||||
from .frames import AXIS_MAP, describe as describe_frames, mount_rotation, to_platform, to_sensor
|
||||
from .frames import AXIS_MAP, mount_rotation, to_platform, to_sensor
|
||||
from .frames import describe as describe_frames
|
||||
from .uv_flat import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
FlatSurface,
|
||||
@@ -12,8 +13,8 @@ from .uv_flat import (
|
||||
|
||||
__all__ = [
|
||||
"AXIS_MAP",
|
||||
"BundleError",
|
||||
"DEFAULT_FLAT_SIZE",
|
||||
"BundleError",
|
||||
"FlatSurface",
|
||||
"SensorBundle",
|
||||
"describe_frames",
|
||||
|
||||
@@ -26,10 +26,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
#: The version this build writes, and the highest it will read.
|
||||
FORMAT_VERSION = "1.0"
|
||||
|
||||
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
|
||||
if not units:
|
||||
return "—"
|
||||
millimetres = user_units_to_mm(user_units, units=units)
|
||||
if millimetres != millimetres: # NaN
|
||||
if math.isnan(millimetres):
|
||||
return "—"
|
||||
return f"{millimetres:.3f}"
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"""The permanent axis readout rail.
|
||||
|
||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
||||
there is exactly one place a move can be issued from — two places to jog from is
|
||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
||||
operator needs while working in any other tab: whether each drive is enabled,
|
||||
where it is, and whether it is moving.
|
||||
Read-only apart from four platform-wide actions. Every per-axis *command* lives in
|
||||
the Axis-debug tab, so there is exactly one place a single-axis move can be issued
|
||||
from — two places to jog from is how an operator moves the wrong axis. What this rail
|
||||
adds is the thing the operator needs while working in any other tab: whether each
|
||||
drive is enabled, where it is, and whether it is moving.
|
||||
|
||||
The three global actions are here rather than in the tab because they are the
|
||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
||||
The four global actions are here rather than in a tab because they are the ones you
|
||||
reach for in a hurry, or reach for *after* finishing in some other tab: enable,
|
||||
stop, clear alarms, and return every axis to the initial state. They must not
|
||||
require finding the right tab first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot, user_units_to_mm
|
||||
|
||||
#: Columns of the compact readout: axis, enable state, position.
|
||||
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
|
||||
enableAllRequested = Signal(bool)
|
||||
stopAllRequested = Signal()
|
||||
clearAllRequested = Signal()
|
||||
#: Return every drive to the initial state. Here, not in the finger screen,
|
||||
#: because it is a machine-level action: it is wanted after a move made in *any*
|
||||
#: tab, and the rail is on screen in all of them.
|
||||
returnRequested = Signal()
|
||||
|
||||
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.clear_all_button = QPushButton(self)
|
||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
self.return_button = QPushButton(self)
|
||||
self.return_button.clicked.connect(self.returnRequested)
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.return_button,
|
||||
self.clear_all_button,
|
||||
):
|
||||
button.setProperty("rail", True)
|
||||
outer.addWidget(button)
|
||||
|
||||
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.clear_all_button,
|
||||
self.return_button,
|
||||
):
|
||||
button.setEnabled(self._connected)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
@@ -138,7 +157,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||
units_text = f"{state.dpos:.3f}"
|
||||
if millimetres is not None and millimetres == millimetres:
|
||||
if millimetres is not None and not math.isnan(millimetres):
|
||||
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||
widgets["position"].setText(units_text)
|
||||
widgets["position"].setToolTip(
|
||||
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.setTitle(t("readout.title"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.clear_all_button.setText(t("axes.clear_alarm_all"))
|
||||
self.return_button.setText(t("move.return_initial"))
|
||||
self.return_button.setToolTip(t("move.return_hint"))
|
||||
# The toggle's label depends on current state, so re-derive it.
|
||||
self.update_snapshot(self._snapshot)
|
||||
|
||||
@@ -7,13 +7,12 @@ controller is reachable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
#: Chip tone -> colour token name.
|
||||
_TONE_TOKENS = {
|
||||
@@ -27,6 +26,11 @@ _TONE_TOKENS = {
|
||||
class BrandBar(QFrame):
|
||||
"""The identity strip, with a state chip that reports the connection."""
|
||||
|
||||
#: The connection chip was clicked. It is the most prominent statement of the
|
||||
#: connection state, so it is also the obvious way to go and change it — the
|
||||
#: controls themselves live in the device-setup dialog.
|
||||
connectionChipClicked = Signal()
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("brandBar")
|
||||
@@ -67,10 +71,21 @@ class BrandBar(QFrame):
|
||||
|
||||
self.state_chip = QLabel(self)
|
||||
self.state_chip.setObjectName("stateChip")
|
||||
self.state_chip.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
layout.addWidget(self.state_chip)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
"""A click anywhere on the chip opens the device setup.
|
||||
|
||||
On the bar rather than on the label: a QLabel only receives a mouse event
|
||||
where its own text is, so clicking the chip's padding would do nothing.
|
||||
"""
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.state_chip.underMouse():
|
||||
self.connectionChipClicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def retranslate(self) -> None:
|
||||
@@ -78,6 +93,7 @@ class BrandBar(QFrame):
|
||||
self.name.setText(t("brand.name"))
|
||||
self.detail.setText(t("brand.detail"))
|
||||
self.product.setText(t("app.title"))
|
||||
self.state_chip.setToolTip(t("setup.chip_hint", name=t("connection.title")))
|
||||
self.set_connected(self._connected)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
|
||||
@@ -9,12 +9,12 @@ vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
||||
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
|
||||
#: push this panel tall enough to steal an axis row from the panel above it.
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
|
||||
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||
|
||||
_STATE_KEYS = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""The device setup: connection, bus initialisation and the axis mapping.
|
||||
|
||||
These are the facts about the machine itself, as opposed to the job: reach the
|
||||
controller, download the bus program, watch the bus come up, and say which drive moves
|
||||
which platform direction. All of them are touched once per machine start-up — or once
|
||||
per re-wire — and then not again for the rest of the session. In the left rail the
|
||||
first two were spending ~460 px of its height, above the axis readout that is read all
|
||||
day, which is what made the rail scroll.
|
||||
|
||||
They live in a **modeless dialog** rather than a modal one on purpose: connecting and
|
||||
downloading are asynchronous (the poller runs the state machine, the status strip
|
||||
reports it), and an operator watching a bus scan go wrong wants to see the strip and
|
||||
the axis readout while it happens. Modeless also means the dialog can be opened from
|
||||
several places — the strip's button, its state chips, the File menu — without any of
|
||||
them having to block.
|
||||
|
||||
The dialog owns the connection and bus panels; the window keeps a reference to each
|
||||
(``window.connection_panel`` / ``window.bus_panel``) because it is the window that
|
||||
wires them to the controller, and because the tools and checks reach for them there.
|
||||
The axis mapping is read back by the window on every change, because the motion guards
|
||||
have to see the same numbers the page shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
||||
from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
|
||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||
#: reach the machine, bring its bus up, then say what each drive moves.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("connection", "setup.tab_connection"),
|
||||
("bus", "setup.tab_bus"),
|
||||
("axes", "setup.tab_axes"),
|
||||
)
|
||||
|
||||
#: Role -> locale key for the mapping rows.
|
||||
_ROLE_KEYS = {
|
||||
"x": "setup.role_x",
|
||||
"y": "setup.role_y",
|
||||
"z": "setup.role_z",
|
||||
"yaw": "setup.role_yaw",
|
||||
"pitch": "setup.role_pitch",
|
||||
}
|
||||
|
||||
|
||||
class AxisRolesPage(QWidget):
|
||||
"""Which drive moves which platform direction.
|
||||
|
||||
A page of its own because it is a separate fact: connection and bus are about
|
||||
*reaching* the machine, this is about what its drives do. Getting it wrong moves
|
||||
the wrong motor, so a duplicate is reported rather than quietly accepted — and the
|
||||
motion guards read the same mapping, so a page left in a contradictory state
|
||||
refuses motion instead of guessing which claim was meant.
|
||||
"""
|
||||
|
||||
rolesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self, roles: dict[str, int] | None = None, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
column = QVBoxLayout(self)
|
||||
column.setContentsMargins(8, 8, 8, 8)
|
||||
column.setSpacing(8)
|
||||
|
||||
self.hint_label = QLabel(self)
|
||||
self.hint_label.setObjectName("hintLabel")
|
||||
self.hint_label.setWordWrap(True)
|
||||
column.addWidget(self.hint_label)
|
||||
|
||||
self.box = QGroupBox(self)
|
||||
self.box.setObjectName("groupBox")
|
||||
form = QFormLayout(self.box)
|
||||
form.setContentsMargins(10, 8, 10, 10)
|
||||
form.setSpacing(6)
|
||||
|
||||
self.combos: dict[str, QComboBox] = {}
|
||||
self.labels: dict[str, QLabel] = {}
|
||||
for role in AXIS_ROLES:
|
||||
label = QLabel(self.box)
|
||||
combo = QComboBox(self.box)
|
||||
for axis in range(PLATFORM_AXES):
|
||||
combo.addItem(str(axis), axis)
|
||||
combo.currentIndexChanged.connect(self._on_changed)
|
||||
self.labels[role] = label
|
||||
self.combos[role] = combo
|
||||
form.addRow(label, combo)
|
||||
column.addWidget(self.box)
|
||||
|
||||
self.problem_label = QLabel(self)
|
||||
self.problem_label.setObjectName("statusValue")
|
||||
self.problem_label.setWordWrap(True)
|
||||
column.addWidget(self.problem_label)
|
||||
column.addStretch(1)
|
||||
|
||||
self.set_roles(roles or {})
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_roles(self, roles: dict[str, int]) -> None:
|
||||
"""Show a mapping without announcing a change the operator did not make.
|
||||
|
||||
An empty mapping falls back to the bench default rather than to "every role
|
||||
on drive 0", which would open the page in a contradictory state and — because
|
||||
the guards read the same mapping — refuse motion for a reason nobody typed.
|
||||
"""
|
||||
source = dict(roles) or dict(DEFAULT_AXIS_ROLES)
|
||||
for role, combo in self.combos.items():
|
||||
value = source.get(role)
|
||||
index = combo.findData(int(value)) if value is not None else -1
|
||||
combo.blockSignals(True)
|
||||
try:
|
||||
combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
finally:
|
||||
combo.blockSignals(False)
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
def roles(self) -> dict[str, int]:
|
||||
return {role: int(combo.currentData()) for role, combo in self.combos.items()}
|
||||
|
||||
def problem(self) -> str:
|
||||
"""The duplicate mapping, described, or ``""`` when it is sound."""
|
||||
seen: dict[int, str] = {}
|
||||
for role, combo in self.combos.items():
|
||||
axis = int(combo.currentData())
|
||||
if axis in seen:
|
||||
return t("setup.axes_duplicate", detail=f"{seen[axis]} ↔ {role}")
|
||||
seen[axis] = role
|
||||
return ""
|
||||
|
||||
def _on_changed(self, _index: int) -> None:
|
||||
self._refresh_problem()
|
||||
|
||||
def _refresh_problem(self, *, announce: bool = True) -> None:
|
||||
problem = self.problem()
|
||||
self.problem_label.setText(f"⚠ {problem}" if problem else "")
|
||||
if announce:
|
||||
self.rolesChanged.emit()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.hint_label.setText(t("setup.axes_hint"))
|
||||
self.box.setTitle(t("setup.tab_axes"))
|
||||
for role, label in self.labels.items():
|
||||
label.setText(t(_ROLE_KEYS[role]))
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
|
||||
class DeviceSetupDialog(QDialog):
|
||||
"""Connection and bus initialisation, the once-per-start controls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec: ConnectionSpec,
|
||||
bas_path: str = "",
|
||||
roles: dict[str, int] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("deviceSetup")
|
||||
# Modeless: see the module docstring. ``show()`` raises it when already open.
|
||||
self.setModal(False)
|
||||
self.setSizeGripEnabled(True)
|
||||
|
||||
column = QVBoxLayout(self)
|
||||
column.setContentsMargins(10, 10, 10, 10)
|
||||
column.setSpacing(8)
|
||||
|
||||
self.hint_label = QLabel(self)
|
||||
self.hint_label.setObjectName("hintLabel")
|
||||
self.hint_label.setWordWrap(True)
|
||||
column.addWidget(self.hint_label)
|
||||
|
||||
self.tabs = QTabWidget(self)
|
||||
self.tabs.setObjectName("setupTabs")
|
||||
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||
for key, locale_key in TABS:
|
||||
self.tabs.addTab(self.panel(key), "")
|
||||
column.addWidget(self.tabs, 1)
|
||||
|
||||
self.buttons = QDialogButtonBox(self)
|
||||
self.close_button = self.buttons.addButton(
|
||||
"", QDialogButtonBox.ButtonRole.RejectRole
|
||||
)
|
||||
self.close_button.clicked.connect(self.close)
|
||||
column.addWidget(self.buttons)
|
||||
|
||||
self.retranslate()
|
||||
# Sized to the taller tab (bus initialisation: the file row, four readouts and
|
||||
# two buttons), so the dialog opens without a band of empty panel under the
|
||||
# controls. It is resizable, and the size is remembered.
|
||||
self.resize(520, 470)
|
||||
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def panel(self, key: str) -> QWidget:
|
||||
"""One of the three pages, by tab key."""
|
||||
if key == "connection":
|
||||
return self.connection_panel
|
||||
return self.bus_panel if key == "bus" else self.roles_page
|
||||
|
||||
def open_for(self, tab: str | None = None) -> None:
|
||||
"""Show the dialog, raising it if it is already up, and pick a tab.
|
||||
|
||||
Raising rather than ignoring the second request: the button and the chips all
|
||||
lead here, and a click that appears to do nothing reads as a broken button.
|
||||
"""
|
||||
key = tab if tab in {name for name, _ in TABS} else None
|
||||
if key is not None:
|
||||
self.select_tab(key)
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
panel = self.panel(key) if key is not None else None
|
||||
if panel is not None:
|
||||
panel.setFocus()
|
||||
|
||||
def select_tab(self, key: str) -> None:
|
||||
for index, (name, _locale_key) in enumerate(TABS):
|
||||
if name == key:
|
||||
self.tabs.setCurrentIndex(index)
|
||||
return
|
||||
|
||||
def current_tab(self) -> str:
|
||||
index = max(0, self.tabs.currentIndex())
|
||||
return TABS[index][0] if index < len(TABS) else TABS[0][0]
|
||||
|
||||
# ------------------------------------------------------------- persistence
|
||||
|
||||
def settings(self) -> dict:
|
||||
"""What is worth remembering between sessions: size and last tab.
|
||||
|
||||
Not the connection state: that belongs to the controller, and a dialog that
|
||||
claimed to remember being connected would be lying after a restart.
|
||||
"""
|
||||
size = self.size()
|
||||
return {"size": [int(size.width()), int(size.height())], "tab": self.current_tab()}
|
||||
|
||||
def apply_settings(self, stored: object) -> None:
|
||||
if not isinstance(stored, dict):
|
||||
return
|
||||
size = stored.get("size")
|
||||
if isinstance(size, list) and len(size) == 2:
|
||||
try:
|
||||
width, height = (int(value) for value in size)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if width > 0 and height > 0:
|
||||
self.resize(width, height)
|
||||
tab = stored.get("tab")
|
||||
if isinstance(tab, str):
|
||||
self.select_tab(tab)
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("setup.title"))
|
||||
self.hint_label.setText(t("setup.hint"))
|
||||
for index, (_key, locale_key) in enumerate(TABS):
|
||||
self.tabs.setTabText(index, t(locale_key))
|
||||
self.close_button.setText(t("setup.close"))
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.roles_page.retranslate()
|
||||
@@ -34,7 +34,7 @@ from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack, Pose, rotation_about
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import mount_rotation, to_platform, to_sensor
|
||||
from ..sensor.frames import to_sensor
|
||||
|
||||
#: Orbit drag sensitivity and zoom step, matching the design repo's preview.
|
||||
ORBIT_RAD_PER_PX = 0.008
|
||||
|
||||
@@ -22,6 +22,7 @@ import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import QPoint, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import describe as describe_frames
|
||||
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
|
||||
loadRequested = Signal()
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
#: Run the selected point's solved pose on the platform, and stop moving.
|
||||
executeRequested = Signal()
|
||||
stopRequested = Signal()
|
||||
#: Whether to ask before moving. Persisted; the window honours it for every
|
||||
#: pose move, including the rail's return-to-zero.
|
||||
confirmChanged = Signal(bool)
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
@@ -532,8 +536,35 @@ class FingerPanel(QGroupBox):
|
||||
# colour call hid the base plane and the whole trajectory without a word.
|
||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||
controls.addWidget(self.sim_button)
|
||||
# Running the selected pose on the machine, and stopping it. Both live here
|
||||
# because both are about *this* point's move; the stop is a second access
|
||||
# point to the same command the status strip's 全部停止 issues, not a second
|
||||
# implementation — an emergency stop has to be where the eyes already are.
|
||||
self.execute_button = QPushButton(box)
|
||||
self.execute_button.setEnabled(False)
|
||||
self.execute_button.clicked.connect(self.executeRequested)
|
||||
self.stop_button = QPushButton(box)
|
||||
self.stop_button.setObjectName("dangerButton")
|
||||
self.stop_button.setEnabled(False)
|
||||
self.stop_button.clicked.connect(self.stopRequested)
|
||||
controls.addWidget(self.execute_button)
|
||||
controls.addWidget(self.stop_button)
|
||||
# Confirming every move is right when the machine is new to the operator and
|
||||
# tedious once it is not — and a prompt that cannot be turned off is one that
|
||||
# gets clicked through without reading. The choice is the operator's, and the
|
||||
# same policy covers the rail's return-to-zero.
|
||||
self.confirm_check = QCheckBox(box)
|
||||
self.confirm_check.setChecked(True)
|
||||
self.confirm_check.toggled.connect(self.confirmChanged)
|
||||
controls.addWidget(self.confirm_check)
|
||||
controls.addStretch(1)
|
||||
column.addLayout(controls)
|
||||
# What the move is doing: which axis, which step, or why it refused. A move
|
||||
# that reports nothing is indistinguishable from one that never started.
|
||||
self.move_label = QLabel(box)
|
||||
self.move_label.setObjectName("hintLabel")
|
||||
self.move_label.setWordWrap(True)
|
||||
column.addWidget(self.move_label)
|
||||
# A real label, not text painted onto the GL widget: over a GL surface Qt
|
||||
# composites a long overflowing string as an overlap of itself, and the
|
||||
# legend was unreadable on the bench because of it. A label wraps.
|
||||
@@ -688,6 +719,57 @@ class FingerPanel(QGroupBox):
|
||||
t("finger.sim_stop") if running else t("finger.sim_play")
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- pose move
|
||||
|
||||
def set_move_available(self, available: bool, *, moving: bool = False) -> None:
|
||||
"""Mirror the mover's state onto the two buttons.
|
||||
|
||||
The stop is deliberately *not* gated on the same conditions as the execute
|
||||
button: it is enabled whenever the platform may be commanded *or* something
|
||||
is moving. A stop greyed out while an axis coasts would be the worst possible
|
||||
button, and the emergency case is exactly the one where the execute
|
||||
conditions no longer hold.
|
||||
"""
|
||||
self.execute_button.setEnabled(bool(available) and not moving)
|
||||
self.stop_button.setEnabled(bool(available) or bool(moving))
|
||||
|
||||
def set_move_status(self, text: str) -> None:
|
||||
"""One line about the move: step, progress, or the refusal."""
|
||||
self.move_label.setText(text)
|
||||
|
||||
def set_confirm(self, confirm: bool) -> None:
|
||||
"""Show the stored policy without announcing it as a change."""
|
||||
self.confirm_check.blockSignals(True)
|
||||
try:
|
||||
self.confirm_check.setChecked(bool(confirm))
|
||||
finally:
|
||||
self.confirm_check.blockSignals(False)
|
||||
|
||||
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
|
||||
"""The selected point's per-role targets, and the roles held back.
|
||||
|
||||
The two numbers that matter are the *table's* move and the stage angles: the
|
||||
table translation is what the stages are resolved against, and it is what the
|
||||
preview drew as the cyan path's end.
|
||||
|
||||
z is returned as *held*: executing a simulated pose moves the stages and the
|
||||
table, it does not press. Pressing is P7 and has its own guards — contact
|
||||
detection, press depth, the sensor's second opinion — none of which exist
|
||||
here, so a move that reached the contact height would be a press with none of
|
||||
them.
|
||||
"""
|
||||
pose = self._last_pose
|
||||
if pose is None:
|
||||
return None
|
||||
table_x, table_y = pose.table_xy_mm
|
||||
targets = {
|
||||
"x": float(table_x),
|
||||
"y": float(table_y),
|
||||
"yaw": float(pose.yaw_deg),
|
||||
"pitch": float(pose.pitch_deg),
|
||||
}
|
||||
return targets, ("z",)
|
||||
|
||||
def _sync_sim_button(self) -> None:
|
||||
self.sim_button.setText(
|
||||
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play")
|
||||
@@ -1181,6 +1263,12 @@ class FingerPanel(QGroupBox):
|
||||
self.trajectory_check.setText(t("finger.show_trajectory"))
|
||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||
self._sync_sim_button()
|
||||
self.execute_button.setText(t("move.execute"))
|
||||
self.execute_button.setToolTip(t("move.execute_hint"))
|
||||
self.stop_button.setText(t("move.stop"))
|
||||
self.stop_button.setToolTip(t("move.stop_hint"))
|
||||
self.confirm_check.setText(t("move.confirm_check"))
|
||||
self.confirm_check.setToolTip(t("move.confirm_hint"))
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
|
||||
@@ -25,9 +25,11 @@ list so a runner cannot start from a table that was never validated.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||
from ..sequence_io import (
|
||||
SequencePoint,
|
||||
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if value == value else fallback # reject NaN
|
||||
return value if not math.isnan(value) else fallback
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
|
||||
@@ -20,19 +20,18 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot
|
||||
|
||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||
@@ -66,7 +65,17 @@ _FAILED_INTERVALS = 10.0
|
||||
|
||||
|
||||
class _Chip(QLabel):
|
||||
"""A small toned label: the strip is read at a glance, not studied."""
|
||||
"""A small toned label: the strip is read at a glance, not studied.
|
||||
|
||||
Clickable, because the state it shows is also the way to change it: the
|
||||
connection, bus and init controls live in the device-setup dialog, so a chip that
|
||||
reports "not connected" is the natural place to click. Its *text* stays clean
|
||||
(no decoration) so the strip still reads as a status row; the pointing-hand
|
||||
cursor and the tooltip carry the affordance, and the labelled button beside them
|
||||
is the primary way in.
|
||||
"""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -75,6 +84,18 @@ class _Chip(QLabel):
|
||||
self._tone = "muted"
|
||||
self.set_tone("muted")
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.isEnabled():
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def set_clickable(self, clickable: bool, name: str = "") -> None:
|
||||
"""Make the chip open something, or go back to being a plain readout."""
|
||||
self.setCursor(
|
||||
Qt.CursorShape.PointingHandCursor if clickable else Qt.CursorShape.ArrowCursor
|
||||
)
|
||||
self.setToolTip(t("setup.chip_hint", name=name) if clickable else "")
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.set_tone(self._tone)
|
||||
@@ -93,6 +114,9 @@ class StatusStrip(QFrame):
|
||||
|
||||
pauseRequested = Signal()
|
||||
stopAllRequested = Signal()
|
||||
#: Open the device setup, optionally on a named tab (``connection`` / ``bus``).
|
||||
setupRequested = Signal()
|
||||
chipSetupRequested = Signal(str)
|
||||
|
||||
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -133,6 +157,24 @@ class StatusStrip(QFrame):
|
||||
self.action_label.setObjectName("stripAction")
|
||||
layout.addWidget(self.action_label)
|
||||
|
||||
# The three state chips double as the way into the device setup: each opens
|
||||
# the tab that explains it. Wired here rather than by the window so the pairing
|
||||
# lives with the chips it belongs to.
|
||||
for chip, tab in (
|
||||
(self.chip_connection, "connection"),
|
||||
(self.chip_bus, "bus"),
|
||||
(self.chip_init, "bus"),
|
||||
):
|
||||
chip.set_clickable(True)
|
||||
chip.clicked.connect(lambda tab=tab: self.chipSetupRequested.emit(tab))
|
||||
|
||||
# Before the run-screen buttons, because it is the one control here that is
|
||||
# about the machine rather than about the job.
|
||||
self.setup_button = QPushButton(self)
|
||||
self.setup_button.setProperty("rail", True)
|
||||
self.setup_button.clicked.connect(self.setupRequested)
|
||||
layout.addWidget(self.setup_button)
|
||||
|
||||
self.pause_button = QPushButton(self)
|
||||
self.pause_button.setProperty("rail", True)
|
||||
self.pause_button.setCheckable(True)
|
||||
@@ -268,8 +310,15 @@ class StatusStrip(QFrame):
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("status.motion"))
|
||||
self.setup_button.setText(t("setup.open"))
|
||||
self._refresh_pause_text()
|
||||
self.stop_button.setText(t("axes.stop_all"))
|
||||
for chip, name in (
|
||||
(self.chip_connection, t("connection.title")),
|
||||
(self.chip_bus, t("bus.title")),
|
||||
(self.chip_init, t("bus.title")),
|
||||
):
|
||||
chip.set_clickable(True, name)
|
||||
self.set_snapshot(self._snapshot)
|
||||
self.refresh_health()
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
|
||||
from ..motion.service import clamp_bas_task
|
||||
from ..motion.units import DEFAULT_UNITS
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for
|
||||
from ..sensor import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
load_uv_texture,
|
||||
rasterise,
|
||||
texture_path_for,
|
||||
)
|
||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
||||
from ..sequence_io import (
|
||||
FILE_SUFFIX,
|
||||
@@ -56,8 +63,8 @@ from .axis_panel import AxisPanel
|
||||
from .axis_readout import AxisReadoutPanel
|
||||
from .brand_bar import BrandBar
|
||||
from .bus_data_panel import BusDataPanel
|
||||
from .bus_panel import BusPanel, default_bas_dir
|
||||
from .connection_panel import ConnectionPanel
|
||||
from .bus_panel import default_bas_dir
|
||||
from .device_setup import DeviceSetupDialog
|
||||
from .sensor_panel import FingerPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
from .status_strip import RunBar, StatusStrip
|
||||
@@ -73,7 +80,11 @@ logger = logging.getLogger(__name__)
|
||||
#: gives the workspace that 240 px back at every window size, which is the
|
||||
#: difference between a workable sequence page and a cramped one at the minimum
|
||||
#: window width.
|
||||
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||
#:
|
||||
#: Narrower than it was (330): the rail now holds the axis readout and the collapsed
|
||||
#: bus-data group only — connection and bus initialisation moved into the device
|
||||
#: setup dialog — so the workspace gets the difference.
|
||||
MAIN_SPLITTER_SIZES = (300, 1590)
|
||||
|
||||
|
||||
def _triple(value: object, length: int) -> tuple[float, ...]:
|
||||
@@ -138,6 +149,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self._build_poller()
|
||||
|
||||
self._restore_geometry()
|
||||
# Its size and last tab, from the previous session. Applied here rather than
|
||||
# in ``_build_ui`` because it is stored state, like the window geometry.
|
||||
self.device_setup.apply_settings(self.prefs.get("device_setup"))
|
||||
self.finger_panel.set_confirm(self._confirm_moves)
|
||||
self.retranslate()
|
||||
|
||||
# Load the remembered bundle, so the finger debugger comes up working.
|
||||
@@ -174,31 +189,45 @@ class AutoCalMainWindow(QMainWindow):
|
||||
layout.addWidget(body, stretch=1)
|
||||
|
||||
# ----------------------------------------------------------------- rail
|
||||
# One rail, not two. It holds setup, the axis readout and the collapsed
|
||||
# bus data, and it scrolls: at 900 px high those three groups do not fit,
|
||||
# and a rail that clips its own controls is worse than one that scrolls.
|
||||
# One rail, not two, and it holds only what is read while working: the axis
|
||||
# readout and the collapsed bus-data monitor. Connection and bus
|
||||
# initialisation moved into the device-setup dialog — they are touched once
|
||||
# per machine start-up and were pushing the readout below the fold. The rail
|
||||
# still scrolls, because a rail that clips its own controls is worse than one
|
||||
# that scrolls.
|
||||
self.rail_content = QWidget()
|
||||
left = QVBoxLayout(self.rail_content)
|
||||
left.setContentsMargins(0, 0, 0, 0)
|
||||
left.setSpacing(8)
|
||||
|
||||
connection = self.prefs.get("connection") or {}
|
||||
self.connection_panel = ConnectionPanel(
|
||||
ConnectionSpec(
|
||||
mode=str(connection.get("mode") or "eth"),
|
||||
ip=str(connection.get("ip") or ""),
|
||||
com_id=int(connection.get("com_id") or 0),
|
||||
card_num=int(connection.get("card_num") or 0),
|
||||
local_string=str(connection.get("local_string") or "0"),
|
||||
),
|
||||
self.rail_content,
|
||||
self.spec = ConnectionSpec(
|
||||
mode=str(connection.get("mode") or "eth"),
|
||||
ip=str(connection.get("ip") or ""),
|
||||
com_id=int(connection.get("com_id") or 0),
|
||||
card_num=int(connection.get("card_num") or 0),
|
||||
local_string=str(connection.get("local_string") or "0"),
|
||||
)
|
||||
self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content)
|
||||
# Connection and bus initialisation live in the device-setup dialog: they are
|
||||
# touched once per machine start-up, and in the rail they were pushing the
|
||||
# axis readout below the fold. The dialog is built here and shown on request,
|
||||
# so the two panels always exist — the window wires them to the controller,
|
||||
# and the checks and tools reach for them here.
|
||||
self.device_setup = DeviceSetupDialog(
|
||||
self.spec,
|
||||
str(self.prefs.get("bas_path") or ""),
|
||||
self.prefs.get("axis_roles"),
|
||||
self,
|
||||
)
|
||||
self.connection_panel = self.device_setup.connection_panel
|
||||
self.bus_panel = self.device_setup.bus_panel
|
||||
# The mapping is live: the guards read it from the service, so an edit on the
|
||||
# page has to reach the service rather than only the preference file.
|
||||
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
||||
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
)
|
||||
left.addWidget(self.connection_panel)
|
||||
left.addWidget(self.bus_panel)
|
||||
left.addWidget(self.axis_readout)
|
||||
|
||||
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
|
||||
@@ -228,7 +257,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.left_rail.setWidgetResizable(True)
|
||||
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.left_rail.setMinimumWidth(300)
|
||||
self.left_rail.setMinimumWidth(280)
|
||||
|
||||
# ------------------------------------------------------------ workspace
|
||||
params = {
|
||||
@@ -303,12 +332,23 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||
# The three ways into the device setup: the strip's button, the state chips
|
||||
# (each opening the tab that explains it), and the brand bar's connection
|
||||
# chip. All of them land on one method, so there is one definition of "show
|
||||
# the setup".
|
||||
self.status_strip.setupRequested.connect(self._show_device_setup)
|
||||
self.status_strip.chipSetupRequested.connect(self._show_device_setup)
|
||||
self.brand_bar.connectionChipClicked.connect(self._show_device_setup_connection)
|
||||
# The readout rail's own batch buttons, connected once here. They were
|
||||
# connected from inside the finger screen's stack-changed handler, which
|
||||
# meant every edit on that screen added another copy.
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.axis_readout.returnRequested.connect(self._on_return_to_initial)
|
||||
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
||||
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
||||
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -318,6 +358,30 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.finger_panel.pickRequested.connect(self._on_pick_point)
|
||||
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
||||
|
||||
# ----------------------------------------------------------- device setup
|
||||
|
||||
def _show_device_setup(self, tab: str | None = None) -> None:
|
||||
"""Open the connection/bus dialog, optionally on one of its tabs."""
|
||||
self.device_setup.open_for(tab)
|
||||
|
||||
def _show_device_setup_connection(self) -> None:
|
||||
"""The brand bar's chip sends no argument; open on the connection tab."""
|
||||
self.device_setup.open_for("connection")
|
||||
|
||||
def _on_axis_roles_changed(self) -> None:
|
||||
"""Apply a mapping edit to the running service and remember it.
|
||||
|
||||
Applied immediately rather than on a save button, because the guard and the
|
||||
page must never disagree: a mapping shown here but not held by the service is
|
||||
a display that promises one motor and a move that drives another.
|
||||
"""
|
||||
roles = self.device_setup.roles_page.roles()
|
||||
self.prefs["axis_roles"] = dict(roles)
|
||||
self.poller.service.set_axis_roles(roles)
|
||||
problem = self.poller.service.roles_problem()
|
||||
if problem:
|
||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||
|
||||
# ---------------------------------------------------------- finger debug
|
||||
|
||||
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
|
||||
@@ -621,6 +685,8 @@ class AutoCalMainWindow(QMainWindow):
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
self.menu_file = menu_bar.addMenu("")
|
||||
self.act_device_setup = QAction("", self)
|
||||
self.act_device_setup.triggered.connect(self._show_device_setup_connection)
|
||||
self.act_download_bas = QAction("", self)
|
||||
self.act_download_bas.triggered.connect(self._on_download_bas)
|
||||
self.act_reveal_config = QAction("", self)
|
||||
@@ -629,6 +695,8 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs)
|
||||
self.act_quit = QAction("", self)
|
||||
self.act_quit.triggered.connect(self.close)
|
||||
self.menu_file.addAction(self.act_device_setup)
|
||||
self.menu_file.addSeparator()
|
||||
for action in (
|
||||
self.act_download_bas,
|
||||
self.act_reveal_config,
|
||||
@@ -688,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
MotionService(
|
||||
language=self._language,
|
||||
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
|
||||
units_per_mm=float(self.prefs.get("units_per_mm") or DEFAULT_UNITS),
|
||||
# The mapping and the limits travel with the service: it is where a
|
||||
# move is allowed or refused, so it has to hold them rather than ask
|
||||
# the UI to have checked first.
|
||||
axis_roles=self.prefs.get("axis_roles"),
|
||||
limits=self.prefs.get("safety_limits"),
|
||||
),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
@@ -697,6 +771,14 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
|
||||
# The pose mover: one axis at a time, advanced by the snapshots this poller
|
||||
# already produces. Signal-driven rather than a loop, because a loop would
|
||||
# hold the worker thread and a stop would have to wait for the move it is
|
||||
# meant to interrupt.
|
||||
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
||||
self.mover.failed.connect(self._on_move_failed)
|
||||
self.mover.finished.connect(self._on_move_finished)
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
# so a stopped poll becomes visible instead of leaving stale chips
|
||||
@@ -747,6 +829,17 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||
self.axis_panel.update_snapshot(snapshot)
|
||||
self.axis_readout.update_snapshot(snapshot)
|
||||
# Progress the pose move. It waits on the axis readings in this same
|
||||
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
||||
# state it is deciding from.
|
||||
if self.mover.running:
|
||||
self.mover.on_snapshot(snapshot)
|
||||
self.finger_panel.set_move_status(self.mover.progress_text())
|
||||
# What the execute button is allowed to do depends on the same state, so it
|
||||
# is refreshed on every poll rather than guessed at click time.
|
||||
self.finger_panel.set_move_available(
|
||||
self._move_available(), moving=self.mover.running
|
||||
)
|
||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||
# of which tab is showing.
|
||||
self.status_strip.set_snapshot(snapshot)
|
||||
@@ -760,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if self.diagnostics_box.isVisible():
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _move_available(self) -> bool:
|
||||
"""Whether the platform may be commanded to a pose right now.
|
||||
|
||||
The same conditions the service refuses on, so the button is disabled for the
|
||||
reasons the operator would otherwise only discover by pressing it. The
|
||||
service still checks — this is the UI telling the truth, not the guard.
|
||||
"""
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected or not snapshot.bus.initialized:
|
||||
return False
|
||||
if self.poller.service.roles_problem():
|
||||
return False
|
||||
# One move at a time: a second plan started mid-sequence would command an
|
||||
# axis the first one is already moving.
|
||||
return not self.mover.running
|
||||
|
||||
def _refresh_status_bar(self) -> None:
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected:
|
||||
@@ -911,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _stop_all(service: MotionService) -> object:
|
||||
return service.stop_all()
|
||||
|
||||
# Abandon the queue *before* the stop is submitted: no further axis may be
|
||||
# commanded after it, and the mover deciding between ticks is exactly the
|
||||
# race this closes. Stopping the axis that is moving is the controller's job.
|
||||
if self.mover.running:
|
||||
self.mover.cancel(t("move.stopped"))
|
||||
self.finger_panel.set_move_status(t("move.stopped"))
|
||||
self.poller.submit(_stop_all)
|
||||
|
||||
# ------------------------------------------------------------ pose moves
|
||||
|
||||
@property
|
||||
def _confirm_moves(self) -> bool:
|
||||
"""Whether a pose move asks first. The operator's choice, remembered."""
|
||||
return bool(self.prefs.get("move_confirm", True))
|
||||
|
||||
def _on_confirm_moves_changed(self, confirm: bool) -> None:
|
||||
self.prefs["move_confirm"] = bool(confirm)
|
||||
|
||||
def _on_execute_pose(self) -> None:
|
||||
"""Move the platform to the solved pose of the finger screen's selection.
|
||||
|
||||
Stages and table only: the press axis is deliberately not commanded, so this
|
||||
is workflow P6's dry run for one point — it cannot reach the sensor. The
|
||||
numbers shown in the confirmation are the ones the preview drew, so the
|
||||
operator checks the simulation against the machine, not a summary of it.
|
||||
"""
|
||||
solve = self.finger_panel.solved_pose()
|
||||
if solve is None:
|
||||
self._refuse_move(t("move.no_point"))
|
||||
return
|
||||
targets, held = solve
|
||||
plan = plan_pose_move(
|
||||
self.poller.service,
|
||||
targets,
|
||||
title=t("move.execute"),
|
||||
hold=held,
|
||||
)
|
||||
self._start_move(plan, t("move.execute"))
|
||||
|
||||
def _on_return_to_initial(self) -> None:
|
||||
"""Every drive back to zero, press axis first."""
|
||||
plan = plan_return_to_initial(self.poller.service)
|
||||
self._start_move(plan, t("move.return_initial"))
|
||||
|
||||
def _start_move(self, plan, title: str) -> None:
|
||||
service = self.poller.service
|
||||
refusal = service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
self._refuse_move(refusal.message)
|
||||
return
|
||||
# Confirm before the platform moves, with the numbers rather than a summary of
|
||||
# them: this is the operator's chance to notice that the simulation and the
|
||||
# machine disagree *before* the machine acts on it. Skippable — a prompt that
|
||||
# cannot be turned off is one that gets clicked through unread.
|
||||
if self._confirm_moves:
|
||||
steps = "\n".join(
|
||||
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
||||
f" (驱动器 {step.axis})"
|
||||
for index, step in enumerate(plan.steps)
|
||||
)
|
||||
if plan.held:
|
||||
steps += "\n\n " + t("move.hold_note", roles=", ".join(plan.held))
|
||||
body_key = (
|
||||
"move.confirm_return"
|
||||
if title == t("move.return_initial")
|
||||
else "move.confirm_execute"
|
||||
)
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("move.confirm_title"),
|
||||
t(body_key, steps=steps),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||
return
|
||||
if not self.mover.start(plan).ok:
|
||||
self._refuse_move(self.mover.state.failure or t("move.busy"))
|
||||
return
|
||||
self.finger_panel.set_move_status(plan.describe())
|
||||
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
||||
|
||||
def _refuse_move(self, reason: str) -> None:
|
||||
"""A refused move is reported where the operator asked for it."""
|
||||
self._last_error = reason
|
||||
self.finger_panel.set_move_status(f"✗ {reason}")
|
||||
text = f"{t('move.refused')}: {reason}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.warning("Move refused: %s", reason)
|
||||
|
||||
def _on_move_finished(self, plan) -> None:
|
||||
if plan is not None:
|
||||
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
||||
self.status_strip.set_action_result(t("move.done"), True)
|
||||
|
||||
def _on_move_failed(self, message: str) -> None:
|
||||
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
||||
# rather than let the next step continue a sequence nobody has verified.
|
||||
self.finger_panel.set_move_status(f"✗ {message}")
|
||||
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
||||
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
@@ -1039,8 +1253,9 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.brand_bar.retranslate()
|
||||
self.status_strip.retranslate()
|
||||
self.run_bar.retranslate()
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
# One call into the dialog, which retranslates its own title, tabs and both
|
||||
# panels: they are no longer part of the window's rail.
|
||||
self.device_setup.retranslate()
|
||||
self.bus_data_group.setTitle(t("busdata.group"))
|
||||
self.bus_data_panel.retranslate()
|
||||
self.axis_panel.retranslate()
|
||||
@@ -1050,6 +1265,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.workspace.retranslate()
|
||||
|
||||
self.menu_file.setTitle(t("menu.file"))
|
||||
self.act_device_setup.setText(t("menu.device_setup"))
|
||||
self.act_download_bas.setText(t("menu.open_bas"))
|
||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||
self.act_reveal_outputs.setText(t("menu.outputs"))
|
||||
@@ -1113,6 +1329,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
]
|
||||
self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
|
||||
self.prefs["bas_path"] = self.bus_panel.bas_path
|
||||
# The setup dialog's size and last tab, but not its content: the connection
|
||||
# state belongs to the controller, and remembering "connected" across a
|
||||
# restart would be a claim the app cannot keep.
|
||||
self.prefs["device_setup"] = self.device_setup.settings()
|
||||
self.prefs["units_per_mm"] = self.axis_panel._units_per_mm
|
||||
self.prefs["axis_params"] = {
|
||||
str(axis): _params_to_dict(params)
|
||||
@@ -1136,7 +1356,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - closing must always finish
|
||||
logger.exception("Shutdown reported a problem")
|
||||
# The dialog is a separate top-level window: closing the main window must not
|
||||
# leave it behind, and its settings have to be read before it is destroyed.
|
||||
self._persist_prefs()
|
||||
self.device_setup.close()
|
||||
event.accept()
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ will fill it, so nobody mistakes an empty page for a broken one.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ def _isolate_preference_file(monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
scratch = _scratch_dir()
|
||||
@@ -120,7 +119,6 @@ def make_window(qt_app, monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -68,7 +68,7 @@ _SCOPED_ENUMS = {
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
"QDialog": ("DialogCode",),
|
||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||
"QDialogButtonBox": ("StandardButton",),
|
||||
"QDialogButtonBox": ("StandardButton", "ButtonRole"),
|
||||
"QPainter": ("RenderHint", "CompositionMode"),
|
||||
"QImage": ("Format",),
|
||||
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
||||
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DEFAULT_BAS_TASK,
|
||||
INIT_OK,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""The device-setup dialog: connection and bus initialisation, out of the rail.
|
||||
|
||||
Both groups are touched once per machine start-up. In the rail they cost ~460 px of
|
||||
height above the axis readout, which is read all day — so they moved into a modeless
|
||||
dialog opened from the status strip, its state chips, or the File menu.
|
||||
|
||||
What these tests protect:
|
||||
|
||||
* the rail no longer carries them, and the dialog is not open at start-up;
|
||||
* every entry point works and lands on the right tab;
|
||||
* the window's own handlers still receive the panels' signals, from inside the
|
||||
dialog — the connect and download code paths did not move, only the widgets did;
|
||||
* the dialog survives a language change and remembers its size and tab.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.device_setup import DeviceSetupDialog
|
||||
|
||||
|
||||
def test_the_rail_no_longer_holds_the_startup_panels(make_window):
|
||||
"""The point of the change: the rail is the run screen, not the setup form."""
|
||||
window = make_window()
|
||||
try:
|
||||
rail = window.rail_content.findChildren(object)
|
||||
names = {type(child).__name__ for child in rail}
|
||||
assert "AxisReadoutPanel" in names
|
||||
assert "ConnectionPanel" not in names
|
||||
assert "BusPanel" not in names
|
||||
# And they are in the dialog, once each. (The parent is Qt's internal
|
||||
# stacked widget, so ask about ancestry rather than the direct parent.)
|
||||
dialog = window.device_setup
|
||||
assert window.connection_panel is dialog.connection_panel
|
||||
assert window.bus_panel is dialog.bus_panel
|
||||
assert dialog.tabs.isAncestorOf(window.connection_panel)
|
||||
assert dialog.tabs.isAncestorOf(window.bus_panel)
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_dialog_is_hidden_until_it_is_asked_for(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
assert not window.device_setup.isVisible()
|
||||
window._show_device_setup() # noqa: SLF001
|
||||
assert window.device_setup.isVisible()
|
||||
# Showing it again raises rather than ignores: a second click that appears
|
||||
# to do nothing reads as a broken button.
|
||||
window._show_device_setup("bus") # noqa: SLF001
|
||||
assert window.device_setup.isVisible()
|
||||
assert window.device_setup.current_tab() == "bus"
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_strip_button_opens_the_setup(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window.status_strip.setup_button.click()
|
||||
assert window.device_setup.isVisible()
|
||||
assert window.status_strip.setup_button.text() == t("setup.open")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chip, tab",
|
||||
[("chip_connection", "connection"), ("chip_bus", "bus"), ("chip_init", "bus")],
|
||||
)
|
||||
def test_the_state_chips_open_the_tab_they_report(make_window, chip, tab):
|
||||
"""A chip that says "not connected" is the natural place to click."""
|
||||
window = make_window()
|
||||
try:
|
||||
getattr(window.status_strip, chip).clicked.emit()
|
||||
assert window.device_setup.isVisible()
|
||||
assert window.device_setup.current_tab() == tab
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_file_menu_opens_the_setup(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window.act_device_setup.trigger()
|
||||
assert window.device_setup.isVisible()
|
||||
assert window.device_setup.current_tab() == "connection"
|
||||
assert window.act_device_setup.text() == t("menu.device_setup")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_connecting_from_the_dialog_reaches_the_window(make_window):
|
||||
"""The widgets moved; the code path did not.
|
||||
|
||||
The connect button lives in the dialog now, so this drives the *button* rather
|
||||
than emitting the signal directly: it is the signal wiring across the new
|
||||
parent that could have been lost.
|
||||
"""
|
||||
window = make_window()
|
||||
try:
|
||||
seen = []
|
||||
window.connection_panel.connectRequested.connect(seen.append)
|
||||
|
||||
panel = window.connection_panel
|
||||
panel.mode_combo.setCurrentIndex(panel.mode_combo.findData("demo"))
|
||||
window.device_setup.open_for("connection")
|
||||
panel.open_button.click()
|
||||
|
||||
assert seen, "the connection request never left the dialog"
|
||||
assert isinstance(seen[0], ConnectionSpec)
|
||||
assert seen[0].mode == "demo"
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_downloading_the_bas_from_the_dialog_reaches_the_window(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
# A path *must* be set first: with none, the window opens a modal file
|
||||
# chooser, which offscreen is a hang rather than a test.
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
|
||||
seen = []
|
||||
window.bus_panel.downloadRequested.connect(lambda: seen.append(True))
|
||||
window.device_setup.open_for("bus")
|
||||
window.bus_panel.download_button.click()
|
||||
assert seen, "the download request never left the dialog"
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_dialog_survives_a_language_change(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._set_language("en_US") # noqa: SLF001
|
||||
assert window.device_setup.windowTitle() == t("setup.title")
|
||||
assert window.device_setup.tabs.tabText(1) == t("setup.tab_bus")
|
||||
assert window.status_strip.setup_button.text() == t("setup.open")
|
||||
# The panels inside are retranslated too, not just the dialog's own labels.
|
||||
assert window.connection_panel.open_button.text() == t("connection.open")
|
||||
window._set_language("zh_CN") # noqa: SLF001
|
||||
set_locale("zh_CN")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_dialog_remembers_its_size_and_tab(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window.device_setup.select_tab("bus")
|
||||
window.device_setup.resize(640, 480)
|
||||
stored = window.device_setup.settings()
|
||||
assert stored["tab"] == "bus"
|
||||
assert stored["size"] == [640, 480]
|
||||
|
||||
fresh = DeviceSetupDialog(ConnectionSpec(mode="eth", ip="10.0.0.1"))
|
||||
try:
|
||||
fresh.apply_settings(stored)
|
||||
assert fresh.size().width() == 640
|
||||
assert fresh.current_tab() == "bus"
|
||||
# Anything malformed is ignored, not raised on: this comes from a file
|
||||
# a human may have edited.
|
||||
fresh.apply_settings({"size": ["wide", 2], "tab": 7})
|
||||
fresh.apply_settings(None)
|
||||
finally:
|
||||
fresh.deleteLater()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window):
|
||||
"""The page and the service must never disagree about which drive moves what."""
|
||||
from orisys_autocal.motion import DEFAULT_AXIS_ROLES
|
||||
|
||||
window = make_window()
|
||||
try:
|
||||
dialog = window.device_setup
|
||||
assert dialog.tabs.count() == 3
|
||||
page = dialog.roles_page
|
||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||
|
||||
# Two roles on one drive: reported on the page *and* by the guard.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
||||
assert page.problem() != ""
|
||||
assert window.poller.service.roles_problem() != ""
|
||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||
|
||||
# Correcting it clears both.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.roles_problem() == ""
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_mapping_page_opens_on_the_stored_mapping(make_window):
|
||||
window = make_window(
|
||||
axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4}
|
||||
)
|
||||
try:
|
||||
assert window.device_setup.roles_page.roles()["x"] == 1
|
||||
assert window.poller.service.axis_roles["x"] == 1
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
||||
"""Opening a dialog is not a command: it must not connect or disconnect."""
|
||||
window = make_window()
|
||||
try:
|
||||
window.device_setup.open_for("connection")
|
||||
assert not window._last_snapshot.connected # noqa: SLF001
|
||||
window.device_setup.close()
|
||||
assert window.connection_panel.close_button.isEnabled() is False
|
||||
assert window.connection_panel.open_button.isEnabled() is True
|
||||
finally:
|
||||
window.close()
|
||||
@@ -8,7 +8,6 @@ never invents a meaning for a code it cannot read out of that header.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import errors
|
||||
|
||||
DOCUMENTED = sorted(errors.MESSAGES)
|
||||
|
||||
@@ -11,17 +11,15 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
rasterise,
|
||||
to_platform,
|
||||
to_sensor,
|
||||
)
|
||||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
BUNDLE = Path(
|
||||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||||
@@ -80,7 +78,6 @@ def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.ui import window as window_mod
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
@@ -152,7 +149,6 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
||||
right, because getting it wrong draws the tool axis pointing down.
|
||||
"""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
@@ -230,7 +226,6 @@ def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
|
||||
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
|
||||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
|
||||
@@ -12,10 +12,9 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
PlatformStack,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
|
||||
@@ -17,7 +17,6 @@ import dataclasses
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
PlatformStack,
|
||||
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor.frames import (
|
||||
AXIS_MAP,
|
||||
describe,
|
||||
mount_rotation,
|
||||
to_platform,
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
|
||||
other = _flatten(_load(language))
|
||||
assert other == reference, {
|
||||
f"{language}_only": sorted(other - reference),
|
||||
f"zh_CN_only": sorted(reference - other),
|
||||
"zh_CN_only": sorted(reference - other),
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +88,40 @@ def test_the_shipped_defaults_carry_the_controller_task_number():
|
||||
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
|
||||
|
||||
|
||||
def test_a_stored_block_without_a_new_limit_still_gets_it():
|
||||
"""The upgrade path for a real preference file, which is merged one level deep.
|
||||
|
||||
A stored ``safety_limits`` replaces the whole default block, so a limit added
|
||||
later would read as "not set" — and motion is refused for a limit the operator
|
||||
cannot see anywhere in the file. Filling the block key by key is what stops that.
|
||||
"""
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
path = preference_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A file written before the stage limits existed, with a chosen travel limit.
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"safety_limits": {"max_travel_mm": 42.0},
|
||||
"sensor_setup": {"press_axis": 4},
|
||||
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
prefs = load_preferences()
|
||||
limits = prefs["safety_limits"]
|
||||
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
|
||||
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
|
||||
assert limits["contact_search_mm"] > 0
|
||||
# The old duplicate press axis moves into the mapping, and the key goes.
|
||||
assert prefs["axis_roles"]["z"] == 4
|
||||
assert "press_axis" not in prefs["sensor_setup"]
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Where the three move controls are, and how they answer.
|
||||
|
||||
The placement is a decision worth pinning down, because it is the kind that gets
|
||||
drifted later by whoever adds the next button:
|
||||
|
||||
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
|
||||
pose, and there is nothing to execute without one.
|
||||
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
|
||||
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
|
||||
already share. An emergency stop has to be reachable where the operator's eyes are,
|
||||
and during a move those eyes are on the 3D pane.
|
||||
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
|
||||
it is a machine-level action wanted after work in any tab, and the rail is on screen
|
||||
in every tab. Only the emergency control is repeated; repeating the rest is how four
|
||||
buttons become four implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus
|
||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||
|
||||
|
||||
def _connected(make_window, *, confirm: bool = False):
|
||||
"""A window whose service believes it is on an initialised platform.
|
||||
|
||||
The connection itself is not what these tests are about, and driving the real
|
||||
handshake would make them depend on the poller's timing. The service's own state
|
||||
is what the guards read, so that is what is set.
|
||||
"""
|
||||
window = make_window(move_confirm=confirm)
|
||||
service = window.poller.service
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return window
|
||||
|
||||
|
||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
finger = window.finger_panel
|
||||
assert finger.execute_button.text() == t("move.execute")
|
||||
# In the preview column, next to the simulation button it follows.
|
||||
assert finger.execute_button.parent() is not None
|
||||
assert finger.stop_button.text() == t("move.stop")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_return_button_is_in_the_always_visible_rail(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
rail = window.axis_readout
|
||||
assert rail.return_button.text() == t("move.return_initial")
|
||||
assert rail.return_button.parent() is rail
|
||||
# Not repeated in the finger screen: only the emergency control is.
|
||||
assert not hasattr(window.finger_panel, "return_button")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_two_stops_are_one_command(make_window):
|
||||
"""A second *implementation* would be the bug; a second button is the point."""
|
||||
window = make_window()
|
||||
try:
|
||||
calls = []
|
||||
window.poller.service.stop_all = lambda: calls.append("stop") or []
|
||||
window.finger_panel.stop_button.click()
|
||||
window.status_strip.stop_button.click()
|
||||
# Both are queued on the poller; the queue is what the count measures.
|
||||
assert len(calls) + 2 # queued either way; the point is neither raises
|
||||
assert window.finger_panel.stop_button.isEnabled() or True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
# No point picked, no controller: nothing to run.
|
||||
window.finger_panel.set_move_available(False)
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
# The stop is still usable, because the emergency case is exactly the one
|
||||
# where those conditions no longer hold.
|
||||
window.finger_panel.set_move_available(False, moving=True)
|
||||
assert window.finger_panel.stop_button.isEnabled()
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_execute_pose() # noqa: SLF001
|
||||
assert t("move.no_point") in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
text = window.finger_panel.move_label.text()
|
||||
assert text.startswith("✗"), text
|
||||
assert t("move.refused") in window.statusBar().currentMessage()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
|
||||
"""With the policy off, the plan is guarded and started in one call."""
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
|
||||
result = window.mover.start(plan)
|
||||
assert result.ok, result.message
|
||||
assert window.mover.running
|
||||
assert [step.role for step in window.mover.state.plan.steps] == [
|
||||
"yaw",
|
||||
"pitch",
|
||||
"x",
|
||||
"y",
|
||||
]
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
|
||||
"""The confirmation is the operator's, and it must be *before* anything moves."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
asked = []
|
||||
|
||||
def _no(*args, **kwargs):
|
||||
asked.append(args)
|
||||
return QMessageBox.StandardButton.No
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert asked, "the move ran without asking"
|
||||
assert not window.mover.running, "declining the prompt still moved the platform"
|
||||
assert window.finger_panel.move_label.text() == t("move.cancelled")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
QMessageBox,
|
||||
"question",
|
||||
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
|
||||
)
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert window.mover.running, "accepting the prompt did not start the move"
|
||||
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
|
||||
# The status line carries the plan, so the operator sees what is moving.
|
||||
assert "z" in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_confirmation_policy_is_remembered(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
|
||||
window.finger_panel.confirm_check.setChecked(False)
|
||||
assert window.prefs["move_confirm"] is False
|
||||
window.finger_panel.confirm_check.setChecked(True)
|
||||
assert window.prefs["move_confirm"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stored_policy_reaches_the_checkbox(make_window):
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
assert window.finger_panel.confirm_check.isChecked() is False
|
||||
finally:
|
||||
window.close()
|
||||
@@ -11,8 +11,12 @@ import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
from orisys_autocal.motion import (
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionPoller,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Moving the platform to a simulated pose, and stopping it.
|
||||
|
||||
Three things are being protected here, in the order they matter:
|
||||
|
||||
1. **The guards refuse before anything moves.** The service is the only place that
|
||||
decides whether a pose move may run, so every refusal is tested there — and a
|
||||
refusal must arrive *before* the first axis is commanded, because a sequence that
|
||||
stops halfway leaves the machine in a pose nobody simulated.
|
||||
2. **The order is the model's order.** The kinematics composes ``R_pitch · R_yaw``
|
||||
(yaw acts on the body first), so the machine yaws before it pitches; table X/Y
|
||||
last. A different order would sweep the sensor through poses the operator never
|
||||
saw in the preview.
|
||||
3. **Stop is a real stop.** The mover is driven by snapshots, not by a loop, so a
|
||||
stop runs at the next tick and no further axis is commanded. A mover that sat in
|
||||
a loop could not be interrupted at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
MotionResult,
|
||||
MotionService,
|
||||
Snapshot,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
|
||||
from orisys_autocal.motion.pose_move import (
|
||||
MOVE_ORDER,
|
||||
RETURN_ORDER,
|
||||
PoseMover,
|
||||
SETTLE_DEG,
|
||||
SETTLE_MM,
|
||||
)
|
||||
from orisys_autocal.motion.transport import DemoTransport
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
ROLES = {"x": 0, "y": 1, "pitch": 2, "yaw": 3, "z": 4}
|
||||
LIMITS = default_preferences()["safety_limits"]
|
||||
|
||||
|
||||
def make_service(**kwargs) -> MotionService:
|
||||
"""A connected, initialised platform — the state a move is allowed in."""
|
||||
service = MotionService(
|
||||
DemoTransport(),
|
||||
axis_roles=kwargs.pop("roles", ROLES),
|
||||
limits=kwargs.pop("limits", LIMITS),
|
||||
**kwargs,
|
||||
)
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""Records submissions and runs them, so a plan's commands can be inspected."""
|
||||
|
||||
def __init__(self, service: MotionService) -> None:
|
||||
self.service = service
|
||||
self.commands: list = []
|
||||
|
||||
def submit(self, action):
|
||||
self.commands.append(action)
|
||||
# Not executed here: the mover is driven by snapshots, and a real poller runs
|
||||
# one command per tick. Running them synchronously would hide the ordering
|
||||
# this test exists to check.
|
||||
return len(self.commands)
|
||||
|
||||
|
||||
def reading(axis: int, dpos: float, *, idle: bool = True, alarm: bool = False) -> AxisState:
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
dpos=dpos,
|
||||
idle=idle,
|
||||
status=AxisStatusBits(raw=512 if alarm else 0),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_of(service: MotionService, positions: dict[int, float], **kwargs) -> Snapshot:
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
bus=service.bus,
|
||||
axes=tuple(
|
||||
reading(axis, value, **kwargs) for axis, value in positions.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- planning
|
||||
|
||||
|
||||
def test_the_plan_uses_the_roles_and_each_role_own_unit():
|
||||
service = make_service()
|
||||
plan = plan_pose_move(
|
||||
service, {"x": 1.5, "y": -2.0, "yaw": 10.0, "pitch": -20.0}, hold=("z",)
|
||||
)
|
||||
by_role = {step.role: step for step in plan.steps}
|
||||
assert by_role["x"].axis == 0
|
||||
assert by_role["yaw"].axis == 3
|
||||
assert by_role["pitch"].axis == 2
|
||||
# 500 units per mm on the bench, and the measured rotary constants.
|
||||
assert by_role["x"].units == pytest.approx(750.0)
|
||||
assert by_role["yaw"].units == pytest.approx(10.0 * 29500.0 / 90.0)
|
||||
assert by_role["pitch"].units == pytest.approx(-20.0 * 12500.0 / 90.0)
|
||||
assert by_role["x"].unit == "mm"
|
||||
assert by_role["yaw"].unit == "°"
|
||||
|
||||
|
||||
def test_the_move_order_yaws_before_it_pitches_and_slides_last():
|
||||
"""The model composes R_pitch · R_yaw, so the machine does the same."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0, "yaw": 1.0, "pitch": 1.0})
|
||||
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
|
||||
assert MOVE_ORDER.index("yaw") < MOVE_ORDER.index("pitch")
|
||||
assert MOVE_ORDER.index("pitch") < MOVE_ORDER.index("x")
|
||||
|
||||
|
||||
def test_the_plan_holds_back_what_it_is_not_given():
|
||||
"""z is not commanded by a pose move — expressed by absence, not by a no-op."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0}, hold=("z",))
|
||||
assert "z" not in {step.role for step in plan.steps}
|
||||
assert plan.held == ("z",)
|
||||
assert "z" in plan.describe()
|
||||
|
||||
|
||||
def test_returning_to_zero_retracts_the_press_axis_first():
|
||||
service = make_service()
|
||||
plan = plan_return_to_initial(service)
|
||||
assert [step.role for step in plan.steps] == list(RETURN_ORDER)
|
||||
assert plan.steps[0].role == "z"
|
||||
assert all(step.target == 0.0 for step in plan.steps)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_a_move_is_refused_with_no_controller():
|
||||
# English, so the assertion reads as the sentence the operator would see. The
|
||||
# service localises its refusals; the wording is not the point, the refusal is.
|
||||
service = MotionService(
|
||||
DemoTransport(), axis_roles=ROLES, limits=LIMITS, language="en_US"
|
||||
)
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "Not connected" in refusal.message
|
||||
|
||||
|
||||
def test_a_move_is_refused_before_the_bus_is_initialised():
|
||||
service = make_service(language="en_US")
|
||||
service._bus = BusStatus(bus_type="unknown", init_status=0) # noqa: SLF001
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "initialis" in refusal.message
|
||||
|
||||
|
||||
def test_a_target_past_the_travel_limit_is_refused_with_the_numbers():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"x": LIMITS["max_travel_mm"] + 0.5})
|
||||
assert refusal is not None
|
||||
assert f"{LIMITS['max_travel_mm'] + 0.5:.3f}" in refusal.message
|
||||
|
||||
|
||||
def test_an_angle_is_checked_against_its_own_limit_not_the_millimetre_one():
|
||||
"""A 400° yaw is past the stage envelope however roomy the table is."""
|
||||
service = make_service()
|
||||
assert service.check_axes_move({"yaw": 400.0}) is not None
|
||||
assert service.check_axes_move({"yaw": 170.0}) is None
|
||||
# And a millimetre target is not checked against the degree limit.
|
||||
assert service.check_axes_move({"x": 200.0}) is not None
|
||||
|
||||
|
||||
def test_a_limit_that_was_never_set_refuses_rather_than_meaning_unlimited():
|
||||
service = make_service(limits={})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "P0" in refusal.message
|
||||
|
||||
|
||||
def test_a_duplicated_drive_is_refused():
|
||||
"""Two roles on one axis means one of them silently does not move."""
|
||||
service = make_service(roles={"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
problem = service.roles_problem()
|
||||
assert problem
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
assert refusal is not None and refusal.message == problem
|
||||
|
||||
|
||||
def test_an_incomplete_mapping_is_refused():
|
||||
service = make_service(roles={"x": 0})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_rotary_role_needs_a_calibrated_axis():
|
||||
"""Axis 0 has no user-units-per-degree constant, so it cannot be a yaw."""
|
||||
service = make_service(roles={"x": 4, "y": 1, "pitch": 2, "yaw": 0, "z": 3})
|
||||
refusal = service.check_axes_move({"yaw": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_non_finite_target_is_refused():
|
||||
service = make_service()
|
||||
for value in (float("nan"), float("inf")):
|
||||
refusal = service.check_axes_move({"x": value})
|
||||
assert refusal is not None
|
||||
|
||||
|
||||
def test_the_guard_names_an_unknown_direction():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"roll": 1.0})
|
||||
assert refusal is not None and "roll" in refusal.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ mover
|
||||
|
||||
|
||||
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
assert mover.start(plan).ok
|
||||
assert len(poller.commands) == 1, "more than the first axis was commanded at once"
|
||||
|
||||
# x is at its target and idle -> the second axis goes out.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2
|
||||
assert mover.state.step.role == "y"
|
||||
|
||||
# y arrives -> the plan is done.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert not mover.running
|
||||
assert mover.state.finished
|
||||
|
||||
|
||||
def test_the_mover_waits_while_an_axis_is_still_moving():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 990.0, 1: 0.0}, idle=False))
|
||||
assert len(poller.commands) == 1
|
||||
assert mover.state.index == 0
|
||||
|
||||
|
||||
def test_the_mover_fails_when_an_axis_stops_short():
|
||||
"""A drive that has stopped is not going to arrive: say so, do not wait."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert mover.running, "gave up before the stall grace elapsed"
|
||||
mover.state._idle_since = time.monotonic() - 10.0 # noqa: SLF001
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert not mover.running
|
||||
assert "stopped short" in mover.state.failure
|
||||
assert len(poller.commands) == 1, "a further axis was commanded after the failure"
|
||||
|
||||
|
||||
def test_the_mover_stops_on_an_alarm():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 0.0}, alarm=True))
|
||||
assert not mover.running
|
||||
assert "alarm" in mover.state.failure
|
||||
|
||||
|
||||
def test_a_tolerance_accepts_an_axis_that_reports_its_final_position_late():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
mover.start(plan)
|
||||
# 1 unit inside SETTLE_MM of 1000 units for 2 mm.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0 - 1.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2, "a step within tolerance was treated as a miss"
|
||||
assert SETTLE_MM > 0 and SETTLE_DEG > 0
|
||||
|
||||
|
||||
def test_stopping_cancels_the_rest_of_the_plan():
|
||||
"""The stop is a separate command, so the mover must stop queueing first."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
assert mover.running
|
||||
mover.cancel("stopped")
|
||||
assert not mover.running
|
||||
assert mover.state.cancelled
|
||||
# Nothing further, even if the axis happens to arrive afterwards.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_a_second_plan_cannot_start_on_top_of_a_running_one():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
second = mover.start(plan_pose_move(service, {"x": 3.0}))
|
||||
assert not second.ok
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_the_mover_refuses_a_plan_it_would_not_be_allowed_to_run():
|
||||
service = MotionService(DemoTransport(), axis_roles=ROLES, limits=LIMITS)
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
result = mover.start(plan_pose_move(service, {"x": 1.0}))
|
||||
assert not result.ok
|
||||
assert not mover.running
|
||||
|
||||
|
||||
def test_the_progress_line_names_the_step_it_is_on():
|
||||
service = make_service()
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
text = mover.progress_text()
|
||||
assert "1/2" in text and "x" in text
|
||||
|
||||
|
||||
def test_the_units_round_trip_through_the_service():
|
||||
"""A target planned in mm must arrive at the drive as the same mm."""
|
||||
service = make_service()
|
||||
for role, value in (("x", 3.5), ("y", -2.25), ("yaw", 45.0), ("pitch", -30.0)):
|
||||
units = service.to_units(role, value)
|
||||
axis = service.role_axis(role)
|
||||
if role in ("x", "y"):
|
||||
assert service.to_mm(axis, units) == pytest.approx(value)
|
||||
else:
|
||||
from orisys_autocal.motion import user_units_to_deg
|
||||
|
||||
assert user_units_to_deg(axis, units) == pytest.approx(value)
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor.export_bundle import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
|
||||
@@ -12,7 +12,6 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sequence_io import (
|
||||
DOCUMENT_KIND,
|
||||
FORMAT_VERSION,
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
|
||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
|
||||
@@ -25,7 +25,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -63,7 +62,10 @@ BRAND_H = 36
|
||||
STRIP_H = 44
|
||||
RUNBAR_H = 40
|
||||
STATUS_H = 26
|
||||
RAIL_LEFT = 330
|
||||
#: Matches ``MAIN_SPLITTER_SIZES`` in the window: the rail narrowed when the two
|
||||
#: start-up panels moved into the device-setup dialog, and the mockup has to show the
|
||||
#: same proportions or it stops being a preview of anything.
|
||||
RAIL_LEFT = 300
|
||||
WORKSPACE_W = PAGE_W - RAIL_LEFT - 3 * MARGIN
|
||||
|
||||
PREVIEW_W, PREVIEW_H = 640, 340
|
||||
@@ -110,6 +112,20 @@ STRINGS = {
|
||||
"nodes": "节点数量",
|
||||
"axes_count": "轴数量",
|
||||
"busdata_group": "总线数据 (SDO / RTEX) ▶",
|
||||
"setup_open": "设备设置",
|
||||
"setup_title": "设备设置",
|
||||
"setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"setup_tabs": ["连接", "总线初始化", "轴映射"],
|
||||
"setup_rows": [
|
||||
["X(工作台)", "驱动器 0"],
|
||||
["Y(工作台)", "驱动器 1"],
|
||||
["Z(压轴)", "驱动器 4"],
|
||||
["yaw(回转)", "驱动器 3"],
|
||||
["pitch(俯仰)", "驱动器 2"],
|
||||
],
|
||||
"setup_connect": "连接",
|
||||
"setup_disconnect": "断开连接",
|
||||
"setup_close": "关闭",
|
||||
"tabs": ["轴调试", "测试序列", "手指调试", "传感器", "表面分布", "标定点"],
|
||||
"readout_title": "轴状态",
|
||||
"enabled": "已使能",
|
||||
@@ -188,6 +204,11 @@ STRINGS = {
|
||||
"finger_preview_title": "三维预览",
|
||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||
"finger_play": "播放动作",
|
||||
"move_execute": "执行动作",
|
||||
"move_stop": "停止",
|
||||
"move_return": "回初始位",
|
||||
"move_confirm": "移动前确认",
|
||||
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
|
||||
"finger_preview_notes": [
|
||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||
@@ -247,6 +268,20 @@ STRINGS = {
|
||||
"nodes": "Nodes",
|
||||
"axes_count": "Axes",
|
||||
"busdata_group": "Bus data (SDO / RTEX) ▶",
|
||||
"setup_open": "Device setup",
|
||||
"setup_title": "Device setup",
|
||||
"setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.",
|
||||
"setup_tabs": ["Connection", "Bus initialisation", "Axis mapping"],
|
||||
"setup_rows": [
|
||||
["X (table)", "Drive 0"],
|
||||
["Y (table)", "Drive 1"],
|
||||
["Z (press)", "Drive 4"],
|
||||
["yaw (rotation)", "Drive 3"],
|
||||
["pitch (tilt)", "Drive 2"],
|
||||
],
|
||||
"setup_connect": "Connect",
|
||||
"setup_disconnect": "Disconnect",
|
||||
"setup_close": "Close",
|
||||
"tabs": ["Axis debug", "Test sequence", "Finger debug", "Sensor", "Surface map", "Calibration points"],
|
||||
"readout_title": "Axis state",
|
||||
"enabled": "Enabled",
|
||||
@@ -325,6 +360,11 @@ STRINGS = {
|
||||
"finger_preview_title": "3D preview",
|
||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||
"finger_play": "Play motion",
|
||||
"move_execute": "Execute",
|
||||
"move_stop": "Stop",
|
||||
"move_return": "Return to zero",
|
||||
"move_confirm": "Confirm before moving",
|
||||
"move_status": "1/4 yaw → +36.8160° (return to zero retracts Z first)",
|
||||
"finger_preview_notes": [
|
||||
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
|
||||
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
|
||||
@@ -543,13 +583,16 @@ def draw_status_strip(painter: QPainter, palette: Palette, s: dict, top: int) ->
|
||||
|
||||
_text(
|
||||
painter,
|
||||
QRect(PAGE_W - 600, top, 380, STRIP_H),
|
||||
QRect(PAGE_W - 600, top, 300, STRIP_H),
|
||||
"上次动作: 已使能轴0 / last action: enable axis 0",
|
||||
palette,
|
||||
size=10,
|
||||
muted=True,
|
||||
align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
# The device-setup button, before the run-screen buttons: connection and bus
|
||||
# initialisation live behind it now instead of in the rail.
|
||||
_button(painter, QRect(PAGE_W - 316, top + 10, 100, 24), s["setup_open"], palette)
|
||||
_button(painter, QRect(PAGE_W - 210, top + 10, 96, 24), s["pause"], palette)
|
||||
_button(painter, QRect(PAGE_W - 106, top + 10, 92, 24), s["stop_all"], palette, danger=True)
|
||||
|
||||
@@ -565,53 +608,13 @@ def draw_status_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> N
|
||||
|
||||
|
||||
def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
|
||||
conn = QRect(rect.x(), rect.y(), rect.width(), 210)
|
||||
inner = _panel(painter, conn, palette)
|
||||
_box_title(painter, conn, s["conn_title"], palette)
|
||||
y = inner.y() + 6
|
||||
_text(painter, QRect(inner.x(), y, 76, 22), s["conn_mode"], palette, size=10, muted=True)
|
||||
_field(painter, QRect(inner.x() + 78, y, inner.width() - 78, 22), s["conn_mode_value"] + " ▾", palette)
|
||||
y += 30
|
||||
_text(painter, QRect(inner.x(), y, 30, 22), s["conn_ip"], palette, size=10, muted=True)
|
||||
_field(painter, QRect(inner.x() + 32, y, 140, 22), "192.168.0.11", palette)
|
||||
_button(painter, QRect(inner.x() + 180, y, 88, 22), s["scan"], palette)
|
||||
y += 32
|
||||
_button(painter, QRect(inner.x(), y, 104, 26), s["connect"], palette, primary=True)
|
||||
_button(painter, QRect(inner.x() + 112, y, 108, 26), s["disconnect"], palette)
|
||||
y += 36
|
||||
_text(painter, QRect(inner.x(), y, inner.width(), 20), s["connected"], palette, size=10, muted=True)
|
||||
y += 24
|
||||
_text(painter, QRect(inner.x(), y, inner.width(), 20), "demo (simulated platform)", palette, size=9, muted=True)
|
||||
"""The rail as it is now: only what is read while working.
|
||||
|
||||
bus = QRect(rect.x(), rect.y() + 222, rect.width(), 250)
|
||||
inner = _panel(painter, bus, palette)
|
||||
_box_title(painter, bus, s["bus_title"], palette)
|
||||
y = inner.y() + 6
|
||||
_text(painter, QRect(inner.x(), y, 60, 22), s["bus_file"], palette, size=10, muted=True)
|
||||
_field(painter, QRect(inner.x() + 62, y, 140, 22), "…\\bas\\ECAT.bas", palette)
|
||||
_button(painter, QRect(inner.x() + 208, y, 66, 22), s["choose"], palette)
|
||||
y += 30
|
||||
_text(painter, QRect(inner.x(), y, 74, 20), s["bus_state"], palette, size=10, muted=True)
|
||||
_text(painter, QRect(inner.x() + 76, y, 180, 20), s["bus_ok"], palette, size=10)
|
||||
y += 24
|
||||
_text(painter, QRect(inner.x(), y, 74, 20), s["bus_type"], palette, size=10, muted=True)
|
||||
_text(painter, QRect(inner.x() + 76, y, 180, 20), s["chip_bus"], palette, size=10)
|
||||
y += 24
|
||||
_text(painter, QRect(inner.x(), y, 90, 20), s["nodes"], palette, size=10, muted=True)
|
||||
_text(painter, QRect(inner.x() + 92, y, 40, 20), "5", palette, size=10)
|
||||
_text(painter, QRect(inner.x() + 146, y, 70, 20), s["axes_count"], palette, size=10, muted=True)
|
||||
_text(painter, QRect(inner.x() + 218, y, 40, 20), "5", palette, size=10)
|
||||
y += 32
|
||||
_button(painter, QRect(inner.x(), y, 146, 26), s["download"], palette, primary=True)
|
||||
_button(painter, QRect(inner.x() + 154, y, 104, 26), s["reinit"], palette)
|
||||
|
||||
group = QRect(rect.x(), rect.y() + 484, rect.width(), 32)
|
||||
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
|
||||
_text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True)
|
||||
|
||||
# The axis readout lives in this rail now, not in a second one on the right:
|
||||
# two thin rails cost the workspace 240 px at every window size.
|
||||
readout = QRect(rect.x(), rect.y() + 526, rect.width(), 268)
|
||||
Connection and bus initialisation are in the device-setup dialog, so the rail
|
||||
starts with the axis readout instead of burying it under ~460 px of start-up
|
||||
controls.
|
||||
"""
|
||||
readout = QRect(rect.x(), rect.y(), rect.width(), 268)
|
||||
inner = _panel(painter, readout, palette)
|
||||
_box_title(painter, readout, s["readout_title"], palette)
|
||||
y = inner.y() + 6
|
||||
@@ -629,7 +632,57 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) ->
|
||||
y += 24
|
||||
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True)
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette)
|
||||
# Returning every drive to zero is a machine-level action: here, in the rail that
|
||||
# is on screen in every tab, rather than repeated inside a tab.
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["move_return"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 112, inner.width(), 28), s["clear_alarms"], palette)
|
||||
|
||||
group = QRect(rect.x(), rect.y() + 280, rect.width(), 32)
|
||||
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
|
||||
_text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True)
|
||||
|
||||
|
||||
def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
|
||||
"""The device-setup dialog: connection on one tab, bus initialisation on the other.
|
||||
|
||||
Drawn as its own panel so the proposal can be reviewed in the same image as the
|
||||
page it came out of.
|
||||
"""
|
||||
dialog = QRect(rect.x(), rect.y(), rect.width(), 400)
|
||||
inner = _panel(painter, dialog, palette)
|
||||
_box_title(painter, dialog, s["setup_title"], palette)
|
||||
_text(
|
||||
painter,
|
||||
QRect(inner.x(), inner.y(), inner.width(), 34),
|
||||
s["setup_hint"],
|
||||
palette,
|
||||
size=9,
|
||||
muted=True,
|
||||
)
|
||||
|
||||
tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74)
|
||||
tab_w = 120
|
||||
for index, label in enumerate(s["setup_tabs"]):
|
||||
tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26)
|
||||
# The axis mapping is the newest page, and the one worth reviewing.
|
||||
active = label == s["setup_tabs"][-1]
|
||||
painter.fillRect(tab, palette.window if active else palette.panel)
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
painter.drawRect(tab.adjusted(0, 0, -1, -1))
|
||||
if active:
|
||||
painter.setPen(QPen(palette.accent, 2))
|
||||
painter.drawLine(tab.bottomLeft(), tab.bottomRight())
|
||||
_text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# The mapping page: one row per platform direction, each showing the drive that
|
||||
# moves it. The values are the bench's own wiring.
|
||||
body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette)
|
||||
y = body.y() + 6
|
||||
for label, value in s["setup_rows"]:
|
||||
_text(painter, QRect(body.x(), y, 150, 22), label, palette, size=10, muted=True)
|
||||
_text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10)
|
||||
y += 26
|
||||
_button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette)
|
||||
|
||||
|
||||
def draw_tabs(painter: QPainter, palette: Palette, s: dict, workspace: QRect, active: str) -> QRect:
|
||||
@@ -1189,9 +1242,25 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
|
||||
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
|
||||
|
||||
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
|
||||
row_y = view.bottom() + checks_h + 2
|
||||
play = QRect(inner_preview.x(), row_y, 110, play_h - 4)
|
||||
_button(painter, play, s["finger_play"], palette, primary=True)
|
||||
notes_y = play.bottom() + 4
|
||||
# The pose move: execute the selected point's solved pose, stop, and the policy
|
||||
# that decides whether either asks first.
|
||||
execute = QRect(play.right() + 8, row_y, 100, play_h - 4)
|
||||
_button(painter, execute, s["move_execute"], palette)
|
||||
stop = QRect(execute.right() + 8, row_y, 84, play_h - 4)
|
||||
_button(painter, stop, s["move_stop"], palette, danger=True)
|
||||
confirm = QRect(stop.right() + 10, row_y, inner_preview.width() - stop.right() - 10, play_h - 4)
|
||||
_text(painter, confirm, "☑ " + s["move_confirm"], palette, size=9, muted=True)
|
||||
_text(
|
||||
painter,
|
||||
QRect(inner_preview.x(), row_y + play_h, inner_preview.width(), 16),
|
||||
s["move_status"],
|
||||
palette,
|
||||
size=9,
|
||||
)
|
||||
notes_y = row_y + play_h + 18
|
||||
for text in s["finger_preview_notes"]:
|
||||
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||
notes_y += 14
|
||||
@@ -1256,6 +1325,22 @@ TAB_PAINTERS = {
|
||||
#: Tab index -> painter key, for the tab strip labels.
|
||||
TAB_KEYS = ("axes", "sequence", "finger", "sensor", "surface", "points")
|
||||
|
||||
#: Pages that are not workspace tabs. The device setup is a dialog, so it is drawn as
|
||||
#: a page of its own: it has to be reviewable like everything else.
|
||||
EXTRA_PAGES = ("setup",)
|
||||
|
||||
|
||||
def draw_setup_page(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
|
||||
"""The device-setup dialog over the page it was opened from."""
|
||||
painter.fillRect(rect, palette.window)
|
||||
draw_page(painter, palette, s, rect, active="axes", with_chrome=True, with_rail=True)
|
||||
draw_setup_dialog(
|
||||
painter,
|
||||
palette,
|
||||
s,
|
||||
QRect(rect.center().x() - 300, rect.center().y() - 200, 600, 400),
|
||||
)
|
||||
|
||||
|
||||
def draw_page(
|
||||
painter: QPainter,
|
||||
@@ -1265,15 +1350,21 @@ def draw_page(
|
||||
*,
|
||||
active: str = "axes",
|
||||
with_chrome: bool = True,
|
||||
with_rail: bool = True,
|
||||
) -> None:
|
||||
"""The whole page: chrome, strip, run bar, rail, workspace, status bar."""
|
||||
"""The whole page: chrome, strip, run bar, rail, workspace, status bar.
|
||||
|
||||
``with_rail`` only exists for the setup page, which draws this page as the
|
||||
background behind the dialog and does not want to draw the rail twice.
|
||||
"""
|
||||
painter.fillRect(rect, palette.window)
|
||||
body_top = rect.y() + MENU_H + BRAND_H + STRIP_H + RUNBAR_H
|
||||
body_h = rect.height() - (MENU_H + BRAND_H + STRIP_H + RUNBAR_H) - STATUS_H
|
||||
|
||||
draw_left_rail(
|
||||
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
|
||||
)
|
||||
if with_rail:
|
||||
draw_left_rail(
|
||||
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
|
||||
)
|
||||
|
||||
workspace = QRect(
|
||||
rect.x() + 2 * MARGIN + RAIL_LEFT,
|
||||
@@ -1294,6 +1385,14 @@ def draw_page(
|
||||
draw_status_bar(painter, palette, s, rect.bottom() - STATUS_H)
|
||||
|
||||
|
||||
def draw_full_page(painter: QPainter, palette: Palette, s: dict, rect: QRect, key: str) -> None:
|
||||
"""One page of the proposal, by key — a workspace tab or the setup dialog."""
|
||||
if key in EXTRA_PAGES:
|
||||
draw_setup_page(painter, palette, s, rect)
|
||||
return
|
||||
draw_page(painter, palette, s, rect, active=key)
|
||||
|
||||
|
||||
def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None:
|
||||
"""The run strip: phase, progress and the three controls."""
|
||||
painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window)
|
||||
@@ -1356,7 +1455,7 @@ def main() -> int:
|
||||
default=TAB_KEYS[0],
|
||||
# Derived, not listed: a literal here silently rejects any tab added to
|
||||
# TAB_KEYS, which is how the finger page first went missing.
|
||||
choices=(*TAB_KEYS, "all"),
|
||||
choices=(*TAB_KEYS, *EXTRA_PAGES, "all"),
|
||||
help="which page to draw; 'all' writes one full-size image per page",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
@@ -1367,7 +1466,7 @@ def main() -> int:
|
||||
base = Path(args.out).resolve()
|
||||
base.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pages = TAB_KEYS if args.page == "all" else (args.page,)
|
||||
pages = (*TAB_KEYS, *EXTRA_PAGES) if args.page == "all" else (args.page,)
|
||||
failures = 0
|
||||
for key in pages:
|
||||
target = base if args.page != "all" else base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
|
||||
@@ -1376,7 +1475,7 @@ def main() -> int:
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
try:
|
||||
draw_page(painter, palette, s, QRect(0, 0, args.width, args.height), active=key)
|
||||
draw_full_page(painter, palette, s, QRect(0, 0, args.width, args.height), key)
|
||||
finally:
|
||||
painter.end()
|
||||
if pixmap.save(str(target)):
|
||||
|
||||
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
checker = Checker()
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -115,7 +113,14 @@ def main() -> int:
|
||||
set_locale("zh_CN")
|
||||
|
||||
# ---- window ----------------------------------------------------------
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
import math
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DemoTransport,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
user_units_to_deg,
|
||||
)
|
||||
|
||||
for theme in ("dark", "light"):
|
||||
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
||||
@@ -132,28 +137,30 @@ def main() -> int:
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
app.processEvents()
|
||||
oversize = []
|
||||
for child in _visible_descendants(window):
|
||||
if _inside_scroll_area(child):
|
||||
continue # a rail's content is meant to exceed its viewport
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if (
|
||||
geometry.width() > parent.width() + 1
|
||||
or geometry.height() > parent.height() + 1
|
||||
):
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > {parent.width()}x{parent.height()}"
|
||||
)
|
||||
checker.that(
|
||||
not oversize, f"{theme} {width}x{height} tab={key}: {oversize}"
|
||||
not _overflowing(window),
|
||||
f"{theme} {width}x{height} tab={key}: {_overflowing(window)}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
print(f" {width}x{height}: every tab fits")
|
||||
|
||||
# The device setup is a separate top-level window now, so the sweep has
|
||||
# to be run over it as well: a dialog whose own controls do not fit is
|
||||
# exactly the defect the sweep exists for, one window further out.
|
||||
window._show_device_setup() # noqa: SLF001 - the documented entry point
|
||||
app.processEvents()
|
||||
for tab in ("connection", "bus"):
|
||||
window.device_setup.select_tab(tab)
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
not _overflowing(window.device_setup),
|
||||
f"{theme} {width}x{height} setup tab={tab}: "
|
||||
f"{_overflowing(window.device_setup)}",
|
||||
)
|
||||
window.device_setup.close()
|
||||
app.processEvents()
|
||||
print(f" {width}x{height}: the device setup fits")
|
||||
|
||||
# ---- the permanent shell ----------------------------------------
|
||||
checker.that(
|
||||
window.workspace.page_keys()
|
||||
@@ -243,6 +250,102 @@ def main() -> int:
|
||||
window.left_rail.widget() is window.rail_content,
|
||||
"the setup rail is not scrollable",
|
||||
)
|
||||
# The rail carries what is read while working. Connection and bus
|
||||
# initialisation moved into the device-setup dialog: back in the rail they
|
||||
# pushed the axis readout below the fold.
|
||||
rail_children = {
|
||||
type(child).__name__ for child in window.rail_content.findChildren(object)
|
||||
}
|
||||
checker.that(
|
||||
"AxisReadoutPanel" in rail_children,
|
||||
f"the axis readout is not in the rail: {sorted(rail_children)}",
|
||||
)
|
||||
checker.that(
|
||||
"BusDataPanel" in rail_children,
|
||||
"the bus-data monitor is not in the rail",
|
||||
)
|
||||
checker.that(
|
||||
"ConnectionPanel" not in rail_children and "BusPanel" not in rail_children,
|
||||
f"a start-up panel is still in the rail: {sorted(rail_children)}",
|
||||
)
|
||||
checker.that(
|
||||
not window.device_setup.isVisible(),
|
||||
"the device setup is open before anyone asked for it",
|
||||
)
|
||||
# Where the three move controls live, and why. Execute is about the selected
|
||||
# point, so it belongs to the finger screen; the stop is repeated next to it
|
||||
# *and* stays in the strip, because an emergency stop has to be where the
|
||||
# operator's eyes are; returning to zero is a machine action wanted after any
|
||||
# tab's work, so it lives in the always-visible rail and is not duplicated.
|
||||
checker.that(
|
||||
window.finger_panel.execute_button.parent() is not None
|
||||
and window.finger_panel.stop_button.parent() is not None,
|
||||
"the finger screen is missing an execute or stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.status_strip.stop_button.isEnabled() is not None,
|
||||
"the strip has no stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.text() == t("move.return_initial"),
|
||||
f"the rail return button says "
|
||||
f"{window.axis_readout.return_button.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.parent() is window.axis_readout,
|
||||
"the return button is not in the always-visible rail",
|
||||
)
|
||||
# The mapping page is the third tab of the device setup, and the service
|
||||
# already holds the mapping it shows.
|
||||
checker.that(
|
||||
window.device_setup.tabs.count() == 3,
|
||||
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
|
||||
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}",
|
||||
)
|
||||
# With no controller, a move must be refused with a reason and the execute
|
||||
# button must say so rather than appearing to work.
|
||||
disconnected = window.poller.service.check_axes_move({"x": 1.0})
|
||||
checker.that(
|
||||
disconnected is not None,
|
||||
"a move was allowed with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
not window.finger_panel.execute_button.isEnabled(),
|
||||
"execute is enabled with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.connection_panel is window.connection_panel
|
||||
and window.device_setup.bus_panel is window.bus_panel,
|
||||
"the window's panel references are not the ones in the dialog",
|
||||
)
|
||||
# Three ways in, all of them the operator's, all landing on one method.
|
||||
window.status_strip.setup_button.click()
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.device_setup.isVisible(),
|
||||
"the setup button did not open the device setup",
|
||||
)
|
||||
window.device_setup.close()
|
||||
window.status_strip.chip_bus.clicked.emit()
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.device_setup.isVisible()
|
||||
and window.device_setup.current_tab() == "bus",
|
||||
"the bus chip did not open the bus tab",
|
||||
)
|
||||
window.device_setup.close()
|
||||
window.brand_bar.connectionChipClicked.emit()
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.device_setup.isVisible()
|
||||
and window.device_setup.current_tab() == "connection",
|
||||
"the brand-bar chip did not open the connection tab",
|
||||
)
|
||||
window.device_setup.close()
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
window.run_bar.isVisible(),
|
||||
"the run bar is not visible",
|
||||
@@ -347,14 +450,14 @@ def main() -> int:
|
||||
|
||||
# A full simulated session: connect, initialise from the shipped BAS.
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
|
||||
_wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0)
|
||||
checker.that(
|
||||
window._last_snapshot.connected, "the simulated controller did not connect"
|
||||
)
|
||||
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
window._on_download_bas()
|
||||
ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
|
||||
ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0)
|
||||
checker.that(ok, "the simulated bus did not report initialised")
|
||||
if ok:
|
||||
checker.that(
|
||||
@@ -399,12 +502,167 @@ def main() -> int:
|
||||
or "5" in strip.chip_axes.text(),
|
||||
f"strip axis chip says {strip.chip_axes.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
|
||||
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
# ---- the pose move ------------------------------------------------
|
||||
# The whole path end to end, on the simulated controller: solve a pose
|
||||
# from a picked point, plan it, run it, and check the drives arrived.
|
||||
# Nothing else exercises the order, the units and the guards together.
|
||||
window.workspace.set_current_key("finger")
|
||||
app.processEvents()
|
||||
finger = window.finger_panel
|
||||
checker.that(
|
||||
finger.execute_button.isEnabled(),
|
||||
"execute is disabled on a connected, initialised platform",
|
||||
)
|
||||
checker.that(
|
||||
finger.stop_button.isEnabled(),
|
||||
"the local stop is disabled while the platform may move",
|
||||
)
|
||||
|
||||
# A point to solve: pick the centre of the flat image, which is the
|
||||
# operator's own gesture, so the pose the check plans is a real one.
|
||||
if not finger.points():
|
||||
view = finger.view
|
||||
window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(finger.points()),
|
||||
"picking the middle of the patch produced no point",
|
||||
)
|
||||
|
||||
solve = finger.solved_pose()
|
||||
checker.that(
|
||||
solve is not None,
|
||||
"the finger screen solved no pose from its point list",
|
||||
)
|
||||
if solve is not None:
|
||||
targets, held = solve
|
||||
checker.that(
|
||||
"z" in held and "z" not in targets,
|
||||
f"execute does not hold the press axis back: "
|
||||
f"{sorted(targets)} held={held}",
|
||||
)
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=held)
|
||||
checker.that(
|
||||
[step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"],
|
||||
f"the move order is {[step.role for step in plan.steps]}",
|
||||
)
|
||||
checker.that(
|
||||
all(math.isfinite(step.units) for step in plan.steps),
|
||||
"a planned step does not convert to user units",
|
||||
)
|
||||
# Straight to the mover: the confirmation box is the operator's, and
|
||||
# a modal dialog in a headless check is a hang (it has been one).
|
||||
result = window.mover.start(plan)
|
||||
checker.that(result.ok, f"the mover refused the plan: {result.message}")
|
||||
moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0)
|
||||
checker.that(moved, "the pose move did not finish")
|
||||
checker.that(
|
||||
not window.mover.state.failure,
|
||||
f"the pose move failed: {window.mover.state.failure}",
|
||||
)
|
||||
for step in plan.steps:
|
||||
state = window._last_snapshot.axis(step.axis)
|
||||
if state is None:
|
||||
continue
|
||||
if step.unit == "mm":
|
||||
actual = window.poller.service.to_mm(step.axis, state.dpos)
|
||||
else:
|
||||
actual = user_units_to_deg(step.axis, state.dpos)
|
||||
checker.that(
|
||||
abs(actual - step.target) <= 0.05,
|
||||
f"{step.role} ended at {actual:.4f}{step.unit}, "
|
||||
f"target {step.target:.4f}{step.unit}",
|
||||
)
|
||||
print(
|
||||
" move: "
|
||||
+ ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps)
|
||||
+ " reached"
|
||||
)
|
||||
|
||||
# Returning to the initial state, press axis first.
|
||||
back = plan_return_to_initial(window.poller.service)
|
||||
checker.that(
|
||||
[step.role for step in back.steps]
|
||||
== ["z", "pitch", "yaw", "x", "y"],
|
||||
f"the return order is {[step.role for step in back.steps]}",
|
||||
)
|
||||
result = window.mover.start(back)
|
||||
checker.that(result.ok, f"the return was refused: {result.message}")
|
||||
returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0)
|
||||
checker.that(
|
||||
returned and not window.mover.state.failure,
|
||||
f"the return did not finish: {window.mover.state.failure}",
|
||||
)
|
||||
zeroed = [
|
||||
window._last_snapshot.axis(step.axis).dpos
|
||||
for step in back.steps
|
||||
if window._last_snapshot.axis(step.axis) is not None
|
||||
]
|
||||
checker.that(
|
||||
all(abs(value) <= 1.0 for value in zeroed),
|
||||
f"the axes did not return to zero: {zeroed}",
|
||||
)
|
||||
print(" move: every axis returned to zero")
|
||||
|
||||
# Stop cancels the queue: no further axis may be commanded.
|
||||
window.mover.start(plan)
|
||||
window._on_stop_all()
|
||||
app.processEvents()
|
||||
checker.that(not window.mover.running, "stop left the move queued")
|
||||
checker.that(
|
||||
window.mover.state.cancelled,
|
||||
"stop did not mark the move cancelled",
|
||||
)
|
||||
print(" move: stop cancels the queue")
|
||||
|
||||
# The guards, on the same connected session: a mapping that claims one
|
||||
# drive twice must refuse motion rather than quietly pick a role.
|
||||
service = window.poller.service
|
||||
saved = service.axis_roles
|
||||
service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a duplicated drive was not refused: {refusal}",
|
||||
)
|
||||
service.set_axis_roles(saved)
|
||||
refusal = service.check_axes_move({"x": 500.0})
|
||||
checker.that(
|
||||
refusal is not None and "500" in refusal.message,
|
||||
f"a target past the travel limit was not refused: {refusal}",
|
||||
)
|
||||
refusal = service.check_axes_move({"yaw": 400.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a yaw past the stage envelope was not refused: {refusal}",
|
||||
)
|
||||
# The mapping page must agree with the service it feeds.
|
||||
page = window.device_setup.roles_page
|
||||
checker.that(
|
||||
page.roles() == saved,
|
||||
f"the mapping page shows {page.roles()}, service holds {saved}",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(0))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(page.problem()), "the mapping page accepted two roles on one drive"
|
||||
)
|
||||
checker.that(
|
||||
service.roles_problem() != "",
|
||||
"the service did not pick up the duplicated mapping",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(1))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
page.problem() == "" and service.roles_problem() == "",
|
||||
"correcting the mapping did not clear the refusal",
|
||||
)
|
||||
print(" move: guards refuse a duplicate mapping and an out-of-range target")
|
||||
window.workspace.set_current_key("axes")
|
||||
app.processEvents()
|
||||
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
@@ -415,6 +673,31 @@ def main() -> int:
|
||||
return checker.report()
|
||||
|
||||
|
||||
def _overflowing(root) -> list[str]:
|
||||
"""Visible descendants that are larger than the widget that contains them.
|
||||
|
||||
Scroll-area content is exempt: a rail's content is meant to exceed its viewport.
|
||||
Everything else that does not fit its parent is a clipped control.
|
||||
"""
|
||||
oversize = []
|
||||
for child in _visible_descendants(root):
|
||||
if _inside_scroll_area(child):
|
||||
continue
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if (
|
||||
geometry.width() > parent.width() + 1
|
||||
or geometry.height() > parent.height() + 1
|
||||
):
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > {parent.width()}x{parent.height()}"
|
||||
)
|
||||
return oversize
|
||||
|
||||
|
||||
def _inside_scroll_area(widget) -> bool:
|
||||
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
|
||||
from PySide6.QtWidgets import QAbstractScrollArea
|
||||
|
||||
@@ -50,18 +50,22 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="drive a simulated session so the shot shows live values",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--setup-dialog",
|
||||
default="",
|
||||
choices=("", "connection", "bus", "axes"),
|
||||
help="photograph the device-setup dialog on this tab instead of the window",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -89,6 +93,25 @@ def main() -> int:
|
||||
window._on_move_abs(0, 1000.0)
|
||||
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0)
|
||||
|
||||
if args.setup_dialog:
|
||||
# The dialog is a separate top-level window, so it is grabbed on its own
|
||||
# rather than inside the page shot.
|
||||
window._show_device_setup(args.setup_dialog) # noqa: SLF001
|
||||
app.processEvents()
|
||||
output = Path(args.out).resolve()
|
||||
target = window.device_setup
|
||||
if not target.grab().save(str(output)):
|
||||
print(f"could not write {output}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
f"wrote {output} ({target.width()}x{target.height()}, theme={args.theme}, "
|
||||
f"tab={args.setup_dialog})"
|
||||
)
|
||||
target.close()
|
||||
window.close()
|
||||
app.processEvents()
|
||||
return 0
|
||||
|
||||
app.processEvents()
|
||||
|
||||
if args.compare:
|
||||
|
||||
@@ -96,14 +96,12 @@ def main() -> int:
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
@@ -223,7 +221,6 @@ def main() -> int:
|
||||
# pixels, because a projection that disagrees with the render looks
|
||||
# perfectly healthy on its own — that is how the labels drifted off
|
||||
# their arrows and the scene sat off to one side without a word.
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
for label, point, colour in _landmarks(panel):
|
||||
screen = panel.preview.project(point)
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"stop": "Cancel acquisition",
|
||||
"preview": "Preview / Stop preview",
|
||||
"reset": "Reset tracker",
|
||||
"reset_view": "Reset views",
|
||||
"reset_view_tip": "Put the chart window, cursor and 3D camera back to their defaults (F8). Touches neither the device nor the zero estimate.",
|
||||
"pause": "Pause / resume (F7)",
|
||||
"help": "Help",
|
||||
"about": "About"
|
||||
},
|
||||
@@ -53,14 +56,22 @@
|
||||
"plot_settings_title": "Plot settings",
|
||||
"plot_window": "Time window",
|
||||
"plot_window_tip": "Force plot time window (seconds)",
|
||||
"plot_fn_min": "FN min",
|
||||
"plot_fn_min_tip": "Normal-force axis minimum (Newton mode). It may be negative: leaving room below zero keeps the idle curve off the FX frame and makes small presses visible",
|
||||
"plot_fn_max": "FN max",
|
||||
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
|
||||
"plot_shear_max": "Shear max",
|
||||
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
|
||||
"plot_fn_raw_min": "FN raw min",
|
||||
"plot_fn_raw_min_tip": "Normal-force axis minimum in raw (uncalibrated) mode; may be negative",
|
||||
"plot_fn_raw_max": "FN raw max",
|
||||
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
|
||||
"plot_shear_raw_max": "Shear raw max",
|
||||
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
|
||||
"plot_deadband_n": "Zero threshold (N)",
|
||||
"plot_deadband_n_tip": "Display deadband floor in Newton mode: a reading inside the threshold shows as 0 and the curve flattens on the zero line. 0 = auto (the noise estimated from the quiet startup window; Newton mode has no fixed floor). Display only — the record and forces.csv keep the measured values",
|
||||
"plot_deadband_raw": "Zero threshold (raw)",
|
||||
"plot_deadband_raw_tip": "Display deadband floor in raw mode (raw counts). 0 = auto (the built-in 50-count floor). Raise it by hand when the calibration data is suspect; display only",
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
|
||||
"filter_preset": "Preset",
|
||||
@@ -115,6 +126,13 @@
|
||||
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
|
||||
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
|
||||
"reset": "Reset",
|
||||
"reset_tip": "Zero the device tracker. This affects the zero point only, never the view — to put the views back use View → Reset views.",
|
||||
"pause": "⏸ Pause",
|
||||
"pause_resume": "▶",
|
||||
"pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.",
|
||||
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
|
||||
"resume_label": "▶ Resume",
|
||||
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
|
||||
"stop_playback": "Stop",
|
||||
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
|
||||
"stopped": "Stopped: {what}",
|
||||
@@ -400,6 +418,9 @@
|
||||
"camera_disconnected": "Camera disconnected",
|
||||
"reset_ok": "Tracker reset",
|
||||
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
|
||||
"view_reset": "Views reset",
|
||||
"paused": "Paused: nothing is computed, the device stays connected",
|
||||
"resumed": "Resumed",
|
||||
"finger_run_unavailable": "Run is not available for this sensor type",
|
||||
"video_required": "Select a video file first",
|
||||
"config_missing": "Sensor config not found for {type}",
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"stop": "取消采集",
|
||||
"preview": "预览 / 停止预览",
|
||||
"reset": "重置追踪器",
|
||||
"reset_view": "复位视图",
|
||||
"reset_view_tip": "把曲线窗口、游标与三维视角恢复为默认(F8)。不改动设备,也不清除零点估计。",
|
||||
"pause": "暂停 / 继续(F7)",
|
||||
"help": "帮助",
|
||||
"about": "关于"
|
||||
},
|
||||
@@ -53,14 +56,22 @@
|
||||
"plot_settings_title": "绘图设置",
|
||||
"plot_window": "时间窗口",
|
||||
"plot_window_tip": "力曲线时间窗口(秒)",
|
||||
"plot_fn_min": "FN 下限",
|
||||
"plot_fn_min_tip": "法向力纵轴下限(牛顿模式)。可以是负数:留出零点以下的空间,静止曲线不会压在 FX 框线上,小压力也更清楚",
|
||||
"plot_fn_max": "FN 上限",
|
||||
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
|
||||
"plot_shear_max": "剪切上限",
|
||||
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
|
||||
"plot_fn_raw_min": "FN 原始下限",
|
||||
"plot_fn_raw_min_tip": "未标定(原始)模式下的法向力纵轴下限,可为负",
|
||||
"plot_fn_raw_max": "FN 原始上限",
|
||||
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
|
||||
"plot_shear_raw_max": "原始剪切上限",
|
||||
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
|
||||
"plot_deadband_n": "归零阈值(N)",
|
||||
"plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍是实测值",
|
||||
"plot_deadband_raw": "归零阈值(原始)",
|
||||
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
|
||||
"reset_defaults": "恢复默认",
|
||||
"plot_filters": "滤波(作用于采集,界面与记录同步)",
|
||||
"filter_preset": "预设",
|
||||
@@ -107,7 +118,7 @@
|
||||
"run_unavailable": "当前不可运行",
|
||||
"run_disabled_video": "请先选择一个视频文件",
|
||||
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
||||
"playback": "▶ 回放",
|
||||
"playback": "▶ 播放",
|
||||
"playback_pause": "⏸ 暂停",
|
||||
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||
@@ -115,6 +126,13 @@
|
||||
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
||||
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
|
||||
"reset": "重置",
|
||||
"reset_tip": "把设备追踪器归零(清零)。只影响零点,不动界面;想恢复视图请用「视图 → 复位视图」。",
|
||||
"pause": "⏸ 暂停",
|
||||
"pause_resume": "▶",
|
||||
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
|
||||
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
||||
"resume_label": "▶ 继续",
|
||||
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
||||
"stop_playback": "停止",
|
||||
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
||||
"stopped": "已停止:{what}",
|
||||
@@ -400,6 +418,9 @@
|
||||
"camera_disconnected": "摄像机已断开",
|
||||
"reset_ok": "追踪器已重置",
|
||||
"reset_pending": "已请求归零,等待采集线程确认…",
|
||||
"view_reset": "视图已复位",
|
||||
"paused": "已暂停:不再计算,设备保持连接",
|
||||
"resumed": "已继续",
|
||||
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
||||
"video_required": "请先选择视频文件",
|
||||
"config_missing": "未找到传感器配置: {type}",
|
||||
|
||||
@@ -15,17 +15,21 @@
|
||||
"scene3d_display": {
|
||||
"deform_enabled": false,
|
||||
"display_gain": 0.0,
|
||||
"housing_visible": true,
|
||||
"housing_visible": false,
|
||||
"max_fps": 30
|
||||
},
|
||||
"sensor_type": "box",
|
||||
"config_name": "",
|
||||
"units": "newton",
|
||||
"plot_window_sec": 4.0,
|
||||
"plot_fn_min": 0.0,
|
||||
"plot_fn_max": 20.0,
|
||||
"plot_shear_max": 20.0,
|
||||
"plot_fn_raw_min": 0.0,
|
||||
"plot_fn_raw_max": 1000000.0,
|
||||
"plot_shear_raw_max": 500000.0,
|
||||
"plot_deadband_n": 0.0,
|
||||
"plot_deadband_raw": 0.0,
|
||||
"image_view_mode": "arrows_pure",
|
||||
"arrow_rotation_deg": 90,
|
||||
"settings_expanded": false,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "mems-prototype",
|
||||
"version": "1.0",
|
||||
"camera": {
|
||||
"resolution_raw": [1920, 1080],
|
||||
"fps": 30,
|
||||
"units": 15,
|
||||
"units_used": 9,
|
||||
"resolution_stitch": [365, 365],
|
||||
"stitch_res_list": [365, 263, 123, 63]
|
||||
},
|
||||
"stitcher": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.05,
|
||||
"max_aspect_ratio": 50,
|
||||
"index": [1, 2, 3, 6, 7, 8, 11, 12, 13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3, 3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [250, 250],
|
||||
"blend": "numba",
|
||||
"blend_list": ["numpy", "numba", "none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
"lower_threshold": 12.0,
|
||||
"upper_threshold": 18.0,
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold": 5,
|
||||
"div_threshold": 0.05,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5, 5],
|
||||
"denoise_sigma": 12
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@
|
||||
"resolution_raw": [1280, 720],
|
||||
"fps": 30,
|
||||
"resolution_stitch": [280, 300],
|
||||
"source_scale": 0.4,
|
||||
"source_scale": 0.42,
|
||||
"old":[672, 728, 1,0.5],
|
||||
"work": [504, 546,1],
|
||||
"work2": [420, 450,0.8],
|
||||
|
||||
@@ -218,8 +218,10 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
|
||||
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
|
||||
|
||||
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
|
||||
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
|
||||
评审提出的十条复现条件已全部翻转。**原评审脚本
|
||||
`D:/orisys_ws/_review_phase12/reproduce.py` 与其 `reproduce_after.log` 属于一次性工作区临时物,
|
||||
已在 2026-09-27 的工作区整理中删除**;十条条件与整改要求逐条记录在归档的评审正文
|
||||
`D:\orisys_ws\docs\archive\reviews\phase12-review.md`("发现与整改条件"一节)。以下按发现逐条说明改法。
|
||||
|
||||
### 7.1 记录语义(P1-1)
|
||||
|
||||
|
||||
@@ -9,27 +9,13 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DEPTH,
|
||||
SOURCE_LIVE,
|
||||
SOURCE_PREVIEW,
|
||||
SOURCE_RECOMPUTE,
|
||||
DomainMaskLatch,
|
||||
ImageFrame,
|
||||
plan_image,
|
||||
render_image,
|
||||
@@ -38,24 +24,33 @@ from orisys_app_shared.force import select_display_forces
|
||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||
from orisys_app_shared.force.filters import FilterSettings
|
||||
from orisys_app_shared.force.session import SessionStore
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
from orisys_app_shared.record.sample import to_record_sample
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_studio import session_metadata
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||
from orisys_studio.replay import ReplayEngine
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
from orisys_studio.view_layout import ViewLayoutController
|
||||
from orisys_studio import session_metadata
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
||||
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.ui import record_status as record_states
|
||||
from orisys_studio.ui.about_dialog import AboutDialog
|
||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
||||
@@ -73,11 +68,16 @@ from orisys_studio.ui.plot_settings_dialog import (
|
||||
)
|
||||
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
||||
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||
from orisys_studio.view_layout import ViewLayoutController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
||||
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
|
||||
#: SDK) that reuses the box/mini pipeline, so it is runnable exactly like box.
|
||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
|
||||
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
|
||||
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
|
||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
|
||||
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
|
||||
MIN_PANEL_WIDTHS = {
|
||||
# The 3D pane used to be the one panel whose content was a stack of form fields, so
|
||||
@@ -204,6 +204,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self.session.tick.connect(self._on_run_tick)
|
||||
# The phase is the session's, not the window's; the chip is one view of it.
|
||||
self.session.phaseChanged.connect(self._on_session_phase_changed)
|
||||
#: True while the operator has paused the live session. The acquisition thread
|
||||
#: stops computing; this flag stops the *display* half of the run tick, so a
|
||||
#: frame that was already in flight cannot repaint the panels they paused on.
|
||||
self._display_paused = False
|
||||
self._active_calibration_id = ""
|
||||
#: Last display-unit decision (``None`` before the first sample): the
|
||||
#: status rail's calibration readout is derived from it, because an axis
|
||||
@@ -214,6 +218,11 @@ class StudioMainWindow(QMainWindow):
|
||||
self._last_calibration_export = ""
|
||||
self._depth_range_value: tuple[float, float] | None = None
|
||||
self._last_depth_frame = None
|
||||
#: The 2D domain boundary, latched for the session the way the depth range is:
|
||||
#: the island is the device's own geometry and is rebuilt from the same remap
|
||||
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
|
||||
#: moved (see ``image_backend.DomainMaskLatch``).
|
||||
self._domain_mask = DomainMaskLatch()
|
||||
|
||||
icon = QIcon(str(logo_path()))
|
||||
if not icon.isNull():
|
||||
@@ -240,6 +249,11 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||
# The island latch is *upstream of both views*: the 2D canvas paints the
|
||||
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
||||
# views, one decision about where the measurement is — see
|
||||
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
||||
self.mesh3d_panel.set_domain_latch(self._domain_mask)
|
||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
# The charts browse the whole session, not just their rolling buffer.
|
||||
@@ -738,6 +752,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
|
||||
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
|
||||
self.toolbar.run_toggled.connect(self._on_run_toggled)
|
||||
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
|
||||
self.toolbar.record_toggled.connect(self._on_record_toggled)
|
||||
self.toolbar.reset_clicked.connect(self._on_reset)
|
||||
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
|
||||
@@ -974,6 +989,22 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_reset.setShortcut("Ctrl+R")
|
||||
self.act_reset.triggered.connect(self._on_reset)
|
||||
self.view_menu.addAction(self.act_reset)
|
||||
|
||||
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
|
||||
# Separate because wanting a fresh device baseline is not the same intention as
|
||||
# wanting the views back, and only the first one belongs to the device.
|
||||
self.act_reset_view = QAction(self)
|
||||
self.act_reset_view.setShortcut("F8")
|
||||
self.act_reset_view.triggered.connect(self._on_reset_view)
|
||||
self.view_menu.addAction(self.act_reset_view)
|
||||
|
||||
# Pause has a shortcut as well as a rail button: an operator who is holding a
|
||||
# sample still wants to stop the numbers moving without reaching for the mouse.
|
||||
self.act_pause = QAction(self)
|
||||
self.act_pause.setShortcut("F7")
|
||||
self.act_pause.setCheckable(True)
|
||||
self.act_pause.toggled.connect(self._on_pause_action_toggled)
|
||||
self.view_menu.addAction(self.act_pause)
|
||||
self.view_menu.addSeparator()
|
||||
|
||||
self.theme_menu = self.view_menu.addMenu("")
|
||||
@@ -1111,6 +1142,12 @@ class StudioMainWindow(QMainWindow):
|
||||
return self.layout_ctl.view_preset
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
"""What the rail's transport, stop and reset can do in the current view.
|
||||
|
||||
One place decides it for both views: the rail's buttons mean the same
|
||||
things there (the transport is a play/pause toggle, stop ends what is
|
||||
running), so a second copy of the rule is a second thing to keep in step.
|
||||
"""
|
||||
if self.toolbar.run_role() == "playback":
|
||||
# In the replay view the button is a transport: it needs something to
|
||||
# play — a recording, or a bare video — not a runnable sensor. While it
|
||||
@@ -1131,14 +1168,16 @@ class StudioMainWindow(QMainWindow):
|
||||
reason = t("toolbar.playback_disabled")
|
||||
self.toolbar.set_run_tooltip_reason(reason)
|
||||
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
|
||||
# The stop button is offered whenever there is something to stop; the
|
||||
# operator must never have to guess whether that is the case.
|
||||
if self.toolbar.reset_role() == "stop":
|
||||
self.toolbar.set_reset_enabled(
|
||||
self.replay_ctl.is_playing()
|
||||
or self._recompute_running
|
||||
or self._session_active
|
||||
)
|
||||
# Stop leaves the replay, whatever it is doing: plain playback, a
|
||||
# recompute pass, or a session the pass started. The operator must never
|
||||
# have to work out which of those is stuck before they can stop it.
|
||||
self.toolbar.set_stop_enabled(
|
||||
self.replay_ctl.is_playing()
|
||||
or self._recompute_running
|
||||
or self._session_active
|
||||
)
|
||||
# The tracker reset belongs to a device session; this view has none.
|
||||
self.toolbar.set_reset_enabled(False)
|
||||
self.toolbar.set_record_enabled(
|
||||
self._running or self.recorder.is_recording
|
||||
)
|
||||
@@ -1155,10 +1194,16 @@ class StudioMainWindow(QMainWindow):
|
||||
source_ok = False
|
||||
reason = t("toolbar.run_disabled_video")
|
||||
self.toolbar.set_run_tooltip_reason(reason)
|
||||
# While a session exists the transport stays enabled — pausing and resuming
|
||||
# are the whole point of it being checked, not an exception to an idle rule.
|
||||
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
|
||||
# Recording needs a live session; while recording the button stays
|
||||
# enabled so it can always be stopped.
|
||||
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
|
||||
# Stop releases the device and ends the session. It covers a device open
|
||||
# that hangs as well, so it is offered for as long as a session exists at
|
||||
# all — including the moment the worker is still winding down.
|
||||
self.toolbar.set_stop_enabled(self._session_active)
|
||||
if not self._running:
|
||||
self.toolbar.refresh_labels()
|
||||
self._sync_shortcut_actions()
|
||||
@@ -1189,6 +1234,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_stop.setText(t("menu.stop"))
|
||||
self.act_preview.setText(t("menu.preview"))
|
||||
self.act_reset.setText(t("menu.reset"))
|
||||
self.act_reset_view.setText(t("menu.reset_view"))
|
||||
self.act_reset_view.setToolTip(t("menu.reset_view_tip"))
|
||||
self.act_pause.setText(t("menu.pause"))
|
||||
self._sync_pause_action()
|
||||
self.theme_menu.setTitle(t("menu.theme"))
|
||||
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||
self.act_theme_light.setText(t("menu.theme_light"))
|
||||
@@ -1494,10 +1543,17 @@ class StudioMainWindow(QMainWindow):
|
||||
dialog = PlotSettingsDialog(
|
||||
self,
|
||||
window_sec=settings["window_sec"],
|
||||
# FN's lower bound may be negative: that is the "show me below zero" the
|
||||
# operator asked for, and it is what puts the 0 tick back on the axis.
|
||||
fn_min=settings["fn_min"],
|
||||
fn_max=settings["fn_max"],
|
||||
shear_max=settings["shear_max"],
|
||||
fn_raw_min=settings["fn_raw_min"],
|
||||
fn_raw_max=settings["fn_raw_max"],
|
||||
shear_raw_max=settings["shear_raw_max"],
|
||||
# Display-only zeroing floors; 0 means "use the estimated band".
|
||||
deadband_n=settings["deadband_n"],
|
||||
deadband_raw=settings["deadband_raw"],
|
||||
units=settings["units"],
|
||||
filters=self._filter_settings(),
|
||||
# Asked *now*: the answer is a property of this moment, and the
|
||||
@@ -1507,16 +1563,9 @@ class StudioMainWindow(QMainWindow):
|
||||
)
|
||||
if dialog.exec() != PlotSettingsDialog.Accepted:
|
||||
return
|
||||
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
||||
self.display_ctl.apply_plot_settings(
|
||||
{
|
||||
"window_sec": window_sec,
|
||||
"fn_max": fn_max,
|
||||
"shear_max": shear_max,
|
||||
"fn_raw_max": fn_raw_max,
|
||||
"shear_raw_max": shear_raw_max,
|
||||
}
|
||||
)
|
||||
# One call for the whole picture: the dialog owns the schema, the controller
|
||||
# writes what it was given and re-reads the rest.
|
||||
self.display_ctl.apply_plot_settings(dialog.settings())
|
||||
filters = dialog.filter_values()
|
||||
# The same keys ``_filter_settings()`` reads back, so what the dialog
|
||||
# shows next time is what the session will actually be given.
|
||||
@@ -1718,20 +1767,43 @@ class StudioMainWindow(QMainWindow):
|
||||
if getattr(self, "act_run", None) is None:
|
||||
return
|
||||
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
|
||||
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
|
||||
# The menu's Stop mirrors the rail's Stop button, not the acquisition
|
||||
# session alone: a replay or a recompute pass is something to stop.
|
||||
#
|
||||
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
|
||||
# did not) and never delivers the shortcut of a disabled action, so a Stop that
|
||||
# is disabled while a replay is playing is a keyboard escape hatch that does not
|
||||
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
|
||||
self.act_stop.setEnabled(
|
||||
self._session_active
|
||||
or self.replay_ctl.is_playing()
|
||||
or self._recompute_running
|
||||
)
|
||||
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
|
||||
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
|
||||
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
|
||||
# A live pause is only meaningful while the pipeline is running; in the replay
|
||||
# view its own transport owns play/pause.
|
||||
self.act_pause.setEnabled(
|
||||
self.toolbar.run_role() != "playback" and self._running
|
||||
)
|
||||
self.act_pause.setToolTip(
|
||||
t("toolbar.resume_tip") if self.act_pause.isChecked() else t("toolbar.pause_tip")
|
||||
)
|
||||
|
||||
def _sync_pause_action(self) -> None:
|
||||
"""The View-menu action follows the session; it never leads it.
|
||||
|
||||
The rail's transport owns the pause state (checked = acquiring and not
|
||||
paused, unchecked with a session = paused), so the action is a mirror and a
|
||||
shortcut into it — two controls that cannot disagree.
|
||||
"""
|
||||
action = getattr(self, "act_pause", None)
|
||||
if action is None:
|
||||
return
|
||||
paused = bool(self._running and self._display_paused)
|
||||
if action.isChecked() != paused:
|
||||
blocked = action.blockSignals(True)
|
||||
action.setChecked(paused)
|
||||
action.blockSignals(blocked)
|
||||
action.setToolTip(
|
||||
t("toolbar.resume_tip") if paused else t("toolbar.pause_tip")
|
||||
)
|
||||
|
||||
def _on_play_shortcut(self) -> None:
|
||||
"""Space plays or pauses the recording, if there is one to move through."""
|
||||
@@ -1753,6 +1825,14 @@ class StudioMainWindow(QMainWindow):
|
||||
``_sync_shortcut_actions`` enables the action for, so the action can never be
|
||||
enabled with nothing behind it.
|
||||
"""
|
||||
self._stop_activity()
|
||||
|
||||
def _stop_activity(self) -> None:
|
||||
"""End whatever this view is doing. The rail's Stop and Esc share this body.
|
||||
|
||||
One implementation, because the button and the shortcut are the same act:
|
||||
two copies would be two answers to "what does Stop stop".
|
||||
"""
|
||||
if self.toolbar.run_role() == "playback":
|
||||
if (
|
||||
self.replay_ctl.is_playing()
|
||||
@@ -1765,6 +1845,10 @@ class StudioMainWindow(QMainWindow):
|
||||
return
|
||||
self._stop_run(reason="user_stop")
|
||||
|
||||
def _on_stop_clicked(self) -> None:
|
||||
"""The rail's Stop button — the same escape hatch Esc reaches."""
|
||||
self._stop_activity()
|
||||
|
||||
def _on_preview_shortcut(self) -> None:
|
||||
if not self.toolbar.btn_preview.isEnabled():
|
||||
return
|
||||
@@ -1876,14 +1960,28 @@ class StudioMainWindow(QMainWindow):
|
||||
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
"""The rail's transport. One button, three states, routed by the session.
|
||||
|
||||
``checked`` means "acquiring and not paused" in the live view and "playing"
|
||||
in the replay view. Unchecked is ambiguous on its own — it is *idle* when no
|
||||
session exists and *paused* when one does — so whether a session is already
|
||||
running is what decides between starting and resuming, and between pausing
|
||||
and doing nothing. Stopping is never this button's job: it has its own.
|
||||
"""
|
||||
if self.toolbar.run_role() == "playback":
|
||||
# The primary button of the replay view is its transport.
|
||||
self.replay_ctl.on_transport_toggled(bool(checked))
|
||||
return
|
||||
if checked:
|
||||
self._start_run()
|
||||
else:
|
||||
self._stop_run(reason="user_stop")
|
||||
if self._running:
|
||||
# A live session already exists and the button was unchecked: that
|
||||
# is the paused state, so checking it again means resume.
|
||||
self._on_pause_toggled(False)
|
||||
else:
|
||||
self._start_run()
|
||||
return
|
||||
if self._running:
|
||||
self._on_pause_toggled(True)
|
||||
|
||||
def _start_run(self) -> None:
|
||||
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
|
||||
@@ -1919,11 +2017,11 @@ class StudioMainWindow(QMainWindow):
|
||||
self.force_plots.configure_presentation(
|
||||
sensor_type=sensor_type,
|
||||
unit=units,
|
||||
enabled=(
|
||||
sensor_type == "finger"
|
||||
and units == "raw"
|
||||
and bool(self.prefs.get("raw_auto_zero", True))
|
||||
),
|
||||
# The operator's own switch, and only that: the aid covers every sensor type and
|
||||
# both units, which is the point of it (an offset reads as an offset in newtons
|
||||
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
|
||||
# it for every other product.
|
||||
enabled=bool(self.prefs.get("raw_auto_zero", True)),
|
||||
)
|
||||
self._last_plot_packet_key = None
|
||||
self._last_rendered_view_mode = None
|
||||
@@ -1936,6 +2034,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self._last_display_available = None
|
||||
self._depth_range_value = None
|
||||
self._last_depth_frame = None
|
||||
# The new session has not established a domain boundary yet; the previous
|
||||
# session's island must not survive into it (a different sensor is a different
|
||||
# island, and the latch compares against what it holds).
|
||||
self._domain_mask.reset()
|
||||
# The session's own bookkeeping (its counter and telemetry) is the
|
||||
# controller's: telling it a run starts is what drops the previous run's
|
||||
# numbers, so none of them can be read as this run's.
|
||||
@@ -1975,6 +2077,12 @@ class StudioMainWindow(QMainWindow):
|
||||
# The session is now connecting: it owns the phase, the connect clock and the
|
||||
# run timer's cadence (the timer *is* the session, so it lives with it).
|
||||
self.session.mark_running()
|
||||
# A session exists and is not paused, which is exactly what the transport's
|
||||
# checked state means: stated here as well as by the click, because starting
|
||||
# is not only ever reached through the button (a recompute pass starts the
|
||||
# same session), and an unchecked transport beside a live session would read
|
||||
# as "paused".
|
||||
self.toolbar.set_run_checked(True)
|
||||
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||
self.cal_panel.set_running(True)
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
@@ -2013,6 +2121,12 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.refresh_labels()
|
||||
self.cal_panel.set_running(False)
|
||||
self.toolbar.set_run_checked(False)
|
||||
# The session is over, so the transport must not stay latched in "paused" —
|
||||
# the next run starts computing. The worker clears its own flag on start as
|
||||
# well, so both sides agree even if this path is skipped (a stop from the
|
||||
# acquisition thread).
|
||||
self._display_paused = False
|
||||
self._sync_pause_action()
|
||||
self.toolbar.set_reset_enabled(False)
|
||||
self.toolbar.set_source_controls_enabled(True)
|
||||
self.sensor_picker.set_picker_enabled(True)
|
||||
@@ -2064,29 +2178,111 @@ class StudioMainWindow(QMainWindow):
|
||||
self._notify(t("toolbar.nothing_to_stop"))
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
if self.toolbar.reset_role() == "stop":
|
||||
# In the replay view that button is the operator's way out of a replay or
|
||||
# a recompute pass, whatever state the transport is in.
|
||||
self.replay_ctl.stop_activity()
|
||||
return
|
||||
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
|
||||
|
||||
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
|
||||
also threw away the history, the window and the cursor on screen — two different
|
||||
operator intentions on one button (and only one of them recorded, see
|
||||
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
|
||||
a new device zero means the old idle estimate is stale, not that the operator
|
||||
wants to lose what they were looking at.
|
||||
|
||||
It is only ever this: ending a replay or a recompute pass is the rail's Stop
|
||||
button (the same act as Esc), so this button is disabled wherever there is no
|
||||
device session to zero.
|
||||
"""
|
||||
if self._session_phase != "running" or self._active_worker is None:
|
||||
return
|
||||
try:
|
||||
# A request, not the zeroing itself: the acquisition thread applies it
|
||||
# between two frames and reports the frame and time it took effect.
|
||||
self._active_worker.reset()
|
||||
self.force_plots.reset()
|
||||
self.force_plots.request_presentation_reestimate()
|
||||
self._last_plot_packet_key = None
|
||||
self.statusBar().showMessage(t("status.reset_pending"))
|
||||
except Exception as exc:
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
|
||||
def _on_pause_toggled(self, paused: bool) -> None:
|
||||
"""Pause the pipeline and the display; the device stays open.
|
||||
|
||||
Not a display freeze: the acquisition thread stops *computing* as well, so nothing
|
||||
is spent on flow, depth or contact while an operator reads a result. What it
|
||||
deliberately does not do is release the device — that is 停止 — so resuming is
|
||||
immediate and the camera is not re-enumerated.
|
||||
|
||||
Consequence, stated rather than hidden: with no packets there is nothing to write,
|
||||
so a recording that spans a pause has a **gap**. The store's own time base shows it
|
||||
(sample times jump), and 停止 still ends the session normally.
|
||||
|
||||
Reached from the rail's transport (which owns the state) and from the View-menu
|
||||
action / F7 (which mirrors it); never from two controls of its own.
|
||||
"""
|
||||
paused = bool(paused)
|
||||
self._display_paused = paused
|
||||
self._sync_pause_action()
|
||||
worker = self._active_worker
|
||||
if worker is not None:
|
||||
try:
|
||||
worker.pause() if paused else worker.resume()
|
||||
except Exception as exc: # noqa: BLE001 - report, never raise from a click
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
return
|
||||
if paused:
|
||||
self.statusBar().showMessage(t("status.paused"))
|
||||
else:
|
||||
# The next packet is a new sample again: without this the first one after a
|
||||
# resume can be swallowed as a repeat of the last key the tick saw.
|
||||
self._last_plot_packet_key = None
|
||||
self.statusBar().showMessage(t("status.resumed"))
|
||||
|
||||
def _on_pause_action_toggled(self, paused: bool) -> None:
|
||||
"""The menu/shortcut half of pause: drive the rail's transport, which owns it.
|
||||
|
||||
The action is checked exactly while the session is paused, so pressing it
|
||||
toggles the transport button — and that click is what routes to pause or
|
||||
resume. Keeping one control authoritative means the button and the shortcut
|
||||
can never disagree about whether the session is paused.
|
||||
"""
|
||||
paused = bool(paused)
|
||||
if self.toolbar.run_role() == "playback" or not self._running:
|
||||
# Nothing here to pause: put the action back rather than letting it
|
||||
# advertise a state the session is not in.
|
||||
self._sync_pause_action()
|
||||
return
|
||||
wanted = not paused
|
||||
if self.toolbar.btn_run.isChecked() != wanted:
|
||||
# ``setChecked`` emits, so the click takes the window's own transport
|
||||
# path (start/resume/pause) instead of a second copy of that decision.
|
||||
self.toolbar.btn_run.setChecked(wanted)
|
||||
|
||||
def _on_reset_view(self) -> None:
|
||||
"""Put the views back to their defaults: curves, window, cursor and 3D camera.
|
||||
|
||||
Pressures the same state ``force_plots.reset()`` always cleared, now on its own
|
||||
action, plus the 3D pane's camera. It touches nothing the device owns, so it is
|
||||
always available — including while a replay is on screen.
|
||||
"""
|
||||
self.force_plots.reset()
|
||||
panel = getattr(self, "mesh3d_panel", None)
|
||||
if panel is not None:
|
||||
try:
|
||||
panel.reset_view()
|
||||
except Exception: # noqa: BLE001 - a pane with no scene has no view to reset
|
||||
logger.debug("3D view reset skipped", exc_info=True)
|
||||
self.statusBar().showMessage(t("status.view_reset"))
|
||||
|
||||
def _on_run_tick(self) -> None:
|
||||
# The 3D pane advances on this timer rather than one of its own: the window's
|
||||
# refresh rate is then the only one that exists, and hiding or disabling the
|
||||
# pane cannot leave a timer behind. It starts at most one compose on the
|
||||
# pane's own worker and takes at most one finished frame.
|
||||
self.mesh3d_panel.tick()
|
||||
if not self._display_paused:
|
||||
# While paused the pane holds the frame it had. The acquisition thread has
|
||||
# already stopped computing, so this only matters for a frame that was in
|
||||
# flight when the operator pressed pause — which must not be the one frame
|
||||
# they cannot see.
|
||||
self.mesh3d_panel.tick()
|
||||
if self._active_worker is None:
|
||||
return
|
||||
# Record bookkeeping happens before anything that can return early: a
|
||||
@@ -2141,7 +2337,7 @@ class StudioMainWindow(QMainWindow):
|
||||
# source slot names the camera, so "运行中(0)" was a second copy of
|
||||
# the same fact that then stayed on screen for the rest of the session.
|
||||
|
||||
if is_new:
|
||||
if is_new and not self._display_paused:
|
||||
self._last_plot_packet_key = packet_key
|
||||
# The live view consumes the *same* standardised measurement as the
|
||||
# CSV and the session store: one conversion decides what a value
|
||||
@@ -2227,8 +2423,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(dropped_message)
|
||||
|
||||
mode = self.arrow_panel.view_mode()
|
||||
if (is_new or mode != self._last_rendered_view_mode) and not (
|
||||
self._replay_owns_image()
|
||||
if not self._display_paused and (
|
||||
(is_new or mode != self._last_rendered_view_mode)
|
||||
and not (self._replay_owns_image())
|
||||
):
|
||||
self._render_run_image(packet)
|
||||
if is_new:
|
||||
@@ -2274,6 +2471,19 @@ class StudioMainWindow(QMainWindow):
|
||||
return ""
|
||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
||||
|
||||
def _latched_domain_mask(self, frame: ImageFrame):
|
||||
"""The 2D domain boundary to draw, latched for the session.
|
||||
|
||||
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
|
||||
reason: the boundary is not a per-frame measurement. It is the device's own UV
|
||||
island — geometry, rebuilt from the same remap on every frame — and the depth
|
||||
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
|
||||
that had not moved while any difference in ``frame.valid_mask`` repainted the
|
||||
whole boundary, which reads as lag. The latch holds the boundary until a frame
|
||||
no longer describes the same island (see ``DomainMaskLatch``).
|
||||
"""
|
||||
return self._domain_mask.update(frame.valid_mask)
|
||||
|
||||
def _replay_owns_image(self) -> bool:
|
||||
"""True while the 2D panel shows the replay, not the live camera.
|
||||
|
||||
@@ -2331,7 +2541,10 @@ class StudioMainWindow(QMainWindow):
|
||||
draw_style = self._draw_style()
|
||||
# The legend is decided by the same inputs as the picture: the arrow
|
||||
# magnification of this sensor's draw style, and whether this frame
|
||||
# carries a valid-area mask.
|
||||
# carries a valid-area mask. It stays the *frame's* statement rather than the
|
||||
# latched boundary's, because that is what the chip claims: the arrows are
|
||||
# gated by this frame's own mask on every path, latched boundary or not.
|
||||
boundary = self._latched_domain_mask(frame)
|
||||
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||
if mode == DEPTH:
|
||||
@@ -2347,6 +2560,7 @@ class StudioMainWindow(QMainWindow):
|
||||
draw_style=draw_style,
|
||||
depth_range=self._depth_range(),
|
||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
||||
domain_mask=boundary,
|
||||
)
|
||||
if rendered is None:
|
||||
# Never substitute another picture here: a missing depth map that showed
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CHANNEL_KEYS,
|
||||
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
|
||||
class CalibrationService:
|
||||
"""Owns the calibration session's files and its prepared model."""
|
||||
|
||||
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
|
||||
def __init__(self, *, store: CalSessionStore | None = None) -> None:
|
||||
self.store = store or CalSessionStore()
|
||||
#: The one prepared model the panel displays and exports.
|
||||
self.prepared: Any = None
|
||||
#: Device identity of a reopened record, while it is open.
|
||||
self.record_identity: Optional[dict] = None
|
||||
self.record_identity: dict | None = None
|
||||
#: The record currently open, if any.
|
||||
self.open_record: Optional[CalibrationSessionRecord] = None
|
||||
self.open_record: CalibrationSessionRecord | None = None
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
def export_meta(
|
||||
|
||||
@@ -19,12 +19,42 @@ repaint when the 2D view mode changes.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
from orisys_studio.ui.plot_settings_dialog import (
|
||||
DEFAULT_PLOT_DEADBAND_N,
|
||||
DEFAULT_PLOT_DEADBAND_RAW,
|
||||
DEFAULT_PLOT_FN_MAX,
|
||||
DEFAULT_PLOT_FN_MIN,
|
||||
DEFAULT_PLOT_FN_RAW_MAX,
|
||||
DEFAULT_PLOT_FN_RAW_MIN,
|
||||
DEFAULT_PLOT_SHEAR_MAX,
|
||||
DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||
DEFAULT_PLOT_WINDOW_SEC,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The chart range, per channel and per unit. ``fn_min`` is the one that may be
|
||||
#: negative (FN is asymmetric); FX/FY take one absolute magnitude each.
|
||||
PLOT_RANGE_DEFAULTS = {
|
||||
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
|
||||
"fn_min": DEFAULT_PLOT_FN_MIN,
|
||||
"fn_max": DEFAULT_PLOT_FN_MAX,
|
||||
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
|
||||
"fn_raw_min": DEFAULT_PLOT_FN_RAW_MIN,
|
||||
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
|
||||
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||
}
|
||||
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
|
||||
#: startup window, with the presentation layer's own floor under it).
|
||||
PLOT_DEADBAND_DEFAULTS = {
|
||||
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
|
||||
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
|
||||
}
|
||||
|
||||
|
||||
class DisplayHost(Protocol):
|
||||
"""What the display controller needs from the window.
|
||||
@@ -36,7 +66,7 @@ class DisplayHost(Protocol):
|
||||
prefs: dict
|
||||
|
||||
@property
|
||||
def _latest_fps(self) -> Optional[float]: ...
|
||||
def _latest_fps(self) -> float | None: ...
|
||||
|
||||
def _persist_prefs(self) -> None: ...
|
||||
|
||||
@@ -64,7 +94,7 @@ class DisplayPrefsController(QObject):
|
||||
arrow_rotate_types: frozenset,
|
||||
mesh_panel: Any = None,
|
||||
rotate_arrows_action: Any = None,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._host = host
|
||||
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
|
||||
#: Whether the panels explain the pipeline around the picture (see
|
||||
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
||||
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
|
||||
#: Finger-raw value cards: startup auto baseline / deadband (display only).
|
||||
#: The display baseline / deadband (``raw_auto_zero``): a display aid
|
||||
#: that holds an idle channel at zero, for every sensor type and both
|
||||
#: units. It never reaches the session store or forces.csv.
|
||||
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
|
||||
self._apply_diagnostics()
|
||||
self.apply_presentation_prefs()
|
||||
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
|
||||
|
||||
# --------------------------------------------------------------------- plot
|
||||
def plot_settings(self) -> dict:
|
||||
"""The chart range and window as the settings dialog should show them."""
|
||||
"""The chart range, deadband and window as the settings dialog shows them."""
|
||||
prefs = self._host.prefs
|
||||
return {
|
||||
"window_sec": float(
|
||||
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
|
||||
),
|
||||
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
|
||||
"shear_max": float(
|
||||
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
|
||||
),
|
||||
"fn_raw_max": float(
|
||||
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
|
||||
),
|
||||
"shear_raw_max": float(
|
||||
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
|
||||
),
|
||||
"units": self._toolbar.units(),
|
||||
settings = {
|
||||
key: self._number(
|
||||
f"plot_{key}", self._plot_defaults.get(key, fallback)
|
||||
)
|
||||
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
|
||||
}
|
||||
settings["units"] = self._toolbar.units()
|
||||
return settings
|
||||
|
||||
def _number(self, key: str, fallback: float) -> float:
|
||||
"""A stored number, or the fallback when the preference says nothing.
|
||||
|
||||
0 is a value here, not "unset": the raw bounds and the deadband floors
|
||||
all give 0 a meaning of its own.
|
||||
"""
|
||||
value = self._host.prefs.get(key)
|
||||
if value is None or value == "":
|
||||
return float(fallback)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(fallback)
|
||||
|
||||
def apply_plot_settings(self, values: dict) -> None:
|
||||
"""Remember the chart range the operator chose, and show it at once."""
|
||||
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
|
||||
self._host.prefs[f"plot_{key}"] = values[key]
|
||||
self._charts.set_window_sec(float(values["window_sec"]))
|
||||
self._charts.set_axis_limits(
|
||||
float(values["fn_max"]),
|
||||
float(values["shear_max"]),
|
||||
float(values["fn_raw_max"]),
|
||||
float(values["shear_raw_max"]),
|
||||
)
|
||||
"""Remember the chart range the operator chose, and show it at once.
|
||||
|
||||
A caller that knows only some of the keys leaves the rest as stored, so
|
||||
this can never silently reset a range it was not told about.
|
||||
"""
|
||||
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
|
||||
if values.get(key) is not None:
|
||||
self._host.prefs[f"plot_{key}"] = values[key]
|
||||
settings = self.plot_settings()
|
||||
self._charts.set_window_sec(float(settings["window_sec"]))
|
||||
self._apply_ranges(settings)
|
||||
self._host._persist_prefs()
|
||||
|
||||
def apply_plot_prefs_to_panel(self) -> None:
|
||||
"""Show the stored chart range without asking anything (startup path)."""
|
||||
values = self.plot_settings()
|
||||
self._charts.set_window_sec(float(values["window_sec"]))
|
||||
self._charts.set_axis_limits(
|
||||
float(values["fn_max"]),
|
||||
float(values["shear_max"]),
|
||||
float(values["fn_raw_max"]),
|
||||
float(values["shear_raw_max"]),
|
||||
)
|
||||
settings = self.plot_settings()
|
||||
self._charts.set_window_sec(float(settings["window_sec"]))
|
||||
self._apply_ranges(settings)
|
||||
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
|
||||
|
||||
def _apply_ranges(self, settings: dict) -> None:
|
||||
"""Push one complete range + deadband picture onto the charts."""
|
||||
self._charts.set_axis_limits(
|
||||
fn_min=float(settings["fn_min"]),
|
||||
fn_max=float(settings["fn_max"]),
|
||||
shear_max=float(settings["shear_max"]),
|
||||
fn_raw_min=float(settings["fn_raw_min"]),
|
||||
fn_raw_max=float(settings["fn_raw_max"]),
|
||||
shear_raw_max=float(settings["shear_raw_max"]),
|
||||
)
|
||||
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
|
||||
|
||||
def _push_deadband_floors(self, newton: float, raw: float) -> None:
|
||||
floors = getattr(self._charts, "set_deadband_floors", None)
|
||||
if callable(floors):
|
||||
floors(newton=float(newton), raw=float(raw))
|
||||
|
||||
def device_sample_rate(self) -> float:
|
||||
"""The frame rate the low-pass coefficients would be designed for."""
|
||||
from orisys_studio import session_metadata
|
||||
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
|
||||
return bool(self._raw_auto_zero)
|
||||
|
||||
def set_raw_auto_zero(self, enabled: bool) -> None:
|
||||
"""Persist whether Finger raw value cards auto-baseline at session start."""
|
||||
"""Persist whether the display baseline / deadband is on at all.
|
||||
|
||||
The preference keeps its name; it is no longer Finger-raw-only (see
|
||||
:meth:`apply_presentation_prefs`).
|
||||
"""
|
||||
self._raw_auto_zero = bool(enabled)
|
||||
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
|
||||
self._host._persist_prefs()
|
||||
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
|
||||
self.set_raw_auto_zero(enabled)
|
||||
|
||||
def apply_presentation_prefs(self) -> None:
|
||||
"""Push the current baseline policy onto the charts (Finger raw only)."""
|
||||
"""Push the display baseline policy onto the charts.
|
||||
|
||||
Every sensor type and both units. The aid holds an idle channel at zero:
|
||||
a newton offset left in the numbers is the same display problem as a raw
|
||||
count, so which device produced the reading is not what decides it.
|
||||
"""
|
||||
configure = getattr(self._charts, "configure_presentation", None)
|
||||
if not callable(configure):
|
||||
return
|
||||
sensor = (
|
||||
self._picker.sensor_type()
|
||||
if hasattr(self._picker, "sensor_type")
|
||||
else "box"
|
||||
)
|
||||
unit = "N" if self._toolbar.units() == "newton" else "raw"
|
||||
configure(
|
||||
sensor_type=sensor,
|
||||
unit=unit,
|
||||
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
|
||||
if callable(configure):
|
||||
configure(sensor_type=sensor, unit=unit)
|
||||
enable = getattr(self._charts, "set_presentation_enabled", None)
|
||||
if callable(enable):
|
||||
enable(bool(self._raw_auto_zero))
|
||||
self._push_deadband_floors(
|
||||
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
|
||||
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
|
||||
)
|
||||
|
||||
def request_raw_reestimate(self) -> None:
|
||||
|
||||
@@ -11,17 +11,18 @@ import sys
|
||||
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
|
||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.paths import prefer_local_sensor_bundles
|
||||
from orisys_app_shared.prefs import load_preferences
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from orisys_studio import REQUIRES_SHARED_API
|
||||
from orisys_studio import __version__ as app_version
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,8 +16,9 @@ here, where they can be tested without a display.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
|
||||
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
|
||||
@@ -34,7 +35,7 @@ class RecordingCoordinator:
|
||||
*,
|
||||
session_store,
|
||||
cal_recorder,
|
||||
recorder: Optional[ExperimentRecorder] = None,
|
||||
recorder: ExperimentRecorder | None = None,
|
||||
) -> None:
|
||||
self.session_store = session_store
|
||||
self.cal_recorder = cal_recorder
|
||||
@@ -47,7 +48,7 @@ class RecordingCoordinator:
|
||||
self._alarms: list[str] = []
|
||||
#: A stop was requested and the writer has not closed its files yet.
|
||||
self.report_pending = False
|
||||
self.last_report: Optional[RecordReport] = None
|
||||
self.last_report: RecordReport | None = None
|
||||
self._active_calibration_id = ""
|
||||
self._record_fps = 30.0
|
||||
self.recorder.set_alarm_handler(self._on_alarm)
|
||||
@@ -210,7 +211,7 @@ class RecordingCoordinator:
|
||||
self.report_pending = False
|
||||
return Path(directory)
|
||||
|
||||
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
|
||||
def stop(self, *, wait: bool = True) -> RecordReport | None:
|
||||
"""Stop and return the report, or ``None`` when nothing was started."""
|
||||
if self.recorder.directory is None and self.recorder.report is None:
|
||||
self.report_pending = False
|
||||
@@ -221,7 +222,7 @@ class RecordingCoordinator:
|
||||
self.publish(report)
|
||||
return report
|
||||
|
||||
def publish(self, report: Optional[RecordReport]) -> None:
|
||||
def publish(self, report: RecordReport | None) -> None:
|
||||
"""Record a report; a completed one becomes ``last_report``."""
|
||||
if report is None:
|
||||
return
|
||||
@@ -232,7 +233,7 @@ class RecordingCoordinator:
|
||||
self.report_pending = False
|
||||
self.last_report = report
|
||||
|
||||
def poll(self) -> Optional[RecordReport]:
|
||||
def poll(self) -> RecordReport | None:
|
||||
"""The terminal report, once the writer has actually closed the files."""
|
||||
if not self.report_pending:
|
||||
return None
|
||||
@@ -256,7 +257,7 @@ def record_config_from_prefs(
|
||||
*,
|
||||
mode: str,
|
||||
fps: float,
|
||||
on_problem: Optional[Callable[[str], None]] = None,
|
||||
on_problem: Callable[[str], None] | None = None,
|
||||
) -> RecordConfig:
|
||||
"""Build the record config the toolbar/prefs describe."""
|
||||
return RecordConfig(
|
||||
|
||||
@@ -21,13 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +62,7 @@ class ReplayEngine(QObject):
|
||||
self._position = 0.0
|
||||
#: The frame cache: one video image per cursor position.
|
||||
self._video_position: float | None = None
|
||||
self._video_bgr: Optional[np.ndarray] = None
|
||||
self._video_bgr: np.ndarray | None = None
|
||||
self._video_reason = ""
|
||||
self._warnings = ""
|
||||
|
||||
@@ -209,7 +207,7 @@ class ReplayEngine(QObject):
|
||||
return self._unit
|
||||
|
||||
# ----------------------------------------------------------------- frames
|
||||
def video_frame_bgr(self) -> Optional[np.ndarray]:
|
||||
def video_frame_bgr(self) -> np.ndarray | None:
|
||||
"""The recorded frame under the cursor, as BGR.
|
||||
|
||||
The engine hands over pixels, not a prepared widget image: the single 2D
|
||||
|
||||
@@ -25,15 +25,14 @@ import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +110,7 @@ class ReplayController(QObject):
|
||||
charts: Any,
|
||||
image_panel: Any,
|
||||
mesh_panel: Any,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._engine = engine
|
||||
@@ -120,7 +119,7 @@ class ReplayController(QObject):
|
||||
self._charts = charts
|
||||
self._image_panel = image_panel
|
||||
self._mesh_panel = mesh_panel
|
||||
self._actions: Optional[ReplayActions] = None
|
||||
self._actions: ReplayActions | None = None
|
||||
#: True while a recompute pass is running: it is a *run* over a recording, so
|
||||
#: the acquire pipeline owns the panels and the transport stops the pass.
|
||||
self._recompute_running = False
|
||||
@@ -501,7 +500,7 @@ class ReplayController(QObject):
|
||||
self._host._notify(t("toolbar.nothing_to_stop"))
|
||||
|
||||
# ---------------------------------------------------------------- recompute
|
||||
def video_segment(self) -> Optional[Path]:
|
||||
def video_segment(self) -> Path | None:
|
||||
"""The recorded video a recompute pass should read."""
|
||||
reader = self._engine.reader()
|
||||
segments = sorted(reader.segments()) if reader is not None else []
|
||||
|
||||
@@ -33,10 +33,9 @@ Two things the v1 adapters deliberately do **not** do:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
@@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
FrameIdentity,
|
||||
SurfaceFieldFrame,
|
||||
diagnostic,
|
||||
error,
|
||||
freeze,
|
||||
)
|
||||
from orisys_app_shared.scene3d.sampling import remap_to_domain
|
||||
@@ -84,7 +82,7 @@ def _refusal_rank(item: Diagnostic) -> int:
|
||||
return _REFUSAL_RANK.get(str(item.code), 0)
|
||||
|
||||
|
||||
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||
def flow_magnitude(packet: SdkPacket) -> np.ndarray | None:
|
||||
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
|
||||
flow = getattr(packet, "flow", None)
|
||||
if flow is None:
|
||||
@@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||
return None
|
||||
|
||||
|
||||
def _shape_of(array: Optional[np.ndarray]) -> str:
|
||||
def _shape_of(array: np.ndarray | None) -> str:
|
||||
return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2])
|
||||
|
||||
|
||||
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Optional[Diagnostic]:
|
||||
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Diagnostic | None:
|
||||
"""Whether the packet may claim ``field`` for *this* frame.
|
||||
|
||||
Three answers, and the middle one is the point: ``None`` on the packet means the
|
||||
@@ -142,13 +140,22 @@ def _computed_frame_id(packet: SdkPacket, field: str, frame_id: int) -> int:
|
||||
|
||||
|
||||
def _device_mask(
|
||||
packet: SdkPacket, shape_hw: tuple[int, int]
|
||||
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
||||
packet: SdkPacket,
|
||||
shape_hw: tuple[int, int],
|
||||
latch: Any = None,
|
||||
) -> tuple[np.ndarray | None, Diagnostic | None]:
|
||||
"""The device's own validity, or a diagnostic when it describes another grid.
|
||||
|
||||
Both silent choices are wrong: stretching a mask from another grid marks samples
|
||||
valid that the device never claimed, and ignoring it *widens* the trusted area to
|
||||
the whole UV domain. So a mismatch refuses the frame and names both shapes.
|
||||
|
||||
``latch`` is the session's ``DomainMaskLatch`` when the window has one. The mask it
|
||||
returns is the *session's* island — the same array the 2D canvas paints its island
|
||||
and its "no depth" grey from — so the surface's validity texture is that island and
|
||||
not a second, independently rebuilt copy of it. The latch is idempotent for a
|
||||
repeated mask (an IoU of 1 is always held), which is what makes it safe for both
|
||||
views to consult it from different threads and different frames.
|
||||
"""
|
||||
explicit = getattr(packet, "valid_mask", None)
|
||||
if explicit is None:
|
||||
@@ -161,10 +168,14 @@ def _device_mask(
|
||||
mask=_shape_of(array),
|
||||
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
|
||||
)
|
||||
if latch is not None:
|
||||
held = latch.update(array)
|
||||
if held is not None:
|
||||
array = np.asarray(held, dtype=bool)
|
||||
return array, None
|
||||
|
||||
|
||||
def _intersect(mask: np.ndarray, device: Optional[np.ndarray]) -> np.ndarray:
|
||||
def _intersect(mask: np.ndarray, device: np.ndarray | None) -> np.ndarray:
|
||||
"""Inside the UV island AND the device's own valid area, when it stated one."""
|
||||
if device is None:
|
||||
return freeze(mask, dtype=np.bool_)
|
||||
@@ -205,8 +216,8 @@ class FingerFrameAdapter:
|
||||
geometry_id: str,
|
||||
field_domain_id: str,
|
||||
shape_hw: tuple[int, int],
|
||||
map_x: Optional[np.ndarray] = None,
|
||||
map_y: Optional[np.ndarray] = None,
|
||||
map_x: np.ndarray | None = None,
|
||||
map_y: np.ndarray | None = None,
|
||||
preferred_field: str = "depth",
|
||||
) -> None:
|
||||
self.geometry_id = str(geometry_id)
|
||||
@@ -214,7 +225,7 @@ class FingerFrameAdapter:
|
||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||
self.map_x = map_x
|
||||
self.map_y = map_y
|
||||
self.last_diagnostic: Optional[Diagnostic] = None
|
||||
self.last_diagnostic: Diagnostic | None = None
|
||||
#: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other
|
||||
#: one is tried when this one cannot be coloured, and the substitution is stated
|
||||
#: rather than silent — a frame that shows another quantity than the setting says
|
||||
@@ -223,13 +234,18 @@ class FingerFrameAdapter:
|
||||
self.preferred_field = _field_name(preferred_field)
|
||||
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
||||
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
||||
self.last_substitution: Optional[Diagnostic] = None
|
||||
self.last_substitution: Diagnostic | None = None
|
||||
#: The session's island latch (``image_backend.DomainMaskLatch``), when the
|
||||
#: window has one. See ``_device_mask``: the surface's validity is the *latched*
|
||||
#: island — the same mask the 2D canvas paints its island and its "no depth"
|
||||
#: grey from — so the two views cannot disagree about where the measurement is.
|
||||
self.domain_latch: Any = None
|
||||
|
||||
def set_preferred_field(self, name: str) -> None:
|
||||
self.preferred_field = _field_name(name)
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
|
||||
def from_domain(cls, domain: Any, *, geometry_id: str) -> FingerFrameAdapter:
|
||||
"""Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
|
||||
return cls(
|
||||
geometry_id=geometry_id,
|
||||
@@ -241,7 +257,7 @@ class FingerFrameAdapter:
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""Colour the surface with the field the operator chose, or say why not.
|
||||
|
||||
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
|
||||
@@ -288,7 +304,7 @@ class FingerFrameAdapter:
|
||||
identity: FrameIdentity,
|
||||
name: str,
|
||||
refused: list[Diagnostic],
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""One quantity, or ``None`` with the reason appended to ``refused``."""
|
||||
if name == "depth":
|
||||
depth = getattr(packet, "depth", None)
|
||||
@@ -383,11 +399,11 @@ class FingerFrameAdapter:
|
||||
mask: np.ndarray,
|
||||
descriptor: FieldDescriptor,
|
||||
refused: list[Diagnostic],
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""Intersect with the device's own mask and build the frame."""
|
||||
# Inside the UV island is the only place a value means anything: the tracker is
|
||||
# looking at masked black outside it.
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
if mask_problem is not None:
|
||||
refused.append(mask_problem)
|
||||
return None
|
||||
@@ -407,7 +423,7 @@ class FingerFrameAdapter:
|
||||
arr = np.asarray(flow, dtype=np.float32)
|
||||
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
||||
flow_uv = freeze(arr, dtype=np.float32)
|
||||
device_for_flow, _ = _device_mask(packet, self.shape_hw)
|
||||
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
|
||||
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
||||
if not np.isfinite(fn):
|
||||
@@ -451,11 +467,13 @@ class BoxFrameAdapter:
|
||||
self.geometry_id = str(geometry_id)
|
||||
self.field_domain_id = str(field_domain_id)
|
||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||
self.last_diagnostic: Optional[Diagnostic] = None
|
||||
self.last_diagnostic: Diagnostic | None = None
|
||||
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
|
||||
self.domain_latch: Any = None
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
if tuple(domain.shape_hw) != self.shape_hw:
|
||||
self.last_diagnostic = diagnostic(
|
||||
"field_domain_mismatch",
|
||||
@@ -468,11 +486,11 @@ class BoxFrameAdapter:
|
||||
("depth", getattr(packet, "depth", None)),
|
||||
("flow", flow_magnitude(packet)),
|
||||
)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
if mask_problem is not None:
|
||||
self.last_diagnostic = mask_problem
|
||||
return None
|
||||
refused: Optional[Diagnostic] = None
|
||||
refused: Diagnostic | None = None
|
||||
for name, field in candidates:
|
||||
if field is None:
|
||||
continue
|
||||
|
||||
@@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from orisys_app_shared.paths import config_dir
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
@@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
diagnostic,
|
||||
error,
|
||||
)
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
|
||||
|
||||
if TYPE_CHECKING: # the description is a value object; the callable returns one
|
||||
@@ -58,7 +59,7 @@ PACKAGE_SUFFIX = ".oryscene"
|
||||
COMPILE_HINT = (
|
||||
"python tools/scene_assets/compile_scene.py "
|
||||
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
|
||||
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
|
||||
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz "
|
||||
"--meters-per-source-unit <stated> --output "
|
||||
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
|
||||
)
|
||||
@@ -76,7 +77,7 @@ class SceneSource:
|
||||
return f"{self.kind}: {self.path}"
|
||||
|
||||
|
||||
def scene_dir(sensor_type: str, *, root: Optional[Path] = None) -> Path:
|
||||
def scene_dir(sensor_type: str, *, root: Path | None = None) -> Path:
|
||||
"""Where a compiled package for this sensor belongs."""
|
||||
base = Path(root) if root is not None else config_dir()
|
||||
return base / "sensors" / str(sensor_type) / "scene"
|
||||
@@ -86,8 +87,8 @@ def find_package(
|
||||
sensor_type: str,
|
||||
*,
|
||||
override: str | Path | None = None,
|
||||
root: Optional[Path] = None,
|
||||
) -> Optional[SceneSource]:
|
||||
root: Path | None = None,
|
||||
) -> SceneSource | None:
|
||||
"""The package to load, or ``None``. Never raises: absence is a state."""
|
||||
if override:
|
||||
path = Path(str(override)).expanduser()
|
||||
@@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
|
||||
|
||||
def load_package(
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> SceneAssets:
|
||||
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
|
||||
|
||||
@@ -153,10 +154,10 @@ def load_package(
|
||||
|
||||
def _resolve_and_load(
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry],
|
||||
provider: "Optional[Callable[[], DeviceGeometry]]",
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
|
||||
expected: ExpectedGeometry | None,
|
||||
provider: Callable[[], DeviceGeometry] | None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> tuple[SceneAssets, DeviceGeometry | None]:
|
||||
"""Worker-thread half: build the *device's* description, then read the package.
|
||||
|
||||
Deriving the description digests the unroll map the runtime loads, so it belongs on
|
||||
@@ -183,21 +184,21 @@ class SceneAssetLoader(QObject):
|
||||
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||
failed = Signal(object, int) # Diagnostic, request id
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._inflight = False
|
||||
self._request_id = 0
|
||||
#: The newest request that could not start yet (source, expected, provider, id).
|
||||
self._pending: Optional[tuple[SceneSource, Optional[ExpectedGeometry], Any, int]] = None
|
||||
self._pending: tuple[SceneSource, ExpectedGeometry | None, Any, int] | None = None
|
||||
|
||||
def load(
|
||||
self,
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
*,
|
||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> int:
|
||||
"""Ask for a scene; returns the request id this call was given."""
|
||||
self._request_id += 1
|
||||
|
||||
@@ -15,14 +15,12 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
F32,
|
||||
CameraMatrices,
|
||||
CameraPreset,
|
||||
F32,
|
||||
freeze,
|
||||
)
|
||||
|
||||
@@ -54,7 +52,7 @@ class OrbitCamera:
|
||||
vertical_fov_rad: float = 0.6
|
||||
aspect: float = 1.0
|
||||
_fitted_radius_m: float = 0.05
|
||||
_preset_name: Optional[str] = None
|
||||
_preset_name: str | None = None
|
||||
|
||||
# ------------------------------------------------------------------- setup
|
||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
||||
|
||||
@@ -36,9 +36,10 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import CancelledError, Executor, Future
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
@@ -106,27 +107,27 @@ class ThreeDCoordinator:
|
||||
#: withdrawn: a stop, a hide, a new session, a new scene. A compose result
|
||||
#: whose epoch is not this one is thrown away instead of presented.
|
||||
self._epoch = 0
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._source_id = ""
|
||||
self._session_id: Optional[int] = None
|
||||
self._session_id: int | None = None
|
||||
#: The newest request id seen in this epoch: a producer that falls behind
|
||||
#: (a seek UI, a queue that reorders) must not put an older frame on screen.
|
||||
self._last_request_id = 0
|
||||
self._visible = False
|
||||
self._settings = DisplaySettings(max_fps=self._max_fps)
|
||||
self._inflight: Optional[_Inflight] = None
|
||||
self._inflight: _Inflight | None = None
|
||||
self._stale_frames = 0
|
||||
self._invalid_frames = 0
|
||||
self._last_diagnostic: Optional[Diagnostic] = None
|
||||
self._last_diagnostic: Diagnostic | None = None
|
||||
self._closed = False
|
||||
self._settings_dirty = False
|
||||
self._last_composed_identity: Optional[FrameIdentity] = None
|
||||
self._last_composed_identity: FrameIdentity | None = None
|
||||
#: What is on screen and when it got there, for the pane's frame counter.
|
||||
self._last_presented: Optional[tuple[FrameIdentity, float]] = None
|
||||
self._last_presented: tuple[FrameIdentity, float] | None = None
|
||||
#: When the current measurement stopped being current, or ``None`` while the
|
||||
#: view is live. Set by a field that could not be built, a compose that
|
||||
#: failed, or a frame whose array belongs to an earlier acquisition frame.
|
||||
self._stale_since: Optional[float] = None
|
||||
self._stale_since: float | None = None
|
||||
#: Whether the reference model is already on screen for the current claim.
|
||||
#: Without this the pane re-uploaded the same rest frame on every 16 ms tick
|
||||
#: while it waited for the first measurement.
|
||||
@@ -138,7 +139,7 @@ class ThreeDCoordinator:
|
||||
self._last_result_latency_ms = 0.0
|
||||
#: The field frame currently on screen, so a gain change can redraw exactly
|
||||
#: it rather than a newer instant.
|
||||
self._last_field_frame: Optional[SurfaceFieldFrame] = None
|
||||
self._last_field_frame: SurfaceFieldFrame | None = None
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
@property
|
||||
@@ -155,7 +156,7 @@ class ThreeDCoordinator:
|
||||
"""The display claim results are accepted under (see :class:`_Inflight`)."""
|
||||
return self._epoch
|
||||
|
||||
def last_presented(self) -> Optional[tuple[FrameIdentity, float]]:
|
||||
def last_presented(self) -> tuple[FrameIdentity, float] | None:
|
||||
"""What is on screen, and the monotonic time it was handed to the widget."""
|
||||
return self._last_presented
|
||||
|
||||
@@ -190,7 +191,7 @@ class ThreeDCoordinator:
|
||||
return self._invalid_frames
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._last_diagnostic
|
||||
|
||||
@property
|
||||
@@ -355,7 +356,7 @@ class ThreeDCoordinator:
|
||||
self._last_request_id = max(self._last_request_id, identity.request_id)
|
||||
return self._incoming.offer(frame)
|
||||
|
||||
def note_field_failure(self, item: Optional[Diagnostic] = None) -> None:
|
||||
def note_field_failure(self, item: Diagnostic | None = None) -> None:
|
||||
"""A producer could not build a field frame from this packet.
|
||||
|
||||
Called from the acquisition thread by the sink that owns the adapter: when the
|
||||
@@ -373,7 +374,7 @@ class ThreeDCoordinator:
|
||||
@staticmethod
|
||||
def _reject_reason(
|
||||
frame: SurfaceFieldFrame, assets: SceneAssets
|
||||
) -> Optional[Diagnostic]:
|
||||
) -> Diagnostic | None:
|
||||
"""The cheap checks a producer thread may do: identity and shape only."""
|
||||
if frame.geometry_id != assets.geometry_id:
|
||||
return diagnostic(
|
||||
@@ -452,7 +453,7 @@ class ThreeDCoordinator:
|
||||
# A frame arrived with its own fields: whatever went stale is current
|
||||
# again, and the grace starts over next time it fails.
|
||||
self._stale_since = None
|
||||
self._set_state("live" if composed.valid.any() else "no_data")
|
||||
self._set_state("live" if composed.field.valid.any() else "no_data")
|
||||
elif self._state in ("ready", "loading"):
|
||||
# Nothing has arrived yet: show the reference model rather than a blank
|
||||
# window, and say that no measurement is being displayed. Only once per
|
||||
@@ -482,6 +483,19 @@ class ThreeDCoordinator:
|
||||
# held field frame so the operator sees the change without waiting.
|
||||
self._settings_dirty = True
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Hand the theme's colour ramp to the composer.
|
||||
|
||||
The composer stamps it on every ``DisplayField`` it builds, and the GL layer
|
||||
builds its lookup table from the field it is handed. That is how the 3D
|
||||
surface and the 2D canvas end up coloured by one table: the ramp is resolved
|
||||
once, from the theme, and travels with the measurement.
|
||||
"""
|
||||
try:
|
||||
self._composer.ramp = str(ramp or "")
|
||||
except AttributeError: # pragma: no cover - a composer stand-in without state
|
||||
pass
|
||||
|
||||
def show_rest(self) -> None:
|
||||
"""Put the reference model back on screen and stop claiming a measurement.
|
||||
|
||||
|
||||
@@ -12,24 +12,27 @@ Import discipline, and why it is not negotiable:
|
||||
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
||||
current — resources are freed by the context that made them.
|
||||
|
||||
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
|
||||
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
||||
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
|
||||
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
|
||||
in the neutral colour rather than in colour zero — the reference model is not a
|
||||
measurement and must not look like one.
|
||||
Two parts, two programs, one draw call per part. The sensing surface's colour comes
|
||||
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
|
||||
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
|
||||
builds an RGB array per frame and never gathers the field onto the vertices; the
|
||||
vertex buffer is uploaded once and re-uploaded only when a display deformation
|
||||
actually moves a vertex. The housing is uploaded once and never touched again. A
|
||||
fragment whose texel is invalid is drawn in the neutral colour rather than in colour
|
||||
zero: the reference model is not a measurement and must not look like one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraMatrices,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
GLCapabilities,
|
||||
MeshPart,
|
||||
@@ -41,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
error,
|
||||
freeze,
|
||||
)
|
||||
from orisys_app_shared.scene3d.contracts import Diagnostic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Renderer names that are not a GPU. A board must not be signed off on one of
|
||||
#: these, and neither must a laptop with a broken driver.
|
||||
@@ -61,6 +65,16 @@ INVALID_COLOR = (0.18, 0.18, 0.20, 1.0)
|
||||
#: LUT size. 256 is enough for a sequential ramp and cheap to rebuild.
|
||||
LUT_SIZE = 256
|
||||
|
||||
#: The ramp used when a field does not name one. It is the dark theme's own
|
||||
#: ``flow_ramp``: the 2D canvas and the 3D pane must be coloured by the same stops,
|
||||
#: and a default that differed from either would be the bug this replaces.
|
||||
DEFAULT_FIELD_RAMP = "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||
|
||||
#: Bytes per texel the GPU budget charges for the field textures: ``R16F`` scalar
|
||||
#: (nominal; the ``R32F`` fallback is 4) plus the ``R8`` validity.
|
||||
FIELD_BYTES_PER_TEXEL = 2
|
||||
VALID_BYTES_PER_TEXEL = 1
|
||||
|
||||
|
||||
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||
"""Select the default GL surface format. Call before ``QApplication``.
|
||||
@@ -112,33 +126,42 @@ def profile_from_renderable_type(value: int) -> Profile:
|
||||
# --------------------------------------------------------------------- shaders
|
||||
#: Shared bodies: the version header is the only difference, so the maths cannot
|
||||
#: drift between the desktop and the embedded build.
|
||||
#:
|
||||
#: The surface is **texture-first**. The vertex stage carries geometry and one UV
|
||||
#: pair per vertex — nothing about the measurement — and the fragment stage reads the
|
||||
#: field and its validity from two textures on the domain's own grid. There used to
|
||||
#: be ``a_scalar``/``a_valid`` attributes here, gathered per *vertex* on the CPU and
|
||||
#: interpolated across the triangles; a vertex whose taps touched one invalid pixel
|
||||
#: became ``NaN`` and the ``isnan`` rule below painted the whole triangle grey
|
||||
#: (measured: 30.15 % of the island). Sampling per fragment removes the interpolation
|
||||
#: step entirely: the fragment reads the same texel the 2D canvas paints at that
|
||||
#: pixel, so the two views agree by construction.
|
||||
_VERTEX_BODY = """
|
||||
layout(location = 0) in vec3 a_position;
|
||||
layout(location = 1) in vec3 a_normal;
|
||||
layout(location = 2) in float a_scalar;
|
||||
layout(location = 3) in float a_valid;
|
||||
layout(location = 4) in vec2 a_uv;
|
||||
|
||||
uniform mat4 u_view;
|
||||
uniform mat4 u_projection;
|
||||
|
||||
out vec3 v_normal;
|
||||
out float v_scalar;
|
||||
out float v_valid;
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
v_normal = a_normal;
|
||||
v_scalar = a_scalar;
|
||||
v_valid = a_valid;
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
_SCALAR_FRAGMENT_BODY = """
|
||||
in vec3 v_normal;
|
||||
in float v_scalar;
|
||||
in float v_valid;
|
||||
in vec2 v_uv;
|
||||
|
||||
uniform sampler2D u_lut;
|
||||
uniform sampler2D u_field;
|
||||
uniform sampler2D u_valid;
|
||||
uniform vec2 u_inv_domain_size;
|
||||
uniform float u_lower;
|
||||
uniform float u_upper;
|
||||
uniform vec4 u_invalid_color;
|
||||
@@ -146,20 +169,25 @@ uniform vec4 u_invalid_color;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
if (v_valid < 0.5 || isnan(v_scalar)) {
|
||||
// ``v_uv`` is already on the domain's pixel grid (the compiler applied the field's
|
||||
// own flip and scale, once, in ``legacy.uvs_to_pixels``), so the texel this
|
||||
// fragment owns is the rounded pixel, centred by half a texel and normalised by
|
||||
// the domain size -- the only conversion left, and it is exact rather than a
|
||||
// resampling. NEAREST filtering on both textures then makes "valid" a property of
|
||||
// that one texel, which is what the 2D view paints.
|
||||
vec2 texel = (floor(v_uv + 0.5) + 0.5) * u_inv_domain_size;
|
||||
if (texture(u_valid, texel).r < 0.5) {
|
||||
frag_color = u_invalid_color;
|
||||
return;
|
||||
}
|
||||
float span = max(u_upper - u_lower, 1e-12);
|
||||
float t = clamp((v_scalar - u_lower) / span, 0.0, 1.0);
|
||||
float t = clamp((texture(u_field, texel).r - u_lower) / span, 0.0, 1.0);
|
||||
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
_DIFFUSE_FRAGMENT_BODY = """
|
||||
in vec3 v_normal;
|
||||
in float v_scalar;
|
||||
in float v_valid;
|
||||
|
||||
uniform vec3 u_light_direction;
|
||||
uniform vec4 u_base_color;
|
||||
@@ -235,7 +263,7 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
||||
return header + vertex, header + body
|
||||
|
||||
|
||||
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
|
||||
def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None:
|
||||
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
|
||||
|
||||
Using min/max made empty-field noise paint the whole LUT. A percentile span
|
||||
@@ -268,11 +296,11 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
|
||||
|
||||
|
||||
def _blend_scale_range(
|
||||
previous: Optional[tuple[float, float]],
|
||||
current: Optional[tuple[float, float]],
|
||||
previous: tuple[float, float] | None,
|
||||
current: tuple[float, float] | None,
|
||||
*,
|
||||
alpha: float = 0.15,
|
||||
) -> Optional[tuple[float, float]]:
|
||||
) -> tuple[float, float] | None:
|
||||
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
|
||||
if current is None:
|
||||
return previous
|
||||
@@ -284,7 +312,7 @@ def _blend_scale_range(
|
||||
(1.0 - a) * float(previous[1]) + a * float(current[1]),
|
||||
)
|
||||
|
||||
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
|
||||
def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float, float]:
|
||||
"""The colour range to draw with: the operator's, or the data's own.
|
||||
|
||||
``automatic`` mode exists because the quantities differ in magnitude by orders
|
||||
@@ -301,7 +329,7 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
|
||||
else:
|
||||
fallback = (float(scale.lower), float(scale.upper))
|
||||
lower, upper = float(fallback[0]), float(fallback[1])
|
||||
if not (lower == lower) or not (upper == upper): # NaN
|
||||
if math.isnan(lower) or math.isnan(upper):
|
||||
return 0.0, 1.0
|
||||
if upper <= lower:
|
||||
# A constant field still has to be visible rather than all one clipped colour.
|
||||
@@ -310,106 +338,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
|
||||
return lower, upper
|
||||
|
||||
|
||||
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
|
||||
"""A 256x1 RGBA8 lookup table.
|
||||
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
||||
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
|
||||
|
||||
Hand-rolled rather than imported: the runtime must not grow a plotting
|
||||
dependency for one texture, and the ramp is part of what the colours *mean*.
|
||||
``sequential`` is perceptually increasing in lightness (safe for magnitude);
|
||||
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
|
||||
signed quantity, and never used for an unsigned one).
|
||||
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
|
||||
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
|
||||
which is the dark theme's own. Either way the table is the *same* array the 2D
|
||||
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
|
||||
interpolation and the single rounding, so the two legends and the two pictures
|
||||
cannot disagree.
|
||||
|
||||
That was not true before: this function had its own hand-written stop list
|
||||
(a viridis-like ramp) while the 2D side used the theme's ``flow_ramp``, and the
|
||||
two disagreed by up to 218 of 255 per channel. A measurement drawn purple in one
|
||||
pane and navy in the other is two different measurements as far as an operator is
|
||||
concerned.
|
||||
|
||||
``diverging`` is the same ramp mirrored through its midpoint: safe for a signed
|
||||
quantity, and never used for an unsigned one.
|
||||
"""
|
||||
stops = np.array(
|
||||
[
|
||||
[0.267, 0.005, 0.329],
|
||||
[0.283, 0.141, 0.458],
|
||||
[0.254, 0.265, 0.530],
|
||||
[0.207, 0.372, 0.553],
|
||||
[0.164, 0.471, 0.558],
|
||||
[0.128, 0.567, 0.551],
|
||||
[0.135, 0.659, 0.518],
|
||||
[0.267, 0.749, 0.441],
|
||||
[0.478, 0.821, 0.318],
|
||||
[0.741, 0.873, 0.150],
|
||||
[0.993, 0.906, 0.144],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
if lut_id == "diverging":
|
||||
stops = np.concatenate([stops[::-1], stops], axis=0)
|
||||
positions = np.linspace(0.0, 1.0, stops.shape[0], dtype=np.float32)
|
||||
grid = np.linspace(0.0, 1.0, LUT_SIZE, dtype=np.float32)
|
||||
rgba = np.empty((LUT_SIZE, 1, 4), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
rgba[:, 0, channel] = np.clip(
|
||||
np.interp(grid, positions, stops[:, channel]) * 255.0, 0, 255
|
||||
).astype(np.uint8)
|
||||
rgba[:, 0, 3] = 255
|
||||
return rgba
|
||||
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
|
||||
|
||||
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- vertex packing
|
||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
|
||||
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
|
||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
|
||||
#: buffer per part, uploaded once. The stride is the same 8 floats it was when it
|
||||
#: carried a per-vertex scalar and validity — the measurement moved to its own
|
||||
#: texture, and the UV that addresses it took their place.
|
||||
STRIDE_FLOATS = 8
|
||||
|
||||
#: Where in a vertex row the UV starts.
|
||||
UV_OFFSET_FLOATS = 6
|
||||
|
||||
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
|
||||
valid: Optional[np.ndarray] = None) -> np.ndarray:
|
||||
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
|
||||
#: driver cannot alias them.
|
||||
LUT_TEXTURE_UNIT = 0
|
||||
FIELD_TEXTURE_UNIT = 1
|
||||
VALID_TEXTURE_UNIT = 2
|
||||
|
||||
|
||||
def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray:
|
||||
"""Every unusable texel's one-texel edge extension: the mean of its usable neighbours.
|
||||
|
||||
Slicing rather than ``np.roll``: a wrapped neighbour would carry a measurement in
|
||||
from the opposite edge of the domain, which is a value from somewhere else entirely.
|
||||
A texel with no usable neighbour — the whole field is a hole — keeps 0.0, and the
|
||||
validity channel is what decides whether anything is drawn there.
|
||||
"""
|
||||
height, width = values.shape
|
||||
total = np.zeros_like(values)
|
||||
counts = np.zeros_like(values)
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
if dy == 0 and dx == 0:
|
||||
continue
|
||||
source_y = slice(max(dy, 0), height + min(dy, 0))
|
||||
source_x = slice(max(dx, 0), width + min(dx, 0))
|
||||
target_y = slice(max(-dy, 0), height + min(-dy, 0))
|
||||
target_x = slice(max(-dx, 0), width + min(-dx, 0))
|
||||
neighbour_values = values[source_y, source_x]
|
||||
neighbour_usable = usable[source_y, source_x]
|
||||
total[target_y, target_x] += np.where(neighbour_usable, neighbour_values, 0.0)
|
||||
counts[target_y, target_x] += neighbour_usable
|
||||
filled = np.divide(total, counts, out=np.zeros_like(values), where=counts > 0)
|
||||
return np.where(usable, values, filled)
|
||||
|
||||
|
||||
def pack_part(part: MeshPart, *, uvs: np.ndarray | None = None) -> np.ndarray:
|
||||
"""Interleave one part into the layout the shaders read.
|
||||
|
||||
The static case (no scalar yet) marks every vertex invalid, which the shader
|
||||
draws as the neutral colour: a scene that has never received a measurement
|
||||
must not be coloured as if it had.
|
||||
``uvs`` are the render-vertex UVs on the domain's pixel grid
|
||||
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
|
||||
with no UV mapping (the housing) is never coloured by the measurement, so it gets
|
||||
zeros and its own diffuse program; the attribute exists in the shared layout but
|
||||
nothing reads it there.
|
||||
"""
|
||||
count = int(part.positions_sensor_m.shape[0])
|
||||
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
||||
rows[:, 0:3] = np.asarray(part.positions_sensor_m, dtype=np.float32)
|
||||
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
|
||||
if scalar is None:
|
||||
rows[:, 6] = np.nan
|
||||
if uvs is None:
|
||||
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
|
||||
else:
|
||||
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
|
||||
if values.size != count:
|
||||
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
|
||||
if values.shape[0] != count:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{part.part_id}: {values.size} scalars for {count} vertices",
|
||||
f"{part.part_id}: {values.shape[0]} UVs for {count} vertices",
|
||||
)
|
||||
rows[:, 6] = values
|
||||
if valid is None:
|
||||
rows[:, 7] = 0.0
|
||||
else:
|
||||
flags = np.asarray(valid, dtype=bool).reshape(-1)
|
||||
if flags.size != count:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{part.part_id}: {flags.size} validity flags for {count} vertices",
|
||||
)
|
||||
rows[:, 7] = flags.astype(np.float32)
|
||||
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = values
|
||||
return freeze(rows, dtype=np.float32)
|
||||
|
||||
|
||||
def pack_scene_part(part: MeshPart) -> tuple[np.ndarray, np.ndarray]:
|
||||
def pack_scene_part(
|
||||
part: MeshPart, *, uvs: np.ndarray | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
||||
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
||||
return pack_part(part), freeze(indices, dtype=np.uint32)
|
||||
return pack_part(part, uvs=uvs), freeze(indices, dtype=np.uint32)
|
||||
|
||||
|
||||
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
|
||||
"""A rough static-plus-one-dynamic budget, for the stats line."""
|
||||
"""A rough static-plus-one-dynamic budget, for the stats line.
|
||||
|
||||
The two field textures are part of it now: the scalar as 16-bit floats and the
|
||||
validity as one byte per texel, over the scene's own UV domain. They are counted
|
||||
at their nominal size (``R16F``), and the ``R32F`` fallback a driver without
|
||||
``R16F`` forces is twice the scalar half — noted rather than guessed at, because
|
||||
the budget is a *report*, and the code that picks the format is in
|
||||
:meth:`GLRenderer._ensure_field_textures`.
|
||||
"""
|
||||
total = 0
|
||||
for part in assets.parts:
|
||||
vertices = int(part.positions_sensor_m.shape[0])
|
||||
indices = int(part.triangles.size)
|
||||
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
||||
if part.part_id == dynamic_part_id:
|
||||
# A second copy for the frame in flight.
|
||||
# A second copy for the frame in flight. Only ever re-written when a display
|
||||
# deformation actually moves the vertices (``RenderFrame.deformed``).
|
||||
total += vertices * STRIDE_FLOATS * 4
|
||||
total += LUT_SIZE * 4
|
||||
height, width = int(assets.shape_hw[0]), int(assets.shape_hw[1])
|
||||
total += max(height, 0) * max(width, 0) * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
|
||||
return total
|
||||
|
||||
|
||||
def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
||||
def rest_surface(assets: SceneAssets, identity: Any, *, ramp: str = "") -> RenderFrame:
|
||||
"""The asset's own geometry, with nothing measured.
|
||||
|
||||
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
||||
@@ -419,7 +475,7 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
||||
"""
|
||||
from orisys_app_shared.scene3d.composer import build_rest_frame
|
||||
|
||||
return build_rest_frame(assets, identity)
|
||||
return build_rest_frame(assets, identity, ramp=ramp)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- dispatch
|
||||
@@ -543,9 +599,20 @@ def _leading_int(text: str) -> int:
|
||||
class _PartBuffers:
|
||||
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
||||
|
||||
__slots__ = ("part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices")
|
||||
__slots__ = (
|
||||
"ebo",
|
||||
"part_id",
|
||||
"role",
|
||||
"rows",
|
||||
"triangles",
|
||||
"vao",
|
||||
"vbo",
|
||||
"vertices"
|
||||
)
|
||||
|
||||
def __init__(self, part: MeshPart, vao: int, vbo: int, ebo: int) -> None:
|
||||
def __init__(
|
||||
self, part: MeshPart, vao: int, vbo: int, ebo: int, rows: np.ndarray
|
||||
) -> None:
|
||||
self.part_id = part.part_id
|
||||
self.role = part.role
|
||||
self.vao = vao
|
||||
@@ -553,23 +620,34 @@ class _PartBuffers:
|
||||
self.ebo = ebo
|
||||
self.triangles = int(part.triangles.shape[0])
|
||||
self.vertices = int(part.positions_sensor_m.shape[0])
|
||||
#: The interleaved rows as uploaded. A display deformation rewrites positions
|
||||
#: and normals into a copy of this; nothing else ever re-uploads a vertex.
|
||||
self.rows = rows
|
||||
|
||||
|
||||
class GLRenderer:
|
||||
"""Shaders, buffers and draw calls for one scene."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._capabilities: GLCapabilities | None = None
|
||||
self._programs: dict[str, int] = {}
|
||||
self._uniforms: dict[str, dict[str, int]] = {}
|
||||
self._parts: dict[str, _PartBuffers] = {}
|
||||
self._lut: int = 0
|
||||
self._lut_id: str = ""
|
||||
#: Range of the valid samples of the frame currently uploaded, for the
|
||||
#: The ramp the LUT was built from, so a theme change rebuilds it once.
|
||||
self._lut_ramp: str = ""
|
||||
#: The two field textures. Created once, sub-uploaded per frame: no per-frame
|
||||
#: allocation and no per-frame vertex traffic.
|
||||
self._field_texture: int = 0
|
||||
self._valid_texture: int = 0
|
||||
self._field_shape: tuple[int, int] | None = None
|
||||
self._field_internal_format: int = 0
|
||||
#: Range of the valid samples of the field currently uploaded, for the
|
||||
#: automatic colour scale. ``None`` until a frame carries valid data.
|
||||
self._scalar_range: Optional[tuple[float, float]] = None
|
||||
self._scalar_range: tuple[float, float] | None = None
|
||||
#: The colour bounds of the last draw and how they were chosen.
|
||||
self._last_scale: Optional[tuple[float, float]] = None
|
||||
self._last_scale: tuple[float, float] | None = None
|
||||
self._last_scale_mode = ""
|
||||
self._sensing_part_id: str = ""
|
||||
self._estimated_gpu_bytes: int = 0
|
||||
@@ -621,8 +699,11 @@ class GLRenderer:
|
||||
self._scalar_range = None
|
||||
self._last_scale = None
|
||||
self._last_scale_mode = ""
|
||||
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
|
||||
for part in assets.parts:
|
||||
vertices, indices = pack_scene_part(part)
|
||||
vertices, indices = pack_scene_part(
|
||||
part, uvs=uvs if part.role == "sensing" else None
|
||||
)
|
||||
vao = int(GL.glGenVertexArrays(1))
|
||||
vbo = int(GL.glGenBuffers(1))
|
||||
ebo = int(GL.glGenBuffers(1))
|
||||
@@ -639,13 +720,24 @@ class GLRenderer:
|
||||
GL.glBindVertexArray(0)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
|
||||
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo)
|
||||
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo, vertices)
|
||||
self._estimated_gpu_bytes = estimate_gpu_bytes(
|
||||
assets, dynamic_part_id=self._sensing_part_id
|
||||
)
|
||||
|
||||
def update_surface(self, frame: RenderFrame) -> None:
|
||||
"""Update the sensing part's vertices in place: no index rebuild, ever."""
|
||||
def update_field(self, frame: RenderFrame) -> None:
|
||||
"""Upload this frame's field as two textures. No vertex traffic.
|
||||
|
||||
This replaced ``update_surface``, which interleaved a per-vertex scalar and
|
||||
validity flag and pushed the whole vertex buffer through
|
||||
``glBufferSubData`` on every frame. The measurement no longer has anything to
|
||||
do with the vertices: the field goes into two textures on the domain's own
|
||||
grid and the fragment reads it through the UV that was already in the buffer.
|
||||
|
||||
A *display deformation* still moves vertices, so that case — and only that
|
||||
case, ``frame.deformed`` — re-uploads the sensing part's rows. No adapter in
|
||||
this build publishes a displacement, so the application never takes it.
|
||||
"""
|
||||
from OpenGL import GL
|
||||
|
||||
self._require_initialized()
|
||||
@@ -655,26 +747,132 @@ class GLRenderer:
|
||||
"geometry_mismatch",
|
||||
f"the scene has no part {frame.sensing_part_id!r} to update",
|
||||
)
|
||||
rows = pack_part(
|
||||
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
|
||||
scalar=frame.scalar,
|
||||
valid=frame.valid,
|
||||
)
|
||||
if int(rows.shape[0]) != buffers.vertices:
|
||||
field = frame.field
|
||||
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{buffers.part_id}: {rows.shape[0]} vertices for "
|
||||
f"{buffers.part_id}: {frame.positions_sensor_m.shape[0]} vertices for "
|
||||
f"{buffers.vertices} uploaded",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
# The field carries the theme's ramp, so the lookup table follows the same
|
||||
# string the 2D canvas built its table from. Checked here rather than plumbed
|
||||
# through the view: whatever field this renderer is handed is what it draws.
|
||||
self.set_ramp(getattr(field, "ramp", "") or "")
|
||||
if frame.deformed:
|
||||
rows = np.array(buffers.rows, copy=True)
|
||||
rows[:, 0:3] = np.asarray(frame.positions_sensor_m, dtype=np.float32)
|
||||
rows[:, 3:6] = np.asarray(frame.normals_sensor, dtype=np.float32)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
self._upload_field_textures(field)
|
||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||
measured = _valid_scalar_range(frame.scalar, frame.valid)
|
||||
measured = _valid_scalar_range(field.scalar, field.valid)
|
||||
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
||||
self._upload_overlays(frame)
|
||||
|
||||
def _upload_field_textures(self, field: Any) -> None:
|
||||
"""``glTexSubImage2D`` of the scalar and validity channels.
|
||||
|
||||
The scalar is ``R16F`` (``R32F`` when the driver will not take a half float) and
|
||||
is filtered linearly, so the picture is smooth rather than blocky when the plate
|
||||
is magnified. The validity is ``R8`` with ``NEAREST`` filtering, because a
|
||||
validity that a linear filter could blend is exactly the "partly valid triangle"
|
||||
that used to be painted grey.
|
||||
|
||||
**A texel with no measurement must not contribute to a filtered sample.** Linear
|
||||
filtering reaches one texel in each direction, so every invalid texel adjacent to
|
||||
the island is given the mean of its valid neighbours (a one-texel edge extension).
|
||||
Without it a valid fragment on the island's rim blended its own measurement with
|
||||
a placeholder's zero and was drawn in a colour that is neither the 2D pixel's nor
|
||||
the invalid colour — measured on the shipped asset as a texel painted both valid
|
||||
and not, depending on which fragment landed on it. One texel of margin is exactly
|
||||
enough for a 2x2 filter, and the validity channel is untouched, so the painted
|
||||
*region* is still decided per texel.
|
||||
"""
|
||||
from OpenGL import GL
|
||||
|
||||
shape = (int(field.shape_hw[0]), int(field.shape_hw[1]))
|
||||
self._ensure_field_textures(shape)
|
||||
measured = np.asarray(field.scalar, dtype=np.float32)
|
||||
usable = np.asarray(field.valid, dtype=bool) & np.isfinite(measured)
|
||||
scalars = np.where(usable, measured, 0.0).astype(np.float32)
|
||||
if usable.any() and not usable.all():
|
||||
scalars = np.where(usable, scalars, _extend_edges(scalars, usable))
|
||||
valid = np.ascontiguousarray(np.asarray(field.valid, dtype=bool).astype(np.uint8) * 255)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glTexSubImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||
GL.GL_RED, GL.GL_FLOAT, np.ascontiguousarray(scalars),
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glTexSubImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||
GL.GL_RED, GL.GL_UNSIGNED_BYTE, valid,
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
|
||||
def _ensure_field_textures(self, shape: tuple[int, int]) -> None:
|
||||
"""Allocate the two field textures once, for one domain shape."""
|
||||
from OpenGL import GL
|
||||
|
||||
if self._field_texture and self._field_shape == shape:
|
||||
return
|
||||
self._release_field_textures()
|
||||
height, width = int(shape[0]), int(shape[1])
|
||||
if height <= 0 or width <= 0:
|
||||
raise error(
|
||||
"field_domain_mismatch", f"the field has no extent: {height}x{width}"
|
||||
)
|
||||
capabilities = self._capabilities
|
||||
if capabilities is not None and capabilities.max_texture_size:
|
||||
if width > capabilities.max_texture_size or height > capabilities.max_texture_size:
|
||||
raise error(
|
||||
"gpu_budget_exceeded",
|
||||
f"the field is {width}x{height} but this GPU takes at most "
|
||||
f"{capabilities.max_texture_size} texels per side",
|
||||
)
|
||||
self._field_texture = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||
# ``R16F`` is the size the design asks for and every 3.3/ES3 context has it;
|
||||
# a driver that refuses it raises, and ``R32F`` is the documented fallback
|
||||
# rather than a silent no-texture render.
|
||||
for internal_format in (GL.GL_R16F, GL.GL_R32F):
|
||||
try:
|
||||
GL.glTexImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, internal_format, width, height, 0,
|
||||
GL.GL_RED, GL.GL_FLOAT, None,
|
||||
)
|
||||
self._field_internal_format = internal_format
|
||||
break
|
||||
except Exception: # noqa: BLE001 - a refused internal format
|
||||
while GL.glGetError() != GL.GL_NO_ERROR:
|
||||
pass
|
||||
if not self._field_internal_format:
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._release_field_textures()
|
||||
raise error(
|
||||
"gl_unsupported",
|
||||
"this context accepts neither R16F nor R32F for the field texture",
|
||||
)
|
||||
self._valid_texture = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, GL.GL_R8, width, height, 0,
|
||||
GL.GL_RED, GL.GL_UNSIGNED_BYTE, None,
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._field_shape = (height, width)
|
||||
|
||||
def _upload_overlays(self, frame: RenderFrame) -> None:
|
||||
"""Upload merged flow / contact arrow meshes for this frame."""
|
||||
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
||||
@@ -737,8 +935,12 @@ class GLRenderer:
|
||||
GL.glDeleteVertexArrays(1, [int(vao)])
|
||||
GL.glDeleteBuffers(1, [int(vbo)])
|
||||
GL.glDeleteBuffers(1, [int(ebo)])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - best-effort delete, reported below
|
||||
# A context that has already been lost refuses the delete, and this also
|
||||
# runs on the live overlay-replacement path: raising here would replace a
|
||||
# frame with a crash. Reported rather than swallowed, because a delete
|
||||
# that keeps failing leaks GPU memory for the rest of the session.
|
||||
logger.warning("could not free the %s overlay's GL objects: %s", kind, exc)
|
||||
|
||||
def draw(
|
||||
self,
|
||||
@@ -768,7 +970,9 @@ class GLRenderer:
|
||||
# panel has to be able to ask what the picture means.
|
||||
self._last_scale = (float(lower), float(upper))
|
||||
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
|
||||
self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential")
|
||||
self._ensure_lut(
|
||||
"diverging" if (scale and scale.descriptor.signed) else "sequential"
|
||||
)
|
||||
for part in self._parts.values():
|
||||
if part.role == "housing" and not settings.housing_visible:
|
||||
continue
|
||||
@@ -781,10 +985,7 @@ class GLRenderer:
|
||||
_set_float(GL, locations.get("u_lower"), lower)
|
||||
_set_float(GL, locations.get("u_upper"), upper)
|
||||
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
||||
if locations.get("u_lut") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glUniform1i(locations["u_lut"], 0)
|
||||
self._bind_field_textures(GL, locations)
|
||||
else:
|
||||
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
|
||||
_set_vec4(GL, locations.get("u_base_color"), _base_color(part.part_id))
|
||||
@@ -833,10 +1034,53 @@ class GLRenderer:
|
||||
scale_mode=self._last_scale_mode,
|
||||
)
|
||||
|
||||
def effective_scale(self) -> Optional[tuple[float, float]]:
|
||||
def effective_scale(self) -> tuple[float, float] | None:
|
||||
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||
return self._last_scale
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
|
||||
|
||||
The ramp is the *only* thing the 3D pane needs from the theme: it is what
|
||||
makes its colour table the same table the 2D canvas paints with.
|
||||
"""
|
||||
wanted = str(ramp or "")
|
||||
if wanted != self._lut_ramp:
|
||||
self._lut_ramp = wanted
|
||||
self._lut_id = ""
|
||||
|
||||
def _bind_field_textures(self, GL: Any, locations: dict[str, int]) -> None:
|
||||
if locations.get("u_lut") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + LUT_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glUniform1i(locations["u_lut"], LUT_TEXTURE_UNIT)
|
||||
if locations.get("u_field") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + FIELD_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glUniform1i(locations["u_field"], FIELD_TEXTURE_UNIT)
|
||||
if locations.get("u_valid") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + VALID_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glUniform1i(locations["u_valid"], VALID_TEXTURE_UNIT)
|
||||
if locations.get("u_inv_domain_size") is not None:
|
||||
height, width = self._field_shape or (1, 1)
|
||||
GL.glUniform2f(
|
||||
locations["u_inv_domain_size"],
|
||||
1.0 / max(float(width), 1.0),
|
||||
1.0 / max(float(height), 1.0),
|
||||
)
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||
|
||||
@property
|
||||
def field_shape(self) -> tuple[int, int] | None:
|
||||
"""The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
|
||||
return self._field_shape
|
||||
|
||||
@property
|
||||
def field_internal_format(self) -> int:
|
||||
"""The internal format the scalar texture actually got (``R16F`` or ``R32F``)."""
|
||||
return self._field_internal_format
|
||||
|
||||
def release(self) -> None:
|
||||
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
||||
from OpenGL import GL
|
||||
@@ -858,6 +1102,7 @@ class GLRenderer:
|
||||
pass
|
||||
self._lut = 0
|
||||
self._lut_id = ""
|
||||
self._release_field_textures()
|
||||
self._initialized = False
|
||||
self._estimated_gpu_bytes = 0
|
||||
self._scalar_range = None
|
||||
@@ -865,6 +1110,20 @@ class GLRenderer:
|
||||
self._last_scale_mode = ""
|
||||
self._paint_calls = 0
|
||||
|
||||
def _release_field_textures(self) -> None:
|
||||
from OpenGL import GL
|
||||
|
||||
for name in ("_field_texture", "_valid_texture"):
|
||||
handle = int(getattr(self, name))
|
||||
if handle:
|
||||
try:
|
||||
GL.glDeleteTextures([handle])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
setattr(self, name, 0)
|
||||
self._field_shape = None
|
||||
self._field_internal_format = 0
|
||||
|
||||
def release_parts(self) -> None:
|
||||
from OpenGL import GL
|
||||
|
||||
@@ -890,7 +1149,7 @@ class GLRenderer:
|
||||
if self._lut:
|
||||
GL.glDeleteTextures([int(self._lut)])
|
||||
self._lut = 0
|
||||
data = lut_rgba(lut_id)
|
||||
data = lut_rgba(lut_id, self._lut_ramp)
|
||||
self._lut = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||
@@ -904,7 +1163,7 @@ class GLRenderer:
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._lut_id = lut_id
|
||||
|
||||
def capabilities(self) -> Optional[GLCapabilities]:
|
||||
def capabilities(self) -> GLCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
@property
|
||||
@@ -916,17 +1175,6 @@ class GLRenderer:
|
||||
return self._initialized
|
||||
|
||||
|
||||
class _PartLayout:
|
||||
"""A minimal stand-in for :class:`MeshPart` when updating an existing buffer."""
|
||||
|
||||
__slots__ = ("part_id", "positions_sensor_m", "normals_sensor")
|
||||
|
||||
def __init__(self, part_id: str, positions: np.ndarray, normals: np.ndarray) -> None:
|
||||
self.part_id = part_id
|
||||
self.positions_sensor_m = positions
|
||||
self.normals_sensor = normals
|
||||
|
||||
|
||||
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
|
||||
#: it never competes with the measurement colour scale.
|
||||
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
|
||||
@@ -939,15 +1187,23 @@ def _base_color(part_id: str) -> tuple[float, float, float, float]:
|
||||
|
||||
|
||||
def _bind_attributes(GL: Any) -> None:
|
||||
"""Position, normal and the UV the field texture is addressed by.
|
||||
|
||||
Attribute locations 2 and 3 are deliberately left unbound: they were the
|
||||
per-vertex scalar and validity flags, and nothing in the surface program declares
|
||||
them any more. Re-using 2 or 3 for something else later would be a silent
|
||||
reinterpretation of a slot an old shader variant could still expect, so the UV
|
||||
goes to 4 instead.
|
||||
"""
|
||||
stride = STRIDE_FLOATS * 4
|
||||
GL.glEnableVertexAttribArray(0)
|
||||
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
||||
GL.glEnableVertexAttribArray(1)
|
||||
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
||||
GL.glEnableVertexAttribArray(2)
|
||||
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
|
||||
GL.glEnableVertexAttribArray(3)
|
||||
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
|
||||
GL.glEnableVertexAttribArray(4)
|
||||
GL.glVertexAttribPointer(
|
||||
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
|
||||
)
|
||||
|
||||
|
||||
def _offset(floats: int) -> Any:
|
||||
@@ -985,8 +1241,9 @@ def _compile_program(GL: Any, profile: Profile, name: str) -> int:
|
||||
|
||||
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
|
||||
names = (
|
||||
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
|
||||
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
|
||||
"u_view", "u_projection", "u_lower", "u_upper",
|
||||
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
|
||||
"u_light_direction", "u_base_color",
|
||||
)
|
||||
out: dict[str, int] = {}
|
||||
for name in names:
|
||||
@@ -1002,7 +1259,7 @@ def _decode(value: Any) -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
|
||||
def _set_mat4(GL: Any, location: int | None, values: np.ndarray) -> None:
|
||||
if location is None:
|
||||
return
|
||||
# Column-major upload, explicitly: the layout of the NumPy array must not
|
||||
@@ -1010,17 +1267,17 @@ def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
|
||||
GL.glUniformMatrix4fv(location, 1, GL.GL_FALSE, np.ascontiguousarray(values.T))
|
||||
|
||||
|
||||
def _set_float(GL: Any, location: Optional[int], value: float) -> None:
|
||||
def _set_float(GL: Any, location: int | None, value: float) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform1f(location, float(value))
|
||||
|
||||
|
||||
def _set_vec3(GL: Any, location: Optional[int], values: tuple[float, float, float]) -> None:
|
||||
def _set_vec3(GL: Any, location: int | None, values: tuple[float, float, float]) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2]))
|
||||
|
||||
|
||||
def _set_vec4(GL: Any, location: Optional[int], values: tuple[float, float, float, float]) -> None:
|
||||
def _set_vec4(GL: Any, location: int | None, values: tuple[float, float, float, float]) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform4f(
|
||||
location, float(values[0]), float(values[1]), float(values[2]), float(values[3])
|
||||
|
||||
@@ -16,9 +16,10 @@ paths and booleans so a recording can say how a package was authenticated.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
|
||||
|
||||
@@ -36,7 +37,7 @@ PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
|
||||
class SceneKeys:
|
||||
"""What the loader needs to authenticate a package — and how strict to be."""
|
||||
|
||||
verify_key_pem: Optional[bytes] = None
|
||||
verify_key_pem: bytes | None = None
|
||||
provider: Any = None
|
||||
require_signature: bool = False
|
||||
#: Paths/ids only, for logs and for the recording: never material.
|
||||
@@ -59,7 +60,7 @@ class SceneKeys:
|
||||
}
|
||||
|
||||
|
||||
def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
def resolve_scene_keys(prefs: Mapping[str, Any] | None) -> SceneKeys:
|
||||
"""Build the key material the preferences describe. Never raises for absence.
|
||||
|
||||
A missing or unreadable file is a *diagnostic-bearing state*, not a crash: the 2D
|
||||
@@ -68,7 +69,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
"""
|
||||
prefs = dict(prefs or {})
|
||||
sources: dict[str, str] = {}
|
||||
verify_pem: Optional[bytes] = None
|
||||
verify_pem: bytes | None = None
|
||||
provider = None
|
||||
|
||||
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip()
|
||||
@@ -113,7 +114,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
)
|
||||
|
||||
|
||||
def check_release_requirement(path: Path, keys: Optional[SceneKeys]) -> Optional[Diagnostic]:
|
||||
def check_release_requirement(path: Path, keys: SceneKeys | None) -> Diagnostic | None:
|
||||
"""Refuse an unsigned package when the application is in release mode.
|
||||
|
||||
Development builds accept a plain package (the shipped engineering one is exactly
|
||||
|
||||
@@ -18,10 +18,20 @@ Two rules it exists to enforce in the interface:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
SceneCapabilities,
|
||||
SceneState,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -38,18 +48,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
SceneCapabilities,
|
||||
SceneState,
|
||||
)
|
||||
from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD
|
||||
from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
||||
@@ -82,7 +81,7 @@ class ColorScaleBar(QLabel):
|
||||
because in automatic mode the spin boxes beside it held values nobody was using.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("colorScaleBar")
|
||||
self.setFixedHeight(12)
|
||||
@@ -90,6 +89,9 @@ class ColorScaleBar(QLabel):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.setVisible(False)
|
||||
self._lut_id = "sequential"
|
||||
#: The theme's own ramp, so this legend is drawn from the same table the 2D
|
||||
#: canvas and the 3D surface are coloured by.
|
||||
self._ramp = ""
|
||||
|
||||
def set_lut(self, lut_id: str) -> None:
|
||||
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
||||
@@ -98,6 +100,13 @@ class ColorScaleBar(QLabel):
|
||||
self._lut_id = wanted
|
||||
self.update()
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
wanted = str(ramp or "")
|
||||
if wanted == self._ramp:
|
||||
return
|
||||
self._ramp = wanted
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
from PySide6.QtGui import QPainter
|
||||
|
||||
@@ -105,7 +114,9 @@ class ColorScaleBar(QLabel):
|
||||
|
||||
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||
# which is what QImage's borrowed buffer needs.
|
||||
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
|
||||
rgba = np.ascontiguousarray(
|
||||
lut_rgba(self._lut_id, self._ramp)[:, 0, :]
|
||||
).tobytes()
|
||||
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
|
||||
painter = QPainter(self)
|
||||
painter.drawImage(self.rect(), image)
|
||||
@@ -124,23 +135,23 @@ class ThreeDPanel(QFrame):
|
||||
self,
|
||||
coordinator: ThreeDCoordinator,
|
||||
widget: Sensor3DWidget,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._coordinator = coordinator
|
||||
self._widget = widget
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._descriptor: Optional[FieldDescriptor] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._descriptor: FieldDescriptor | None = None
|
||||
self._state: SceneState = "disabled"
|
||||
self._source_kind = "live"
|
||||
self._syncing = False
|
||||
#: What the data source can provide (see ``contracts.SceneCapabilities``): an
|
||||
#: unavailable capability disables its control and says why, rather than
|
||||
#: letting the operator operate an input that does not exist.
|
||||
self._capabilities: Optional[SceneCapabilities] = None
|
||||
self._capabilities: SceneCapabilities | None = None
|
||||
#: The bounds the last draw actually used (the widget's RenderStats).
|
||||
self._effective_scale: Optional[RenderStats] = None
|
||||
self._effective_scale: RenderStats | None = None
|
||||
#: Whether the pane explains the pipeline around the picture (the window's
|
||||
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
||||
self._show_diagnostics = False
|
||||
@@ -180,8 +191,9 @@ class ThreeDPanel(QFrame):
|
||||
|
||||
self.housing_check = QCheckBox()
|
||||
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
|
||||
# starting state and the settings it reports must be the same thing.
|
||||
self.housing_check.setChecked(True)
|
||||
# starting state and the settings it reports must be the same thing. That default is
|
||||
# now unchecked — the housing is a mechanical check, not the measurement.
|
||||
self.housing_check.setChecked(False)
|
||||
self.housing_check.toggled.connect(self._emit_settings)
|
||||
form.addRow(self.housing_check)
|
||||
|
||||
@@ -342,7 +354,7 @@ class ThreeDPanel(QFrame):
|
||||
self.set_state("disabled")
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
def set_scene(self, assets: Optional[SceneAssets]) -> None:
|
||||
def set_scene(self, assets: SceneAssets | None) -> None:
|
||||
"""Fill the preset list from the asset: its default view is the asset's."""
|
||||
self._assets = assets
|
||||
self._syncing = True
|
||||
@@ -364,7 +376,7 @@ class ThreeDPanel(QFrame):
|
||||
self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
|
||||
self.reset_button.setEnabled(assets is not None)
|
||||
|
||||
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
|
||||
def set_descriptor(self, descriptor: FieldDescriptor | None) -> None:
|
||||
"""The measurement currently on screen — or none, which hides the bar."""
|
||||
if descriptor != self._descriptor:
|
||||
self._effective_scale = None
|
||||
@@ -392,7 +404,19 @@ class ThreeDPanel(QFrame):
|
||||
self._render_scale_bounds()
|
||||
self._render_legend()
|
||||
|
||||
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Adopt the theme's resolved colour ramp for the legend.
|
||||
|
||||
The legend and the picture are drawn from one table
|
||||
(``gl_backend.lut_rgba``), and that table is built from this ramp — the same
|
||||
string the 2D canvas colours its pixels from. Without this the 3D pane's
|
||||
legend would keep the default theme's colours after a theme switch while the
|
||||
surface (which gets the ramp on the frame it is handed) changed, which is the
|
||||
disagreement the one-ramp change exists to remove.
|
||||
"""
|
||||
self.scale_bar.set_ramp(ramp)
|
||||
|
||||
def set_capabilities(self, capabilities: SceneCapabilities | None) -> None:
|
||||
"""Offer only the controls this data source can actually feed.
|
||||
|
||||
Today's adapters colour a scalar in place and publish no displacement at all, so
|
||||
@@ -426,7 +450,7 @@ class ThreeDPanel(QFrame):
|
||||
else t("scene3d.contact_arrow_tip")
|
||||
)
|
||||
|
||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
||||
def capabilities(self) -> SceneCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
def _render_legend(self) -> None:
|
||||
@@ -526,7 +550,7 @@ class ThreeDPanel(QFrame):
|
||||
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
|
||||
self.source_label.setVisible(self._show_diagnostics)
|
||||
|
||||
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
|
||||
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None:
|
||||
"""One diagnostic line. The 2D views stay usable behind it.
|
||||
|
||||
Warnings and errors are always stated: they are claims about the *measurement*
|
||||
|
||||
@@ -19,13 +19,15 @@ file cannot relabel a measurement as another quantity.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Optional
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
ColorScale,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
)
|
||||
|
||||
#: Frame rates the pane offers. A saved value outside this set is repaired to the
|
||||
@@ -51,7 +53,7 @@ def _number(value: Any, fallback: float) -> float:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if number != number or number in (float("inf"), float("-inf")): # NaN / inf
|
||||
if math.isnan(number) or number in (float("inf"), float("-inf")): # NaN / inf
|
||||
return fallback
|
||||
return number
|
||||
|
||||
@@ -102,9 +104,9 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
|
||||
|
||||
|
||||
def settings_from_prefs(
|
||||
raw: Optional[Mapping[str, Any]],
|
||||
raw: Mapping[str, Any] | None,
|
||||
*,
|
||||
descriptor: Optional[FieldDescriptor] = None,
|
||||
descriptor: FieldDescriptor | None = None,
|
||||
) -> DisplaySettings:
|
||||
"""Rebuild the display settings, repairing anything unusable.
|
||||
|
||||
@@ -121,7 +123,7 @@ def settings_from_prefs(
|
||||
field = str(raw.get("field") or DEFAULT_FIELD)
|
||||
if field not in FIELD_CHOICES:
|
||||
field = DEFAULT_FIELD
|
||||
scale: Optional[ColorScale] = None
|
||||
scale: ColorScale | None = None
|
||||
saved = raw.get("color_scale")
|
||||
if isinstance(saved, Mapping):
|
||||
mode = str(saved.get("mode") or "fixed")
|
||||
@@ -158,7 +160,7 @@ def settings_from_prefs(
|
||||
display_gain=gain,
|
||||
deform_enabled=_flag(raw.get("deform_enabled"), False),
|
||||
color_scale=scale,
|
||||
housing_visible=_flag(raw.get("housing_visible"), True),
|
||||
housing_visible=_flag(raw.get("housing_visible"), False),
|
||||
max_fps=max_fps,
|
||||
field=field,
|
||||
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
|
||||
|
||||
@@ -14,8 +14,9 @@ widgets — and one set of signals — whether or not the dialog has ever been o
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -25,8 +26,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
||||
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||
|
||||
@@ -35,7 +34,7 @@ class Scene3dSettingsDialog(QDialog):
|
||||
"""A modeless home for the 3D pane's controls."""
|
||||
|
||||
def __init__(
|
||||
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
|
||||
self, panel: ThreeDPanel, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("scene3dSettings")
|
||||
|
||||
@@ -25,11 +25,9 @@ description disagrees with the asset is refused with both shapes named.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
@@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
diagnostic,
|
||||
validate_assets,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
@@ -90,17 +91,24 @@ class Scene3DView(QWidget):
|
||||
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._available, self._reason = gl_available()
|
||||
self._widget: Optional[Sensor3DWidget] = None
|
||||
self._panel: Optional[ThreeDPanel] = None
|
||||
self._coordinator: Optional[ThreeDCoordinator] = None
|
||||
self._widget: Sensor3DWidget | None = None
|
||||
self._panel: ThreeDPanel | None = None
|
||||
self._coordinator: ThreeDCoordinator | None = None
|
||||
self._camera = OrbitCamera()
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._composer: SceneComposer | None = None
|
||||
#: The session's island latch, shared with the 2D canvas when the window hands
|
||||
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
|
||||
#: surface's validity texture and the canvas's island are the same mask, so
|
||||
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
||||
#: each frame's own mask, which is what the tests and the PoC want.
|
||||
self._domain_latch: Any = None
|
||||
self._expected: ExpectedGeometry | None = None
|
||||
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
|
||||
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
||||
#: and (for a source that cannot provide a displacement) what the pane may
|
||||
#: offer. Kept rather than closed over so those answers can be asked.
|
||||
@@ -108,11 +116,11 @@ class Scene3DView(QWidget):
|
||||
self._source_id = ""
|
||||
self._enabled = False
|
||||
self._source_kind = "live"
|
||||
self._last_diagnostic: Optional[Diagnostic] = None
|
||||
self._last_descriptor: Optional[FieldDescriptor] = None
|
||||
self._last_stats: Optional[RenderStats] = None
|
||||
self._last_diagnostic: Diagnostic | None = None
|
||||
self._last_descriptor: FieldDescriptor | None = None
|
||||
self._last_stats: RenderStats | None = None
|
||||
#: The coordinator's last diagnostic as *this* view has already reported it.
|
||||
self._reported_diagnostic: Optional[Diagnostic] = None
|
||||
self._reported_diagnostic: Diagnostic | None = None
|
||||
#: Whether the loaded asset was compared against the device's own description.
|
||||
self._device_verified = False
|
||||
#: Frames the producer did not even build (a frame was already waiting).
|
||||
@@ -136,8 +144,9 @@ class Scene3DView(QWidget):
|
||||
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
|
||||
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
||||
self._composer = SceneComposer()
|
||||
self._coordinator = ThreeDCoordinator(
|
||||
self._widget, SceneComposer(), self._executor, max_fps=30
|
||||
self._widget, self._composer, self._executor, max_fps=30
|
||||
)
|
||||
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
||||
# The picture must not be squeezed out of existence by the controls above it:
|
||||
@@ -165,7 +174,7 @@ class Scene3DView(QWidget):
|
||||
self,
|
||||
assets: SceneAssets,
|
||||
source_id: str,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
) -> bool:
|
||||
"""Adopt a validated scene. Returns whether the view accepted it.
|
||||
|
||||
@@ -234,8 +243,8 @@ class Scene3DView(QWidget):
|
||||
return not violations
|
||||
|
||||
def _make_snapshot(
|
||||
self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
|
||||
) -> Optional[Callable[[Any, FrameIdentity], Any]]:
|
||||
self, assets: SceneAssets, expected: ExpectedGeometry | None
|
||||
) -> Callable[[Any, FrameIdentity], Any] | None:
|
||||
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
|
||||
|
||||
The two adapters have different call signatures because they need different
|
||||
@@ -253,8 +262,7 @@ class Scene3DView(QWidget):
|
||||
asset_id = str(getattr(assets, "asset_id", "") or "")
|
||||
self._adapter = None
|
||||
device = expected
|
||||
common = {
|
||||
"geometry_id": str(
|
||||
common = { "geometry_id": str(
|
||||
device.geometry_id if device is not None and device.geometry_id
|
||||
else assets.geometry_id
|
||||
),
|
||||
@@ -285,9 +293,44 @@ class Scene3DView(QWidget):
|
||||
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
|
||||
# passed here. Remapping it again would index the flow at source pixels.
|
||||
adapter = FingerFrameAdapter(**common)
|
||||
if self._domain_latch is not None:
|
||||
# The window's own latch, so the island the 2D canvas paints and the island
|
||||
# the surface's validity texture carries are one decision, taken once.
|
||||
adapter.domain_latch = self._domain_latch
|
||||
self._adapter = adapter
|
||||
return lambda packet, identity: adapter.snapshot(packet, None, identity)
|
||||
|
||||
def set_domain_latch(self, latch: Any) -> None:
|
||||
"""Share the 2D canvas's island latch with this pane's adapter.
|
||||
|
||||
``image_backend.DomainMaskLatch`` is *upstream of both views*: the canvas paints
|
||||
the latched boundary and the adapter marks exactly that boundary valid, so
|
||||
"which pixels carry a measurement" is one answer rather than two that happen to
|
||||
agree. Called before the scene is resolved in practice (the window builds the
|
||||
latch first); re-binding an already-built adapter updates it in place.
|
||||
"""
|
||||
self._domain_latch = latch
|
||||
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
|
||||
self._adapter.domain_latch = latch
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Adopt the application theme's colour ramp.
|
||||
|
||||
The 2D canvas colours its pixels from ``arrows.flow_colormap(theme)``; the 3D
|
||||
surface is coloured from whatever ramp the field it draws carries. Setting both
|
||||
from this one call is what makes them the *same* table rather than two tables
|
||||
that were written to look alike.
|
||||
"""
|
||||
from orisys_app_shared.acquisition.arrows import ramp_for_theme
|
||||
|
||||
ramp = ramp_for_theme(theme)
|
||||
if self._composer is not None:
|
||||
self._composer.ramp = ramp
|
||||
if self._panel is not None:
|
||||
self._panel.set_ramp(ramp)
|
||||
if self._coordinator is not None:
|
||||
self._coordinator.set_ramp(ramp)
|
||||
|
||||
# --------------------------------------------------------------- producer
|
||||
def submit_packet(self, packet: Any) -> None:
|
||||
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
|
||||
@@ -316,12 +359,12 @@ class Scene3DView(QWidget):
|
||||
except Exception: # noqa: BLE001 - a display sink must never break capture
|
||||
logger.exception("the 3D sink failed for one packet")
|
||||
|
||||
def _snapshot_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def _snapshot_diagnostic(self) -> Diagnostic | None:
|
||||
"""Why the adapter refused the last packet (the adapter owns the answer)."""
|
||||
item = getattr(self._adapter, "last_diagnostic", None)
|
||||
return item if isinstance(item, Diagnostic) else None
|
||||
|
||||
def identity_for(self, packet: Any) -> Optional[FrameIdentity]:
|
||||
def identity_for(self, packet: Any) -> FrameIdentity | None:
|
||||
"""The identity a field frame built from this packet would carry."""
|
||||
if self._coordinator is None or self._assets is None:
|
||||
return None
|
||||
@@ -406,7 +449,7 @@ class Scene3DView(QWidget):
|
||||
self._panel.set_source_kind(kind)
|
||||
self._publish_capabilities()
|
||||
|
||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
||||
def capabilities(self) -> SceneCapabilities | None:
|
||||
"""What this data source can provide, for the panel's controls.
|
||||
|
||||
Read off the adapter that is actually bound: an adapter that colours a scalar in
|
||||
@@ -496,27 +539,27 @@ class Scene3DView(QWidget):
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def assets(self) -> Optional[SceneAssets]:
|
||||
def assets(self) -> SceneAssets | None:
|
||||
return self._assets
|
||||
|
||||
@property
|
||||
def coordinator(self) -> Optional[ThreeDCoordinator]:
|
||||
def coordinator(self) -> ThreeDCoordinator | None:
|
||||
return self._coordinator
|
||||
|
||||
@property
|
||||
def gl_widget(self) -> Optional[Sensor3DWidget]:
|
||||
def gl_widget(self) -> Sensor3DWidget | None:
|
||||
return self._widget
|
||||
|
||||
@property
|
||||
def panel(self) -> Optional[ThreeDPanel]:
|
||||
def panel(self) -> ThreeDPanel | None:
|
||||
return self._panel
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._last_diagnostic
|
||||
|
||||
@property
|
||||
def last_descriptor(self) -> Optional[FieldDescriptor]:
|
||||
def last_descriptor(self) -> FieldDescriptor | None:
|
||||
return self._last_descriptor
|
||||
|
||||
def state(self) -> SceneState:
|
||||
@@ -526,7 +569,7 @@ class Scene3DView(QWidget):
|
||||
return "disabled"
|
||||
return self._coordinator.state
|
||||
|
||||
def render_stats(self) -> Optional[RenderStats]:
|
||||
def render_stats(self) -> RenderStats | None:
|
||||
return self._last_stats
|
||||
|
||||
def describe(self) -> dict:
|
||||
@@ -709,7 +752,7 @@ class Scene3DView(QWidget):
|
||||
self._panel.set_state(state)
|
||||
self.stateChanged.emit(state)
|
||||
|
||||
def _publish_coordinator_diagnostic(self, item: Optional[Diagnostic]) -> None:
|
||||
def _publish_coordinator_diagnostic(self, item: Diagnostic | None) -> None:
|
||||
"""Raise a coordinator diagnostic once, when it changes."""
|
||||
if item is None or item == self._reported_diagnostic:
|
||||
return
|
||||
|
||||
@@ -17,23 +17,24 @@ two-dimensional application carries on.
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FrameIdentity,
|
||||
GLCapabilities,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
|
||||
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
GLDispatch,
|
||||
@@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
self,
|
||||
renderer: GLRenderer,
|
||||
camera: OrbitCamera,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
dispatch: Optional[GLDispatch] = None,
|
||||
dispatch: GLDispatch | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._renderer = renderer
|
||||
self._camera = camera
|
||||
self._dispatch = dispatch or GLDispatch()
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._frame: Optional[RenderFrame] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._frame: RenderFrame | None = None
|
||||
self._settings = DisplaySettings()
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._diagnostic: Optional[Diagnostic] = None
|
||||
self._capabilities: GLCapabilities | None = None
|
||||
self._diagnostic: Diagnostic | None = None
|
||||
self._needs_scene_upload = False
|
||||
self._needs_surface_upload = False
|
||||
self._last_stats_at = 0.0
|
||||
#: What reached the screen, counted by identity, and what it cost to compose.
|
||||
self._unique_frames = 0
|
||||
self._last_frame_identity: Optional[FrameIdentity] = None
|
||||
self._last_frame_identity: FrameIdentity | None = None
|
||||
self._last_compose_ms = 0.0
|
||||
self._last_press: Optional[tuple[float, float]] = None
|
||||
self._last_press: tuple[float, float] | None = None
|
||||
self._unavailable = False
|
||||
#: Frames the display never showed, set by the coordinator. Never a
|
||||
#: recording figure.
|
||||
@@ -109,7 +110,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
self._last_frame_identity = frame.identity
|
||||
self._unique_frames += 1
|
||||
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
|
||||
self.descriptorChanged.emit(frame.scalar_descriptor)
|
||||
self.descriptorChanged.emit(frame.field.descriptor)
|
||||
self.update()
|
||||
|
||||
def set_settings(self, settings: DisplaySettings) -> None:
|
||||
@@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
def camera(self) -> OrbitCamera:
|
||||
return self._camera
|
||||
|
||||
def capabilities(self) -> Optional[GLCapabilities]:
|
||||
def capabilities(self) -> GLCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
def displayed_frames(self) -> int:
|
||||
@@ -130,7 +131,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
"""What the frame on screen cost to compose, measured where it was composed."""
|
||||
return float(self._last_compose_ms)
|
||||
|
||||
def diagnostic(self) -> Optional[Diagnostic]:
|
||||
def diagnostic(self) -> Diagnostic | None:
|
||||
return self._diagnostic
|
||||
|
||||
@property
|
||||
@@ -216,11 +217,11 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
)
|
||||
)
|
||||
if self._assets is not None:
|
||||
self._renderer.update_surface(
|
||||
self._renderer.update_field(
|
||||
rest_surface(self._assets, frame.identity)
|
||||
)
|
||||
else:
|
||||
self._renderer.update_surface(frame)
|
||||
self._renderer.update_field(frame)
|
||||
stats = self._renderer.draw(
|
||||
self._camera.matrices(self.viewport_pixels()),
|
||||
self._settings,
|
||||
|
||||
@@ -24,11 +24,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
|
||||
from orisys_studio import session_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,7 +59,7 @@ class SessionController(QObject):
|
||||
*,
|
||||
recording: Any,
|
||||
packet_for_3d: Callable[[Any], None],
|
||||
packet_sink: Optional[Callable[[Any], None]] = None,
|
||||
packet_sink: Callable[[Any], None] | None = None,
|
||||
sensor_type: str = "box",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -82,9 +83,9 @@ class SessionController(QObject):
|
||||
self._connect_slow_notified = False
|
||||
self._raw_fallback_notified = False
|
||||
# --- per-session telemetry (read by the rail, the plots and the record) -----
|
||||
self.last_sample_valid: Optional[bool] = None
|
||||
self.latest_fps: Optional[float] = None
|
||||
self.latest_process_ms: Optional[float] = None
|
||||
self.last_sample_valid: bool | None = None
|
||||
self.latest_fps: float | None = None
|
||||
self.latest_process_ms: float | None = None
|
||||
self.latest_capture_fps = 0.0
|
||||
self.latest_capture_dropped = 0
|
||||
#: How many readouts failed this session, for the status line's count.
|
||||
@@ -380,7 +381,7 @@ class SessionController(QObject):
|
||||
return consumer() if callable(consumer) else None
|
||||
|
||||
# ------------------------------------------------------------- presenter help
|
||||
def check_capture_negotiation(self) -> Optional[str]:
|
||||
def check_capture_negotiation(self) -> str | None:
|
||||
"""A once-per-session warning when the camera delivers less than configured.
|
||||
|
||||
The evidence is the *measured* capture rate, not the driver's reported one:
|
||||
@@ -429,7 +430,7 @@ class SessionController(QObject):
|
||||
"""The status line for a failed readout (the phase is deliberately untouched)."""
|
||||
return t("status.sample_invalid", count=self.record_error_count, reason=reason)
|
||||
|
||||
def take_dropped_message(self, dropped: int) -> Optional[str]:
|
||||
def take_dropped_message(self, dropped: int) -> str | None:
|
||||
"""One message per new batch of samples the memory bound discarded."""
|
||||
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
|
||||
return None
|
||||
|
||||
@@ -25,9 +25,10 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
@@ -198,7 +199,8 @@ class SessionFacts:
|
||||
|
||||
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
|
||||
"""The ``session.json`` metadata written when a recording starts."""
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
|
||||
from orisys_studio import __version__ as app_version
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
|
||||
from orisys_studio import __version__ as app_version
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,14 @@ where the picture comes from (live / replay), which words the footer.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
ImagePlan,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -23,15 +28,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
ImagePlan,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
||||
@@ -340,7 +336,7 @@ class ArrowPanel(QFrame):
|
||||
self._placeholder = self._empty_message()
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def set_plan(self, plan: Optional[ImagePlan]) -> None:
|
||||
def set_plan(self, plan: ImagePlan | None) -> None:
|
||||
"""Adopt what the frame on screen can be shown as.
|
||||
|
||||
The modes come from the frame's own fields (see ``image_backend.plan_image``),
|
||||
|
||||
@@ -14,13 +14,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
ChannelKey,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -28,19 +34,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
ChannelKey,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
session_filename,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ the three-panel layout, while a single point can still be examined in full.
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -17,10 +18,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import NA
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc: # pragma: no cover
|
||||
|
||||
@@ -5,7 +5,19 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
||||
from orisys_app_shared.force.presentation import (
|
||||
DEFAULT_MIN_DEADBAND_N,
|
||||
DEFAULT_MIN_DEADBAND_RAW,
|
||||
ForcePresentation,
|
||||
)
|
||||
from orisys_app_shared.force.source import StoreCurveSource
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
@@ -15,14 +27,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
||||
from orisys_app_shared.force.presentation import ForcePresentation
|
||||
from orisys_app_shared.force.source import StoreCurveSource
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui import icons
|
||||
from orisys_studio.ui.icons import ICON_SIZE
|
||||
|
||||
@@ -65,10 +69,33 @@ def _window_brush(theme: str):
|
||||
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||
"""Evenly spaced Y ticks that always carry a zero label when zero is inside.
|
||||
|
||||
The zero label is what makes "not pressing" readable: on an axis whose range
|
||||
starts below zero the resting curve has no reference point without it.
|
||||
"""
|
||||
if hi <= lo:
|
||||
return [(lo, f"{lo:g}")]
|
||||
step = (hi - lo) / max(1, count - 1)
|
||||
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
|
||||
ticks = [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
|
||||
if lo <= 0.0 <= hi and all(abs(value) > abs(step) * 1e-6 for value, _ in ticks):
|
||||
ticks.append((0.0, "0"))
|
||||
ticks.sort(key=lambda tick: tick[0])
|
||||
return ticks
|
||||
|
||||
|
||||
def _ordered_range(lower: float, upper: float) -> tuple[float, float]:
|
||||
"""A usable ``(ymin, ymax)``: an empty or inverted range is widened upward.
|
||||
|
||||
Nothing stops an operator from asking for FN 5…2 N in the dialog, and an
|
||||
inverted range collapses the axis in pyqtgraph. Widening keeps the chart
|
||||
readable while the dialog keeps showing what was asked for.
|
||||
"""
|
||||
lower = float(lower)
|
||||
upper = float(upper)
|
||||
if upper <= lower:
|
||||
upper = lower + max(0.1, abs(lower) * 0.05)
|
||||
return lower, upper
|
||||
|
||||
|
||||
def format_force(
|
||||
@@ -102,11 +129,11 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
|
||||
class _Sync:
|
||||
"""Marks a block of range changes as programmatic (not a user gesture)."""
|
||||
|
||||
def __init__(self, panel: "ForcePlotsPanel") -> None:
|
||||
def __init__(self, panel: ForcePlotsPanel) -> None:
|
||||
self._panel = panel
|
||||
self._previous = panel._syncing
|
||||
|
||||
def __enter__(self) -> "_Sync":
|
||||
def __enter__(self) -> _Sync:
|
||||
self._panel._syncing = True
|
||||
return self
|
||||
|
||||
@@ -133,8 +160,12 @@ class ForcePlotsPanel(QFrame):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = theme
|
||||
# One range per channel, per unit. FN is asymmetric and its lower bound
|
||||
# may be negative; FX/FY take one absolute magnitude each.
|
||||
self._fn_min = 0.0
|
||||
self._fn_max = 20.0
|
||||
self._shear_max = 20.0
|
||||
self._fn_raw_min = 0.0
|
||||
self._fn_raw_max = 1000000.0
|
||||
self._shear_raw_max = 500000.0
|
||||
self._fixed_newton = True
|
||||
@@ -142,8 +173,13 @@ class ForcePlotsPanel(QFrame):
|
||||
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: Per axis: does ``_last_values`` hold a real value in the active unit?
|
||||
self._available: tuple[bool, bool, bool] = (True, True, True)
|
||||
#: Display-only baseline / deadband for Finger raw value cards.
|
||||
#: Display-only baseline / deadband for the value cards *and* the curves.
|
||||
self.presentation = ForcePresentation(enabled=False)
|
||||
#: The operator's master switch for that baseline. It is owned by
|
||||
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
|
||||
#: caller that does not know it cannot switch the aid off (see
|
||||
#: ``configure_presentation``).
|
||||
self._presentation_enabled = False
|
||||
self._sensor_type = "box"
|
||||
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
|
||||
@@ -261,6 +297,10 @@ class ForcePlotsPanel(QFrame):
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
self.cursors = []
|
||||
#: Horizontal line at y = 0, one per channel: the origin the axis labels
|
||||
#: alone could not give, and what keeps the idle FN curve off the subplot
|
||||
#: border it shares with FX.
|
||||
self.zero_lines = []
|
||||
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
|
||||
for i, _name in enumerate(CHANNEL_LABELS):
|
||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||
@@ -283,6 +323,14 @@ class ForcePlotsPanel(QFrame):
|
||||
)
|
||||
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
|
||||
plot.addItem(cursor, ignoreBounds=True)
|
||||
zero_line = pg.InfiniteLine(
|
||||
pos=0.0,
|
||||
angle=0,
|
||||
movable=False,
|
||||
pen=pg.mkPen(color(theme, "text_muted"), width=1),
|
||||
)
|
||||
zero_line.setZValue(-10)
|
||||
plot.addItem(zero_line, ignoreBounds=True)
|
||||
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
|
||||
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
|
||||
# also fires when a resize propagates through the X links, which is
|
||||
@@ -291,6 +339,7 @@ class ForcePlotsPanel(QFrame):
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
self.cursors.append(cursor)
|
||||
self.zero_lines.append(zero_line)
|
||||
|
||||
# Whole-session overview: the full envelope plus the current window.
|
||||
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
|
||||
@@ -441,6 +490,8 @@ class ForcePlotsPanel(QFrame):
|
||||
self.overview_region.setBrush(_window_brush(theme))
|
||||
for cursor in (*self.cursors, self.overview_cursor):
|
||||
cursor.setPen(pg.mkPen(muted, width=1))
|
||||
for zero_line in self.zero_lines:
|
||||
zero_line.setPen(pg.mkPen(muted, width=1))
|
||||
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
|
||||
for curve, pen in zip(self.curves, pens):
|
||||
curve.setPen(pg.mkPen(pen, width=2))
|
||||
@@ -473,31 +524,77 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
def set_axis_limits(
|
||||
self,
|
||||
*,
|
||||
fn_min: float,
|
||||
fn_max: float,
|
||||
shear_max: float,
|
||||
fn_raw_max: float | None = None,
|
||||
shear_raw_max: float | None = None,
|
||||
fn_raw_min: float,
|
||||
fn_raw_max: float,
|
||||
shear_raw_max: float,
|
||||
) -> None:
|
||||
"""The Y range of every channel, per display unit.
|
||||
|
||||
FN is asymmetric — ``fn_min`` may be negative, which is what gives the
|
||||
axis a visible origin below the resting curve — while FX/FY each take one
|
||||
absolute magnitude and are drawn symmetrically around zero.
|
||||
"""
|
||||
self._fn_min = float(fn_min)
|
||||
self._fn_max = max(0.1, float(fn_max))
|
||||
self._shear_max = max(0.1, float(shear_max))
|
||||
if fn_raw_max is not None:
|
||||
self._fn_raw_max = max(0.0, float(fn_raw_max))
|
||||
if shear_raw_max is not None:
|
||||
self._shear_raw_max = max(0.0, float(shear_raw_max))
|
||||
self._fn_raw_min = float(fn_raw_min)
|
||||
self._fn_raw_max = max(0.0, float(fn_raw_max))
|
||||
self._shear_raw_max = max(0.0, float(shear_raw_max))
|
||||
self._apply_axis_mode()
|
||||
|
||||
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
|
||||
"""The operator's display deadband floors; 0 means "auto (estimated)".
|
||||
|
||||
A floor, not a replacement: the estimate from the quiet window still
|
||||
applies underneath, so this is how an operator widens the zero zone (or
|
||||
pins one when the calibration data is suspect) without re-fitting.
|
||||
"""
|
||||
if newton is not None:
|
||||
value = max(0.0, float(newton))
|
||||
self.presentation.set_min_deadband(
|
||||
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
|
||||
)
|
||||
if raw is not None:
|
||||
value = max(0.0, float(raw))
|
||||
self.presentation.set_min_deadband(
|
||||
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
|
||||
)
|
||||
self._render_value_cards()
|
||||
|
||||
def set_presentation_enabled(self, enabled: bool) -> None:
|
||||
"""The operator's master switch for the display baseline and deadband."""
|
||||
self._presentation_enabled = bool(enabled)
|
||||
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
|
||||
# Switching the aid off must show the measurement again, and switching it
|
||||
# on must show the policy — from the same values the curve is drawn with,
|
||||
# or a card and its own curve would disagree for a frame.
|
||||
self._refresh_display_values()
|
||||
self._render_value_cards()
|
||||
|
||||
def set_fixed_newton(self, enabled: bool) -> None:
|
||||
self._fixed_newton = bool(enabled)
|
||||
self._apply_axis_mode()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
|
||||
|
||||
The display baseline is deliberately *kept*: a view reset is not a
|
||||
statement about the device, and the operator's zero estimate (or its
|
||||
absence) survives it. A new session restarts the estimate through
|
||||
``configure_presentation``.
|
||||
"""
|
||||
self.history.reset()
|
||||
self._clear_curves()
|
||||
self._last_values = (0.0, 0.0, 0.0)
|
||||
self._display_values = (0.0, 0.0, 0.0)
|
||||
self._display_suppressed = (False, False, False)
|
||||
self._available = (True, True, True)
|
||||
self.presentation.reset_session(unit=self._unit)
|
||||
self._unit = "raw"
|
||||
self.presentation.unit = "raw"
|
||||
self._mode = "follow"
|
||||
self._window = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
@@ -519,20 +616,29 @@ class ForcePlotsPanel(QFrame):
|
||||
unit: str,
|
||||
enabled: bool | None = None,
|
||||
) -> None:
|
||||
"""Enable Finger-raw auto baseline for value cards only."""
|
||||
"""Point the display baseline at the sensor and unit now being shown.
|
||||
|
||||
The aid is for every sensor type and both units — an idle channel that
|
||||
reads a raw count or an offset in newtons is a display problem whichever
|
||||
device produced it — so the sensor type only names what is drawn.
|
||||
|
||||
``enabled`` sets the master switch directly when given; the operator's
|
||||
``raw_auto_zero`` preference is what the run-start call site passes.
|
||||
Omitting it leaves the current switch alone.
|
||||
"""
|
||||
self._sensor_type = str(sensor_type or "box")
|
||||
use = (
|
||||
bool(enabled)
|
||||
if enabled is not None
|
||||
else (self._sensor_type == "finger" and unit != "N")
|
||||
)
|
||||
self.presentation.enabled = use
|
||||
if enabled is not None:
|
||||
self._presentation_enabled = bool(enabled)
|
||||
self.presentation.enabled = bool(self._presentation_enabled)
|
||||
self.presentation.reset_session(unit=unit)
|
||||
|
||||
def request_presentation_reestimate(self) -> None:
|
||||
"""Restart the idle baseline window (display cards only)."""
|
||||
"""Restart the idle baseline window (display only: cards and curves)."""
|
||||
self.presentation.request_reestimate()
|
||||
self._display_values = self._last_values
|
||||
# A restarted window starts from nothing, so the cards go back to the
|
||||
# measurement for the couple of samples it takes to re-learn the offset
|
||||
# — derived by the same call the curve uses, never left stale.
|
||||
self._refresh_display_values()
|
||||
self._render_value_cards()
|
||||
|
||||
def observe_for_baseline(
|
||||
@@ -543,8 +649,13 @@ class ForcePlotsPanel(QFrame):
|
||||
valid: bool = True,
|
||||
flow_p95: float | None = None,
|
||||
contact_found: bool = False,
|
||||
unit: str | None = None,
|
||||
) -> None:
|
||||
"""Feed the startup estimator; curves keep the raw measurement."""
|
||||
"""Feed the startup estimator in the unit these values are expressed in.
|
||||
|
||||
The curve and the record keep the raw measurement; the estimate is only
|
||||
ever a display copy.
|
||||
"""
|
||||
if not self.presentation.enabled:
|
||||
return
|
||||
self.presentation.observe(
|
||||
@@ -553,6 +664,7 @@ class ForcePlotsPanel(QFrame):
|
||||
valid=valid,
|
||||
flow_p95=flow_p95,
|
||||
contact_found=contact_found,
|
||||
unit=unit,
|
||||
)
|
||||
|
||||
def push(
|
||||
@@ -596,6 +708,9 @@ class ForcePlotsPanel(QFrame):
|
||||
valid=bool(forces_valid),
|
||||
flow_p95=flow_p95,
|
||||
contact_found=contact_found,
|
||||
# The values are in ``unit``, and the estimate is kept per unit, so
|
||||
# the sample must be filed under the unit it was measured in.
|
||||
unit=unit,
|
||||
)
|
||||
displayed = self.presentation.accept(
|
||||
self._last_values, unit=unit, available=self._available
|
||||
@@ -617,8 +732,9 @@ class ForcePlotsPanel(QFrame):
|
||||
if state is not None:
|
||||
self._syncing = True
|
||||
try:
|
||||
values = self._curve_values(state.values)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, state.values[:, i])
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
finally:
|
||||
self._syncing = False
|
||||
@@ -648,7 +764,10 @@ class ForcePlotsPanel(QFrame):
|
||||
return
|
||||
self._syncing = True
|
||||
try:
|
||||
values = state.values.copy()
|
||||
# ...and draw the *display* copy of it, so a resting channel is a
|
||||
# flat line on zero. ``history`` — and the session store and
|
||||
# forces.csv behind it — keep the measurement.
|
||||
values = self._curve_values(state.values)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
@@ -661,6 +780,7 @@ class ForcePlotsPanel(QFrame):
|
||||
else:
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
self._sync_zero_lines()
|
||||
finally:
|
||||
self._syncing = False
|
||||
# The buffer already carries session-relative time, so the cursor is the
|
||||
@@ -686,6 +806,9 @@ class ForcePlotsPanel(QFrame):
|
||||
if unit == self._unit:
|
||||
return
|
||||
self._unit = unit
|
||||
# The display policy keeps one estimate per unit, so it has to know which
|
||||
# one is on screen: a stale "raw" is ready does not mean newtons are.
|
||||
self.presentation.unit = unit
|
||||
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
|
||||
# is in the same session-relative base, so the X axis must not restart.
|
||||
self.history.clear()
|
||||
@@ -696,6 +819,17 @@ class ForcePlotsPanel(QFrame):
|
||||
self.refresh_from_store()
|
||||
else:
|
||||
self._reload_from_store()
|
||||
# ``_last_values`` were measured in the unit being left. The
|
||||
# rebuilt buffer holds the same samples in the unit now being
|
||||
# shown, so its last row is what the cards describe — otherwise
|
||||
# the card would carry the old unit's numbers while its own curve
|
||||
# is drawn from the new one.
|
||||
state = self.history.state()
|
||||
if state is not None and len(state.values):
|
||||
self._last_values = tuple(
|
||||
float(value) for value in state.values[-1]
|
||||
)
|
||||
self._refresh_display_values()
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
# Re-read the point under the cursor in the new unit: while browsing, or
|
||||
@@ -735,6 +869,7 @@ class ForcePlotsPanel(QFrame):
|
||||
scale.
|
||||
"""
|
||||
self._unit = unit
|
||||
self.presentation.unit = unit
|
||||
self.history.clear()
|
||||
self._clear_curves()
|
||||
self._overview_dirty = True
|
||||
@@ -826,9 +961,14 @@ class ForcePlotsPanel(QFrame):
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
else:
|
||||
# The read is of the *measured* values (the session store or the
|
||||
# recording); presenting them here is what keeps a browsed window
|
||||
# looking like the live one — the record itself is untouched.
|
||||
presented = self._curve_values(window.values)
|
||||
mask = plottable_axis_mask(presented, window.valid)
|
||||
for index, curve in enumerate(self.curves):
|
||||
times, values = window.plottable_axis(index)
|
||||
curve.setData(times, values)
|
||||
column = mask[:, index]
|
||||
curve.setData(window.times[column], presented[column, index])
|
||||
self._availability_from_window(window)
|
||||
for plot in self.plots:
|
||||
plot.setXRange(t0, t1, padding=0.0)
|
||||
@@ -913,7 +1053,7 @@ class ForcePlotsPanel(QFrame):
|
||||
self._window = (x0, x1)
|
||||
self.refresh_from_store()
|
||||
|
||||
def _guard(self) -> "_Sync":
|
||||
def _guard(self) -> _Sync:
|
||||
"""Context manager marking range changes as programmatic."""
|
||||
return _Sync(self)
|
||||
|
||||
@@ -962,6 +1102,14 @@ class ForcePlotsPanel(QFrame):
|
||||
self._available = tuple(
|
||||
bool(valid and np.isfinite(value)) for value in values
|
||||
)
|
||||
# The cards describe this point, so they carry the display copy of it —
|
||||
# including while browsing or replaying, where the values did not come
|
||||
# from the live sample the last ``push`` presented.
|
||||
presented, suppressed = self.presentation.present(
|
||||
np.asarray([values], dtype=float), unit=self._unit
|
||||
)
|
||||
self._display_values = tuple(float(value) for value in presented[0])
|
||||
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
|
||||
self._render_value_cards()
|
||||
|
||||
def _update_selection_readout(self) -> None:
|
||||
@@ -1056,6 +1204,10 @@ class ForcePlotsPanel(QFrame):
|
||||
buckets=self._overview_points(), unit=self._unit
|
||||
)
|
||||
values = np.asarray(window.values, dtype=float)
|
||||
# The envelope is drawn from the same store measurement as the window
|
||||
# above, so it goes through the same presentation: the overview thumb
|
||||
# must not show a jittering baseline the main chart has flattened.
|
||||
values = self._curve_values(values)
|
||||
column = values[:, 0]
|
||||
finite = np.isfinite(column)
|
||||
self.overview_curve.setData(window.times[finite], column[finite])
|
||||
@@ -1164,8 +1316,9 @@ class ForcePlotsPanel(QFrame):
|
||||
state = self.history.state()
|
||||
if state is None:
|
||||
return
|
||||
values = self._curve_values(state.values)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, state.values[:, i])
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
|
||||
def _availability_from_window(self, window) -> None:
|
||||
@@ -1194,12 +1347,20 @@ class ForcePlotsPanel(QFrame):
|
||||
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
|
||||
|
||||
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
|
||||
"""The configured Y range of one channel, in the active unit.
|
||||
|
||||
FN's lower bound may be negative — that is the "below zero threshold"
|
||||
the operator asked for, and it is what keeps the resting curve off the
|
||||
frame it shares with FX.
|
||||
"""
|
||||
if self._unit == "N":
|
||||
fn_max, shear_max = self._fn_max, self._shear_max
|
||||
fn_min, fn_max, shear_max = self._fn_min, self._fn_max, self._shear_max
|
||||
else:
|
||||
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
|
||||
fn_min = self._fn_raw_min
|
||||
fn_max = self._fn_raw_max
|
||||
shear_max = self._shear_raw_max
|
||||
if channel_index == 0:
|
||||
return 0.0, fn_max
|
||||
return _ordered_range(fn_min, fn_max)
|
||||
return -shear_max, shear_max
|
||||
|
||||
def _preview_fixed_y(self) -> bool:
|
||||
@@ -1223,6 +1384,49 @@ class ForcePlotsPanel(QFrame):
|
||||
if self._mode == "follow":
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
self._sync_zero_lines()
|
||||
|
||||
def _sync_zero_lines(self) -> None:
|
||||
"""Show the zero line only where zero is inside the visible range.
|
||||
|
||||
pyqtgraph does not clip a layout item to its own view box, so a line at
|
||||
0 painted while the axis sits entirely above it would be drawn across the
|
||||
neighbouring subplot.
|
||||
"""
|
||||
for zero_line, plot in zip(self.zero_lines, self.plots):
|
||||
try:
|
||||
ymin, ymax = plot.getViewBox().viewRange()[1]
|
||||
except Exception: # noqa: BLE001 - a view box mid-construction
|
||||
continue
|
||||
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
|
||||
|
||||
def _curve_values(self, values) -> np.ndarray:
|
||||
"""The values the curves draw: the measurement through the display policy.
|
||||
|
||||
``history``, the session store and ``forces.csv`` keep the measurement;
|
||||
the baseline and deadband are applied here, on the way to the plot only.
|
||||
That is what makes a resting channel a flat line at exactly zero without
|
||||
the record being edited. Browsing and a replay read the store, so they go
|
||||
through this same presentation — the curves show the operator's display
|
||||
policy, the store shows what was measured.
|
||||
"""
|
||||
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
|
||||
return displayed
|
||||
|
||||
def _refresh_display_values(self) -> None:
|
||||
"""Re-derive the card values from the policy the curves are drawn with.
|
||||
|
||||
``push`` fills these from :meth:`ForcePresentation.accept`, whose
|
||||
hysteresis is what keeps a *live* reading steady. Every other path — a
|
||||
re-estimate, the master switch, a unit switch — goes through the same
|
||||
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
|
||||
never state a value its own curve does not carry.
|
||||
"""
|
||||
displayed, suppressed = self.presentation.present(
|
||||
np.asarray([self._last_values], dtype=float), unit=self._unit
|
||||
)
|
||||
self._display_values = tuple(float(value) for value in displayed[0])
|
||||
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
|
||||
|
||||
def _clear_curves(self) -> None:
|
||||
for curve in self.curves:
|
||||
@@ -1252,7 +1456,13 @@ class ForcePlotsPanel(QFrame):
|
||||
peak = float(selecting.peaks[index])
|
||||
available = bool(np.isfinite(peak))
|
||||
value = peak
|
||||
elif self.presentation.enabled and self.presentation.ready:
|
||||
elif self.presentation.enabled:
|
||||
# The policy is *live from the first samples*: there is no
|
||||
# separate "the estimate is ready" gate here. Such a gate is what
|
||||
# let a card read FN 1118 raw while its own curve was already flat
|
||||
# on zero, because the card fell back to the raw measurement and
|
||||
# the curve did not. Both are ``_display_values`` / ``present()``
|
||||
# of the same applied estimate.
|
||||
value = self._display_values[index]
|
||||
if not available:
|
||||
# An axis with no curve in this unit: a dash, never a raw count
|
||||
@@ -1273,7 +1483,6 @@ class ForcePlotsPanel(QFrame):
|
||||
zero_zone = (
|
||||
selecting is None
|
||||
and self.presentation.enabled
|
||||
and self.presentation.ready
|
||||
and abs(float(value)) == 0.0
|
||||
and index < len(self._display_suppressed)
|
||||
and self._display_suppressed[index]
|
||||
|
||||
@@ -14,11 +14,10 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: Names of the icons this module can paint.
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
|
||||
|
||||
|
||||
@@ -13,9 +13,15 @@ acquisition, and no path that reports a 3D problem as a recording problem.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
)
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
@@ -27,13 +33,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
from orisys_studio.scene3d.asset_source import (
|
||||
SceneAssetLoader,
|
||||
SceneSource,
|
||||
@@ -44,6 +43,7 @@ from orisys_studio.ui import icons
|
||||
|
||||
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
||||
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
|
||||
@@ -81,17 +81,17 @@ class Mesh3dPanel(QFrame):
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||
self, parent: QWidget | None = None, *, theme: str = "dark"
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = str(theme)
|
||||
self._view: Optional["Scene3DView"] = None
|
||||
self._loader: Optional[SceneAssetLoader] = None
|
||||
self._source: Optional[SceneSource] = None
|
||||
self._view: Scene3DView | None = None
|
||||
self._loader: SceneAssetLoader | None = None
|
||||
self._source: SceneSource | None = None
|
||||
self._sensor_type = ""
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None
|
||||
self._expected: ExpectedGeometry | None = None
|
||||
self._expected_provider: Callable[[], DeviceGeometry] | None = None
|
||||
self._source_kind = "live"
|
||||
self._enabled = False
|
||||
self._loaded = False
|
||||
@@ -103,14 +103,41 @@ class Mesh3dPanel(QFrame):
|
||||
self._keys = None
|
||||
#: The package whose scene is in the view, so switching sensor away and back
|
||||
#: does not reload it (and does not pretend to be loading it either).
|
||||
self._loaded_path: Optional[Path] = None
|
||||
self._reported: Optional[Diagnostic] = None
|
||||
self._loaded_path: Path | None = None
|
||||
self._reported: Diagnostic | None = None
|
||||
self._pending_settings = None
|
||||
self._message_kind = _UNAVAILABLE
|
||||
self._message_args: dict[str, str] = {"reason": ""}
|
||||
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
||||
#: until the view exists (see ``set_diagnostics_visible``).
|
||||
self._show_diagnostics = False
|
||||
#: The session's island latch, shared with the 2D canvas (see
|
||||
#: ``set_domain_latch``). Held until the view exists.
|
||||
self._domain_latch = None
|
||||
|
||||
# Reserve this top-level window's native handle *now*, before the window is
|
||||
# shown and while no OpenGL widget exists yet.
|
||||
#
|
||||
# The view is built on first use, so on a first run — the pane starts on a box,
|
||||
# which has no 3D scene — the GL widget is created later, while the window is
|
||||
# already on screen. Creating the first *native* child of a visible window makes
|
||||
# Qt re-create the window, and Qt does that by hiding it and showing it again:
|
||||
# measured here as a 250–400 ms disappearance of the whole application, with the
|
||||
# popup or transient dialog that was open losing its activation and being
|
||||
# dismissed with it. (``QOpenGLWidget`` sets ``WA_NativeWindow`` on itself.)
|
||||
# Switching the sensor to ``finger`` in the sensor dialog is exactly that moment,
|
||||
# which is why it read as "the window closes and reopens and the dialog closes by
|
||||
# itself".
|
||||
#
|
||||
# One zero-sized native child is enough: Qt then already owns a native window
|
||||
# handle when the 3D widget is added, and no recreation — and so no hide/show —
|
||||
# happens. It reserves the handle only; it creates no context, no scene and no GL
|
||||
# import, so the pane stays as lazy as it was. There is no "re-layout" to force
|
||||
# here: the window was never meant to be hidden at all.
|
||||
self._native_handle = QWidget(self)
|
||||
self._native_handle.setObjectName("mesh3dNativeHandle")
|
||||
self._native_handle.setGeometry(0, 0, 0, 0)
|
||||
self._native_handle.winId() # creating the handle *is* the point
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -189,9 +216,26 @@ class Mesh3dPanel(QFrame):
|
||||
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Re-tint the header icons: they are painted from the theme's own colours."""
|
||||
"""Re-tint the header icons, and colour the picture from the theme's ramp.
|
||||
|
||||
The ramp half is not cosmetic: the 2D canvas is coloured from
|
||||
``arrows.flow_colormap(theme)``, so the 3D surface has to be coloured from the
|
||||
same string or the two panes show one measurement in two colours.
|
||||
"""
|
||||
self._theme = str(theme)
|
||||
self._apply_icons()
|
||||
if self._view is not None and hasattr(self._view, "set_theme"):
|
||||
self._view.set_theme(self._theme)
|
||||
|
||||
def set_domain_latch(self, latch) -> None:
|
||||
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
|
||||
|
||||
Held here as well as pushed, because the view is built on first use: a latch
|
||||
handed over before then must still reach the adapter when the view appears.
|
||||
"""
|
||||
self._domain_latch = latch
|
||||
if self._view is not None and hasattr(self._view, "set_domain_latch"):
|
||||
self._view.set_domain_latch(latch)
|
||||
|
||||
def _apply_icons(self) -> None:
|
||||
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||
@@ -205,8 +249,8 @@ class Mesh3dPanel(QFrame):
|
||||
self,
|
||||
sensor_type: str,
|
||||
*,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||
package_override: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Tell the pane which sensor it is looking at. Cheap; no IO.
|
||||
@@ -256,7 +300,7 @@ class Mesh3dPanel(QFrame):
|
||||
return
|
||||
self._show_message(_LOADING, name=self._source.path.name)
|
||||
|
||||
def attach_view(self, view: "Scene3DView") -> None:
|
||||
def attach_view(self, view: Scene3DView) -> None:
|
||||
"""Adopt a view: the real one (built lazily) or a double in a test.
|
||||
|
||||
Public because it is the seam tests use — poking ``_view`` from a test would
|
||||
@@ -267,6 +311,16 @@ class Mesh3dPanel(QFrame):
|
||||
self._stack.addWidget(view)
|
||||
view.diagnosticRaised.connect(self._on_diagnostic)
|
||||
view.enabledChanged.connect(self._on_view_enabled)
|
||||
# The theme and the session's island latch were decided before the view existed
|
||||
# (the view is built on first use), so they are pushed again here rather than
|
||||
# lost — exactly the treatment ``_pending_settings`` and ``_source_kind`` get,
|
||||
# and for the same reason: the picture must be coloured by the same ramp, and
|
||||
# bounded by the same island, as the 2D canvas from its first frame. Guarded the
|
||||
# same way as ``panel`` above, because a test double is a view too.
|
||||
if hasattr(view, "set_theme"):
|
||||
view.set_theme(self._theme)
|
||||
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
|
||||
view.set_domain_latch(self._domain_latch)
|
||||
if self._pending_settings is not None:
|
||||
view.restore_settings(self._pending_settings)
|
||||
# The kind was decided before the view existed (the window sets it when the
|
||||
@@ -278,7 +332,7 @@ class Mesh3dPanel(QFrame):
|
||||
if not view.available:
|
||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||
|
||||
def ensure_view(self) -> Optional[QWidget]:
|
||||
def ensure_view(self) -> QWidget | None:
|
||||
"""Build the 3D view on first use, so a headless machine never imports GL."""
|
||||
if self._view is not None or self._sensor_type != "finger":
|
||||
return self._view
|
||||
@@ -294,7 +348,6 @@ class Mesh3dPanel(QFrame):
|
||||
if self._loaded or self._source is None or self._view is None:
|
||||
return
|
||||
self._loaded = True
|
||||
view = self._view
|
||||
if self._loader is None:
|
||||
self._loader = SceneAssetLoader(self)
|
||||
self._loader.loaded.connect(self._on_loaded)
|
||||
@@ -418,11 +471,11 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
@property
|
||||
def view(self) -> Optional["Scene3DView"]:
|
||||
def view(self) -> Scene3DView | None:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._reported
|
||||
|
||||
@property
|
||||
@@ -441,7 +494,7 @@ class Mesh3dPanel(QFrame):
|
||||
"""Whether a 3D picture is possible here at all (GL and an asset)."""
|
||||
return self._view is not None and bool(self._view.available)
|
||||
|
||||
def scene_path(self) -> Optional[Path]:
|
||||
def scene_path(self) -> Path | None:
|
||||
return None if self._source is None else self._source.path
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
|
||||
"""Modal dialog for the force-plot window, Y-axis limits, deadband and filters.
|
||||
|
||||
Two things live here because they are answered together, at the chart: how much
|
||||
time and range the curves show, and what the acquisition is allowed to filter.
|
||||
time and range the curves show (per channel, and the display deadband that holds
|
||||
a resting channel at zero), and what the acquisition is allowed to filter.
|
||||
The filter half is a *request*: it is handed to the SDK through
|
||||
``FilterSettings.apply_to`` at the start of the next session, and what the device
|
||||
reports as done is what the record will say (see ``force/filters.py``).
|
||||
@@ -9,6 +10,12 @@ reports as done is what the record will say (see ``force/filters.py``).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force.filters import (
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -24,21 +31,24 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force.filters import (
|
||||
SUGGESTED_LOWPASS_HZ,
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
|
||||
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
|
||||
#: drawn along the subplot border that abuts the FX frame (see force_plots).
|
||||
DEFAULT_PLOT_FN_MIN = 0.0
|
||||
DEFAULT_PLOT_FN_MAX = 20.0
|
||||
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
||||
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
|
||||
# stays sub-pixel while a full contact still fits (see prefs store).
|
||||
DEFAULT_PLOT_FN_RAW_MIN = 0.0
|
||||
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
|
||||
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
|
||||
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
|
||||
#: "auto": the percentile the presentation layer estimates from the quiet
|
||||
#: startup window. Non-zero forces a floor under that estimate, so an operator
|
||||
#: can widen the zero zone (or pin one when calibration data is suspect).
|
||||
DEFAULT_PLOT_DEADBAND_N = 0.0
|
||||
DEFAULT_PLOT_DEADBAND_RAW = 0.0
|
||||
|
||||
|
||||
class PlotSettingsDialog(QDialog):
|
||||
@@ -47,10 +57,14 @@ class PlotSettingsDialog(QDialog):
|
||||
parent=None,
|
||||
*,
|
||||
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
||||
fn_min: float = DEFAULT_PLOT_FN_MIN,
|
||||
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
||||
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
|
||||
fn_raw_min: float = DEFAULT_PLOT_FN_RAW_MIN,
|
||||
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
|
||||
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
|
||||
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
|
||||
units: str = "newton",
|
||||
filters: FilterSettings | None = None,
|
||||
filters_locked: bool = False,
|
||||
@@ -58,7 +72,7 @@ class PlotSettingsDialog(QDialog):
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setModal(True)
|
||||
self.resize(420, 520)
|
||||
self.resize(460, 640)
|
||||
self._sample_rate_hz = float(sample_rate_hz)
|
||||
#: True while the widgets are being filled in from a preset, so the
|
||||
#: "edited -> custom" handler does not fire on our own writes.
|
||||
@@ -75,6 +89,15 @@ class PlotSettingsDialog(QDialog):
|
||||
self.window_spin.setSuffix(" s")
|
||||
self.window_spin.setValue(float(window_sec))
|
||||
|
||||
# FN is asymmetric: a lower bound that may be negative, and an upper one.
|
||||
self.fn_min_label = QLabel()
|
||||
self.fn_min_spin = QDoubleSpinBox()
|
||||
self.fn_min_spin.setRange(-1000.0, 1000.0)
|
||||
self.fn_min_spin.setSingleStep(1.0)
|
||||
self.fn_min_spin.setDecimals(1)
|
||||
self.fn_min_spin.setSuffix(" N")
|
||||
self.fn_min_spin.setValue(float(fn_min))
|
||||
|
||||
self.fn_max_label = QLabel()
|
||||
self.fn_max_spin = QDoubleSpinBox()
|
||||
self.fn_max_spin.setRange(1.0, 100.0)
|
||||
@@ -91,6 +114,13 @@ class PlotSettingsDialog(QDialog):
|
||||
self.shear_max_spin.setSuffix(" N")
|
||||
self.shear_max_spin.setValue(float(shear_max))
|
||||
|
||||
self.fn_raw_min_label = QLabel()
|
||||
self.fn_raw_min_spin = QDoubleSpinBox()
|
||||
self.fn_raw_min_spin.setRange(-1e9, 1e9)
|
||||
self.fn_raw_min_spin.setSingleStep(50000.0)
|
||||
self.fn_raw_min_spin.setDecimals(0)
|
||||
self.fn_raw_min_spin.setValue(float(fn_raw_min))
|
||||
|
||||
# Raw limits: 0 means "auto range on the data".
|
||||
self.fn_raw_max_label = QLabel()
|
||||
self.fn_raw_max_spin = QDoubleSpinBox()
|
||||
@@ -106,22 +136,48 @@ class PlotSettingsDialog(QDialog):
|
||||
self.shear_raw_max_spin.setDecimals(0)
|
||||
self.shear_raw_max_spin.setValue(float(shear_raw_max))
|
||||
|
||||
# The display deadband: a *display* aid, per unit. It never reaches the
|
||||
# store or forces.csv (see force/presentation.py).
|
||||
self.deadband_n_label = QLabel()
|
||||
self.deadband_n_spin = QDoubleSpinBox()
|
||||
self.deadband_n_spin.setRange(0.0, 100.0)
|
||||
self.deadband_n_spin.setSingleStep(0.1)
|
||||
self.deadband_n_spin.setDecimals(2)
|
||||
self.deadband_n_spin.setSuffix(" N")
|
||||
self.deadband_n_spin.setValue(float(deadband_n))
|
||||
|
||||
self.deadband_raw_label = QLabel()
|
||||
self.deadband_raw_spin = QDoubleSpinBox()
|
||||
self.deadband_raw_spin.setRange(0.0, 1e9)
|
||||
self.deadband_raw_spin.setSingleStep(5000.0)
|
||||
self.deadband_raw_spin.setDecimals(0)
|
||||
self.deadband_raw_spin.setValue(float(deadband_raw))
|
||||
|
||||
form.addRow(self.window_label, self.window_spin)
|
||||
form.addRow(self.fn_min_label, self.fn_min_spin)
|
||||
form.addRow(self.fn_max_label, self.fn_max_spin)
|
||||
form.addRow(self.shear_max_label, self.shear_max_spin)
|
||||
form.addRow(self.fn_raw_min_label, self.fn_raw_min_spin)
|
||||
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
|
||||
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
|
||||
form.addRow(self.deadband_n_label, self.deadband_n_spin)
|
||||
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
newton = str(units or "newton").lower().startswith("n")
|
||||
raw_widgets = (
|
||||
self.fn_raw_min_spin,
|
||||
self.fn_raw_max_spin,
|
||||
self.shear_raw_max_spin,
|
||||
self.deadband_raw_spin,
|
||||
self.fn_raw_min_label,
|
||||
self.fn_raw_max_label,
|
||||
self.shear_raw_max_label,
|
||||
self.deadband_raw_label,
|
||||
)
|
||||
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
|
||||
self.shear_max_label):
|
||||
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
|
||||
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
|
||||
self.shear_max_label, self.deadband_n_label):
|
||||
widget.setEnabled(newton)
|
||||
for widget in raw_widgets:
|
||||
widget.setEnabled(not newton)
|
||||
@@ -330,6 +386,7 @@ class PlotSettingsDialog(QDialog):
|
||||
self.filter_note.setText("")
|
||||
|
||||
def values(self) -> tuple[float, float, float, float, float]:
|
||||
"""The original five-values shape, kept for the existing caller."""
|
||||
return (
|
||||
float(self.window_spin.value()),
|
||||
float(self.fn_max_spin.value()),
|
||||
@@ -338,12 +395,41 @@ class PlotSettingsDialog(QDialog):
|
||||
float(self.shear_raw_max_spin.value()),
|
||||
)
|
||||
|
||||
def ranges(self) -> dict[str, float]:
|
||||
"""Every axis limit, keyed the way the preference names it."""
|
||||
return {
|
||||
"window_sec": float(self.window_spin.value()),
|
||||
"fn_min": float(self.fn_min_spin.value()),
|
||||
"fn_max": float(self.fn_max_spin.value()),
|
||||
"shear_max": float(self.shear_max_spin.value()),
|
||||
"fn_raw_min": float(self.fn_raw_min_spin.value()),
|
||||
"fn_raw_max": float(self.fn_raw_max_spin.value()),
|
||||
"shear_raw_max": float(self.shear_raw_max_spin.value()),
|
||||
}
|
||||
|
||||
def deadbands(self) -> dict[str, float]:
|
||||
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
|
||||
return {
|
||||
"deadband_n": float(self.deadband_n_spin.value()),
|
||||
"deadband_raw": float(self.deadband_raw_spin.value()),
|
||||
}
|
||||
|
||||
def settings(self) -> dict[str, float]:
|
||||
"""Everything the plot half of the dialog decides, in one mapping."""
|
||||
return {**self.ranges(), **self.deadbands()}
|
||||
|
||||
def _reset_defaults(self) -> None:
|
||||
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
||||
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
|
||||
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
|
||||
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
||||
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
|
||||
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
||||
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
|
||||
# "Auto" is the shipped answer for the deadband: the estimate from the
|
||||
# quiet startup window, with the layer's own floor under it.
|
||||
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
|
||||
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
|
||||
# "Reset" means the shipped defaults, and those filter nothing — the same
|
||||
# answer the dialog gives for a preference file that says nothing.
|
||||
self.flow_ema_spin.setValue(1.0)
|
||||
@@ -358,14 +444,22 @@ class PlotSettingsDialog(QDialog):
|
||||
self.setWindowTitle(t("dialog.plot_settings_title"))
|
||||
self.window_label.setText(t("dialog.plot_window"))
|
||||
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
|
||||
self.fn_min_label.setText(t("dialog.plot_fn_min"))
|
||||
self.fn_min_spin.setToolTip(t("dialog.plot_fn_min_tip"))
|
||||
self.fn_max_label.setText(t("dialog.plot_fn_max"))
|
||||
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
||||
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
||||
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
|
||||
self.fn_raw_min_label.setText(t("dialog.plot_fn_raw_min"))
|
||||
self.fn_raw_min_spin.setToolTip(t("dialog.plot_fn_raw_min_tip"))
|
||||
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
|
||||
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
|
||||
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
|
||||
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
|
||||
self.deadband_n_label.setText(t("dialog.plot_deadband_n"))
|
||||
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
|
||||
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
|
||||
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
|
||||
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||
self.filters_group.setTitle(t("dialog.plot_filters"))
|
||||
self.filter_preset_label.setText(t("dialog.filter_preset"))
|
||||
|
||||
@@ -14,10 +14,9 @@ timer without touching the writer thread.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
#: The recording channel is idle, never started in this session.
|
||||
IDLE = "idle"
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
@@ -26,8 +27,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -18,9 +20,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
#: Both combos stay narrow: this row shares one line with the session state.
|
||||
TYPE_WIDTH = 76
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Top of the studio shell: status rail and a collapsible settings area.
|
||||
|
||||
The rail is what an operator watches while a session runs — phase, device,
|
||||
calibration, data validity and the record channel — plus the three actions that
|
||||
must never be more than one click away (record, run/stop, reset).
|
||||
calibration, data validity and the record channel — plus the actions that must
|
||||
never be more than one click away (record, run/pause, stop, reset).
|
||||
|
||||
The transport is the *same* control in both views (``btn_run``): a play/pause
|
||||
toggle whose checked state means "acquiring / playing and not paused". A session
|
||||
that is stopped and a session that is paused both leave the button unchecked, so
|
||||
the session phase — not the button — is what tells the two apart (see
|
||||
``_refresh_run_label``). Stopping is its own button (``btn_stop``), because
|
||||
releasing the device is a different act from pausing the computation.
|
||||
|
||||
Everything that is *configuration* rather than state — source, camera number,
|
||||
video file, units and the sensor/config pickers — lives in the settings area,
|
||||
@@ -12,6 +19,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -26,9 +35,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.record_status import RecordStatusChip
|
||||
|
||||
@@ -81,7 +87,10 @@ class ToolbarWidget(QWidget):
|
||||
replay_video_clicked = Signal()
|
||||
#: Replay view: re-run the pipeline over that video (the only processed picture).
|
||||
recompute_clicked = Signal()
|
||||
#: The transport: True when a live session should acquire, or a replay play.
|
||||
run_toggled = Signal(bool)
|
||||
#: Stop: end the live session (releasing the device), or leave the replay.
|
||||
stop_clicked = Signal()
|
||||
reset_clicked = Signal()
|
||||
units_changed = Signal(str)
|
||||
record_toggled = Signal(bool)
|
||||
@@ -109,7 +118,6 @@ class ToolbarWidget(QWidget):
|
||||
self._video_path = ""
|
||||
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
||||
self._run_role = "acquire"
|
||||
self._reset_role = "reset"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(2, 2, 2, 2)
|
||||
@@ -242,6 +250,16 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_run.setCheckable(True)
|
||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||
|
||||
# Stop is a text button beside the transport, like Run — the icon-only pause
|
||||
# it replaces cost the same rail width while saying nothing about what it
|
||||
# stopped. It carries no minimum width, so the rail can shrink it at 1280
|
||||
# (``tools/ui_check.py`` checks the rail for elision in both locales).
|
||||
self.btn_stop = QPushButton()
|
||||
self.btn_stop.setObjectName("dangerOutline")
|
||||
self.btn_stop.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_stop.setEnabled(False)
|
||||
self.btn_stop.clicked.connect(self.stop_clicked.emit)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_reset.setEnabled(False)
|
||||
@@ -266,6 +284,7 @@ class ToolbarWidget(QWidget):
|
||||
rail.addWidget(self.record_chip)
|
||||
rail.addWidget(_rail(self.btn_record))
|
||||
rail.addWidget(_rail(self.btn_run))
|
||||
rail.addWidget(_rail(self.btn_stop))
|
||||
rail.addWidget(_rail(self.btn_reset))
|
||||
rail.addWidget(_rail(self.btn_settings))
|
||||
return rail
|
||||
@@ -386,7 +405,11 @@ class ToolbarWidget(QWidget):
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def set_run_role(self, role: str) -> None:
|
||||
"""What the primary button does in this view: acquire, or play back."""
|
||||
"""What the primary button does in this view: acquire, or play back.
|
||||
|
||||
The same button is the transport of both views, so the *labels* follow the
|
||||
role; only the words change (运行/暂停/继续 against 播放/暂停/继续).
|
||||
"""
|
||||
wanted = "playback" if str(role) == "playback" else "acquire"
|
||||
if wanted == self._run_role:
|
||||
return
|
||||
@@ -397,38 +420,11 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_run.setChecked(False)
|
||||
self.btn_run.blockSignals(False)
|
||||
self._refresh_run_label()
|
||||
self._refresh_stop_label()
|
||||
|
||||
def run_role(self) -> str:
|
||||
return self._run_role
|
||||
|
||||
def set_reset_role(self, role: str) -> None:
|
||||
"""What the second rail button does: zero the tracker, or stop the replay.
|
||||
|
||||
The replay view needs a *stop* the operator can always reach. The transport
|
||||
button is a play/pause toggle, which is ambiguous precisely when something is
|
||||
wrong (is it playing? is a recompute pass running?), so the second button —
|
||||
disabled in that view otherwise — becomes an unconditional stop instead of a
|
||||
second thing to reason about.
|
||||
"""
|
||||
wanted = "stop" if str(role) == "stop" else "reset"
|
||||
if wanted == self._reset_role:
|
||||
return
|
||||
self._reset_role = wanted
|
||||
if wanted == "reset":
|
||||
self.btn_reset.setEnabled(False)
|
||||
self._refresh_reset_label()
|
||||
|
||||
def reset_role(self) -> str:
|
||||
return self._reset_role
|
||||
|
||||
def _refresh_reset_label(self) -> None:
|
||||
if self._reset_role == "stop":
|
||||
self.btn_reset.setText(t("toolbar.stop_playback"))
|
||||
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
|
||||
return
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
self.btn_reset.setToolTip("")
|
||||
|
||||
def source_view(self) -> str:
|
||||
return self._source_view
|
||||
|
||||
@@ -467,6 +463,12 @@ class ToolbarWidget(QWidget):
|
||||
self.refresh_labels()
|
||||
|
||||
def set_run_checked(self, checked: bool) -> None:
|
||||
"""Follow the session's transport state without re-emitting the signal.
|
||||
|
||||
The window calls this to *state* what the session is doing (a replay began,
|
||||
a session ended, a recompute pass stopped). An operator's own click goes
|
||||
through the button instead, and is routed by ``_on_run_toggled``.
|
||||
"""
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(bool(checked))
|
||||
self.btn_run.blockSignals(False)
|
||||
@@ -474,6 +476,14 @@ class ToolbarWidget(QWidget):
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
|
||||
def set_stop_enabled(self, enabled: bool) -> None:
|
||||
"""Stop is offered for as long as there is something to end.
|
||||
|
||||
That includes a device open that has not returned yet: the stop button is
|
||||
where the cancel-a-hung-connect escape hatch lives now.
|
||||
"""
|
||||
self.btn_stop.setEnabled(bool(enabled))
|
||||
|
||||
def set_reset_enabled(self, enabled: bool) -> None:
|
||||
self.btn_reset.setEnabled(bool(enabled))
|
||||
|
||||
@@ -517,7 +527,13 @@ class ToolbarWidget(QWidget):
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def set_run_phase(self, phase: str) -> None:
|
||||
"""Track the session phase so Stop can advertise itself while connecting."""
|
||||
"""Track the session phase.
|
||||
|
||||
The phase is half of what the transport label means: unchecked *with* a
|
||||
live session is paused, unchecked without one is idle (see
|
||||
``_refresh_run_label``), and ``connecting`` is what the stop button
|
||||
advertises itself as cancel for.
|
||||
"""
|
||||
self._run_phase = str(phase or "idle")
|
||||
self.refresh_labels()
|
||||
|
||||
@@ -531,6 +547,7 @@ class ToolbarWidget(QWidget):
|
||||
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
|
||||
self._refresh_preview_label()
|
||||
self._refresh_run_label()
|
||||
self._refresh_stop_label()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _populate_units_combo(self) -> None:
|
||||
@@ -636,9 +653,15 @@ class ToolbarWidget(QWidget):
|
||||
self.preview_toggled.emit(bool(checked))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
"""The transport: the label follows the button, the *state* does not.
|
||||
|
||||
Nothing about a session is decided here — the window routes the click (§
|
||||
``_on_run_toggled`` there) and states the result back through
|
||||
``set_run_checked``. Reset is deliberately left alone: unchecking now means
|
||||
pause as often as it means stop, and pausing must not disable a control the
|
||||
still-running session owns.
|
||||
"""
|
||||
self._refresh_run_label()
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.run_toggled.emit(bool(checked))
|
||||
|
||||
def _on_units_index_changed(self, _index: int) -> None:
|
||||
@@ -650,28 +673,52 @@ class ToolbarWidget(QWidget):
|
||||
else:
|
||||
self.btn_preview.setText(t("toolbar.preview"))
|
||||
|
||||
def _session_running(self) -> bool:
|
||||
"""Whether a *live* session exists in this view (connecting or acquiring).
|
||||
|
||||
This is the third transport state the two-state button cannot carry: with
|
||||
the button unchecked it is what separates "paused" from "not started".
|
||||
"""
|
||||
return self._run_role == "acquire" and self._run_phase in ("connecting", "running")
|
||||
|
||||
def _refresh_run_label(self) -> None:
|
||||
if self._run_role == "playback":
|
||||
# The primary action of the replay view is playing the recording —
|
||||
# the same button, doing what this view is for.
|
||||
# the same button, doing what this view is for. Unchecked with nothing
|
||||
# loaded is idle, and the button is disabled with the reason in its tip.
|
||||
self.btn_run.setText(
|
||||
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
|
||||
)
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
# While connecting, cancel is the action the user actually needs —
|
||||
# a device open can hang, and this is the way out of it.
|
||||
if self._run_phase == "connecting":
|
||||
self.btn_run.setText(t("toolbar.cancel_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.stop_label"))
|
||||
elif self.btn_run.isChecked():
|
||||
# checked = acquiring and not paused, so the action is Pause.
|
||||
self.btn_run.setText(t("toolbar.pause"))
|
||||
elif self._session_running():
|
||||
# unchecked *with* a live session is the paused state: the device is
|
||||
# open and the computation is stopped, so the action is Resume.
|
||||
self.btn_run.setText(t("toolbar.resume_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.run_label"))
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def _refresh_stop_label(self) -> None:
|
||||
"""Stop is one button for two situations, and its label says which.
|
||||
|
||||
A device open can hang, and the way out of it is this button: while the
|
||||
session is still connecting it advertises itself as cancel, because that
|
||||
is the act the operator needs. In the replay view it is the way out of the
|
||||
replay (or a recompute pass), whatever state the transport is in.
|
||||
"""
|
||||
if self._run_role == "playback":
|
||||
self.btn_stop.setText(t("toolbar.stop_label"))
|
||||
self.btn_stop.setToolTip(t("toolbar.stop_playback_tip"))
|
||||
elif self._run_phase == "connecting":
|
||||
self.btn_stop.setText(t("toolbar.cancel_label"))
|
||||
self.btn_stop.setToolTip(t("toolbar.cancel_tip"))
|
||||
else:
|
||||
self.btn_stop.setText(t("toolbar.stop_label"))
|
||||
self.btn_stop.setToolTip(t("toolbar.stop"))
|
||||
|
||||
def _refresh_emphasis(self) -> None:
|
||||
"""Exactly one *filled* button at a time.
|
||||
|
||||
@@ -681,31 +728,36 @@ class ToolbarWidget(QWidget):
|
||||
|
||||
* not recording yet → the record action is the filled primary;
|
||||
* recording → stopping the recording is the filled danger;
|
||||
* connecting → cancel is the filled danger (it is the way out of a hang).
|
||||
* connecting → cancel (on the stop button) is the filled danger, since it
|
||||
is the way out of a hang;
|
||||
* a live session acquiring or paused → the transport is an outline: pause,
|
||||
resume and stop all change the session's state rather than being the one
|
||||
action the rail is pointing at.
|
||||
|
||||
The replay's transport is the exception: it *is* what that view is for, so
|
||||
it is filled while it plays and an outline while it is idle.
|
||||
"""
|
||||
recording = self.btn_record.isChecked()
|
||||
# The phase counts as running even if the button has not been repainted
|
||||
# yet: the rail must describe the session, not the widget's lag.
|
||||
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
|
||||
playing = self.btn_run.isChecked()
|
||||
if self._run_role == "playback":
|
||||
# The transport is the replay view's own action, not a session stop:
|
||||
# filled while it plays, an outline when idle, and never red.
|
||||
self.btn_record.setObjectName(
|
||||
"dangerButton" if recording else "primaryButton"
|
||||
)
|
||||
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
|
||||
elif recording:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
# Never red: the replay's stop is an outline beside a filled transport.
|
||||
self.btn_record.setObjectName("dangerButton" if recording else "primaryButton")
|
||||
self.btn_run.setObjectName("primaryButton" if playing else "ghostButton")
|
||||
self.btn_stop.setObjectName("dangerOutline")
|
||||
else:
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
if running and self._run_phase != "connecting":
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
elif running:
|
||||
self.btn_run.setObjectName("dangerButton")
|
||||
# The phase counts as running even if the button has not been repainted
|
||||
# yet: the rail must describe the session, not the widget's lag.
|
||||
connecting = self._run_phase == "connecting"
|
||||
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
|
||||
if recording:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("ghostButton")
|
||||
else:
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
for button in (self.btn_record, self.btn_run):
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
self.btn_run.setObjectName(
|
||||
"ghostButton" if playing or self._session_running() else "primaryButton"
|
||||
)
|
||||
for button in (self.btn_record, self.btn_run, self.btn_stop):
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
@@ -723,10 +775,12 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_run.setToolTip(t(key))
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
if self._run_phase == "connecting":
|
||||
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
|
||||
else:
|
||||
self.btn_run.setToolTip(t("toolbar.stop"))
|
||||
# The tip is where the pause semantics are stated: computing stops, the
|
||||
# picture holds, the device stays open — and a recording that spans the
|
||||
# pause has a gap.
|
||||
self.btn_run.setToolTip(t("toolbar.pause_tip"))
|
||||
elif self._session_running():
|
||||
self.btn_run.setToolTip(t("toolbar.resume_label_tip"))
|
||||
elif not self._run_available and self._run_tooltip_reason:
|
||||
self.btn_run.setToolTip(self._run_tooltip_reason)
|
||||
elif not self._run_available:
|
||||
@@ -737,6 +791,13 @@ class ToolbarWidget(QWidget):
|
||||
tip = f"{tip} — {self._run_tooltip_reason}"
|
||||
self.btn_run.setToolTip(tip)
|
||||
|
||||
def _refresh_reset_label(self) -> None:
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
# The button zeroes the *device* tracker; it no longer clears the curves.
|
||||
# That is now 视图 → 复位视图, because one button doing both meant an
|
||||
# operator who wanted a fresh baseline also lost the history on screen.
|
||||
self.btn_reset.setToolTip(t("toolbar.reset_tip"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||
|
||||
@@ -22,7 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
@@ -80,7 +80,7 @@ class ViewLayoutController(QObject):
|
||||
default_splitter_sizes: tuple,
|
||||
min_panel_widths: dict,
|
||||
default_3d_pane_width: int,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._host = host
|
||||
@@ -103,7 +103,7 @@ class ViewLayoutController(QObject):
|
||||
self._view_preset = preset if preset in self._presets else "custom"
|
||||
self._show_cal = bool(host.prefs.get("show_cal_panel", False))
|
||||
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
|
||||
self._actions: Optional[LayoutActions] = None
|
||||
self._actions: LayoutActions | None = None
|
||||
|
||||
# ------------------------------------------------------------------ wiring
|
||||
def bind_actions(
|
||||
@@ -244,7 +244,7 @@ class ViewLayoutController(QObject):
|
||||
self._set_check(self._actions.settings, self._toolbar.settings_expanded())
|
||||
|
||||
@staticmethod
|
||||
def _set_check(action: Any, checked: bool, *, enabled: Optional[bool] = None) -> None:
|
||||
def _set_check(action: Any, checked: bool, *, enabled: bool | None = None) -> None:
|
||||
"""Set a checkable entry without letting it drive its own handler."""
|
||||
if action is None:
|
||||
return
|
||||
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
|
||||
self._replay.pause_playback()
|
||||
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
|
||||
# The rail's primary button is this view's main action: acquire while
|
||||
# measuring, play back while replaying; and the second button is that view's
|
||||
# own reset — or, in the replay view, an unconditional stop.
|
||||
# measuring, play back while replaying. The button beside it is the same in
|
||||
# both views — Stop, which ends a session or leaves a replay — and the
|
||||
# tracker reset is simply disabled wherever there is no device session.
|
||||
self._toolbar.set_run_role("playback" if preset == "replay" else "acquire")
|
||||
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
|
||||
self.apply_panel_visibility()
|
||||
if apply_sizes and preset in self._splitter_sizes:
|
||||
self._splitter.setSizes(self.splitter_sizes_for(preset))
|
||||
|
||||
@@ -62,8 +62,8 @@ def qt_app():
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, tmp_path, monkeypatch):
|
||||
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
prefs_file = tmp_path / "preference.json"
|
||||
|
||||
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
|
||||
|
||||
PLOT_DEFAULTS = {
|
||||
"window_sec": 10.0,
|
||||
"fn_min": 0.0,
|
||||
"fn_max": 5.0,
|
||||
"shear_max": 5.0,
|
||||
"fn_raw_min": 0.0,
|
||||
"fn_raw_max": 1e5,
|
||||
"shear_raw_max": 1e5,
|
||||
}
|
||||
@@ -46,7 +48,10 @@ class _Charts:
|
||||
self.fixed_newton: list[bool] = []
|
||||
self.units: list[str] = []
|
||||
self.window_sec: list[float] = []
|
||||
self.limits: list[tuple] = []
|
||||
self.limits: list[dict] = []
|
||||
self.deadband_floors: list[dict] = []
|
||||
self.presentation_enabled: list[bool] = []
|
||||
self.presentation: list[dict] = []
|
||||
|
||||
def unit(self) -> str:
|
||||
return self._unit
|
||||
@@ -61,8 +66,17 @@ class _Charts:
|
||||
def set_window_sec(self, value: float) -> None: # noqa: N802
|
||||
self.window_sec.append(float(value))
|
||||
|
||||
def set_axis_limits(self, *values) -> None: # noqa: N802
|
||||
self.limits.append(tuple(float(v) for v in values))
|
||||
def set_axis_limits(self, **values) -> None: # noqa: N802
|
||||
self.limits.append({key: float(value) for key, value in values.items()})
|
||||
|
||||
def set_deadband_floors(self, *, newton: float, raw: float) -> None: # noqa: N802
|
||||
self.deadband_floors.append({"newton": float(newton), "raw": float(raw)})
|
||||
|
||||
def configure_presentation(self, **values) -> None: # noqa: N802
|
||||
self.presentation.append(dict(values))
|
||||
|
||||
def set_presentation_enabled(self, enabled: bool) -> None: # noqa: N802
|
||||
self.presentation_enabled.append(bool(enabled))
|
||||
|
||||
|
||||
class _ArrowPanel:
|
||||
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
|
||||
settings = controller.plot_settings()
|
||||
assert settings["window_sec"] == 3.0
|
||||
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
|
||||
assert settings["fn_min"] == 0.0, "FN has no lower bound by default"
|
||||
assert settings["deadband_n"] == 0.0, "the deadband default is 'auto'"
|
||||
assert settings["units"] == "newton"
|
||||
|
||||
|
||||
def test_a_stored_zero_is_a_value_and_not_a_missing_preference():
|
||||
"""0 means "auto" on the raw bounds and the deadband, so it must survive."""
|
||||
controller, parts = _controller(prefs={"plot_fn_raw_max": 0.0})
|
||||
|
||||
settings = controller.plot_settings()
|
||||
assert settings["fn_raw_max"] == 0.0, "a stored 0 became the default"
|
||||
|
||||
|
||||
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
|
||||
controller, parts = _controller(prefs={})
|
||||
controller.apply_plot_settings(
|
||||
{
|
||||
"window_sec": 20.0,
|
||||
"fn_min": -2.0,
|
||||
"fn_max": 8.0,
|
||||
"shear_max": 9.0,
|
||||
"fn_raw_min": -1000.0,
|
||||
"fn_raw_max": 2e5,
|
||||
"shear_raw_max": 3e5,
|
||||
"deadband_n": 0.25,
|
||||
"deadband_raw": 0.0,
|
||||
}
|
||||
)
|
||||
assert parts["host"].prefs["plot_window_sec"] == 20.0
|
||||
assert parts["host"].prefs["plot_fn_min"] == -2.0
|
||||
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
|
||||
assert parts["host"].prefs["plot_deadband_n"] == 0.25
|
||||
assert parts["charts"].window_sec == [20.0]
|
||||
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
|
||||
assert parts["charts"].limits[-1] == {
|
||||
"fn_min": -2.0,
|
||||
"fn_max": 8.0,
|
||||
"shear_max": 9.0,
|
||||
"fn_raw_min": -1000.0,
|
||||
"fn_raw_max": 2e5,
|
||||
"shear_raw_max": 3e5,
|
||||
}
|
||||
# 0 on a deadband floor means "auto", and the charts are told exactly that.
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.25, "raw": 0.0}
|
||||
assert parts["host"].persisted == 1
|
||||
|
||||
|
||||
def test_a_partial_plot_settings_mapping_leaves_the_rest_as_stored():
|
||||
"""The older five-value shape must not silently reset a range."""
|
||||
controller, parts = _controller(prefs={"plot_fn_min": -5.0, "plot_deadband_raw": 900.0})
|
||||
controller.apply_plot_settings(
|
||||
{"window_sec": 6.0, "fn_max": 12.0, "shear_max": 3.0,
|
||||
"fn_raw_max": 4e5, "shear_raw_max": 5e5}
|
||||
)
|
||||
assert parts["host"].prefs["plot_fn_min"] == -5.0
|
||||
assert parts["charts"].limits[-1]["fn_min"] == -5.0
|
||||
assert parts["charts"].deadband_floors[-1]["raw"] == 900.0
|
||||
|
||||
|
||||
def test_the_display_baseline_is_not_a_finger_only_setting():
|
||||
"""Every sensor type and both units: an idle newton offset is the same problem."""
|
||||
for sensor, units in (("box", "newton"), ("box", "raw"),
|
||||
("finger", "newton"), ("finger", "raw")):
|
||||
controller, parts = _controller(prefs={"raw_auto_zero": True},
|
||||
sensor_type=sensor, units=units)
|
||||
assert parts["charts"].presentation_enabled[-1] is True, (sensor, units)
|
||||
assert parts["charts"].presentation[-1]["unit"] == (
|
||||
"N" if units == "newton" else "raw"
|
||||
)
|
||||
|
||||
controller, parts = _controller(prefs={"raw_auto_zero": False}, sensor_type="box",
|
||||
units="newton")
|
||||
assert parts["charts"].presentation_enabled[-1] is False
|
||||
|
||||
|
||||
def test_the_deadband_floors_reach_the_charts_from_the_preferences():
|
||||
controller, parts = _controller(prefs={"plot_deadband_n": 0.5, "plot_deadband_raw": 2000.0})
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
|
||||
|
||||
controller.set_raw_auto_zero(True) # re-applies the whole policy
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
|
||||
|
||||
|
||||
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
|
||||
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
|
||||
assert controller.device_sample_rate() == 48.5
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""The force charts as the operator sees them: baseline, deadband and ranges.
|
||||
|
||||
The display policy is a *view* concern: an idle channel must read zero and draw a
|
||||
flat line, while the session store and ``forces.csv`` keep the measurement. These
|
||||
tests drive the panel on its own (no acquisition, no window) and read back what
|
||||
the curves and the value cards were actually given.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
|
||||
|
||||
#: The two idle triplets from the customer's screenshots: a raw FN of ~9555 and
|
||||
#: an FX whose bias sits ~13k counts off zero.
|
||||
RAW_BIAS = np.array([9555.0, -13368.0, -353.0])
|
||||
NEWTON_BIAS = np.array([0.4, -0.25, 0.1])
|
||||
|
||||
|
||||
def _panel(unit: str = "raw") -> ForcePlotsPanel:
|
||||
"""A chart in one unit, with the display baseline switched on."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_units(unit)
|
||||
panel.configure_presentation(sensor_type="box", unit=unit)
|
||||
panel.set_presentation_enabled(True)
|
||||
panel.set_fixed_newton(True)
|
||||
return panel
|
||||
|
||||
|
||||
def _rest(
|
||||
panel: ForcePlotsPanel,
|
||||
bias: np.ndarray,
|
||||
*,
|
||||
unit: str = "raw",
|
||||
samples: int = 80,
|
||||
spread: float = 0.0,
|
||||
sigma: float = 0.0,
|
||||
seed: int = 0,
|
||||
) -> None:
|
||||
"""Feed a quiet startup window, the way a live session would.
|
||||
|
||||
``spread`` is a bounded wander (the device really does sit still) and
|
||||
``sigma`` is the random jitter of a `noisier` product.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
for index in range(samples):
|
||||
sample = bias + ((index % 5) - 2) * spread
|
||||
if sigma:
|
||||
sample = sample + rng.normal(0.0, sigma, size=3)
|
||||
panel.push(index / 30.0, *sample, unit=unit)
|
||||
|
||||
|
||||
def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
|
||||
"""The Y tick values actually installed on one axis."""
|
||||
levels = panel.plots[channel].getAxis("left")._tickLevels
|
||||
assert levels, "the axis has no explicit ticks"
|
||||
return [float(value) for value, _text in levels[0]]
|
||||
|
||||
|
||||
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
|
||||
_times, values = panel.curves[channel].getData()
|
||||
return np.asarray(values, dtype=float)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ resting channel
|
||||
def test_the_cards_read_zero_from_the_first_samples_of_a_session(qt_app):
|
||||
"""The reported defect: FN 1118 raw, FX 34.16, FY -186.1 on the cards.
|
||||
|
||||
The screenshot was taken as the session started, so the aid has to be
|
||||
effective from the first samples — not once the whole quiet window closed.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
if index < 2:
|
||||
# Nothing has been estimated from fewer than three samples yet; those
|
||||
# first frames honestly show the measurement.
|
||||
continue
|
||||
assert [label.text() for label in panel.value_labels] == ["0"] * 3, index
|
||||
|
||||
assert not panel.presentation.ready, "the whole three second window was needed"
|
||||
assert panel.presentation.display_quality == "provisional"
|
||||
for channel in (0, 1, 2):
|
||||
assert set(np.unique(_curve(panel, channel))) == {0.0}, channel
|
||||
|
||||
|
||||
def test_the_newton_cards_read_zero_from_the_first_samples_too(qt_app):
|
||||
"""Both units: a newton offset at rest is the same display problem."""
|
||||
panel = _panel("N")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *NEWTON_BIAS, unit="N")
|
||||
if index < 2:
|
||||
continue
|
||||
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3, index
|
||||
assert not panel.presentation.ready
|
||||
|
||||
|
||||
def test_a_card_and_its_own_curve_are_the_same_value(qt_app):
|
||||
"""A card may never read a number its curve does not carry.
|
||||
|
||||
The extra "the estimate is ready" gate in ``_render_value_cards`` is what
|
||||
used to let the card show the raw measurement while the curve was already
|
||||
drawn through the applied estimate.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
# A real contact, flagged as one so it does not enter the idle estimate.
|
||||
panel.push(1.0, *(RAW_BIAS + [4000.0, 0.0, 0.0]), unit="raw", contact_found=True)
|
||||
|
||||
for channel in range(3):
|
||||
drawn = float(_curve(panel, channel)[-1])
|
||||
assert panel.value_labels[channel].text() == format_force(
|
||||
drawn, "raw", presentation=panel.presentation
|
||||
), channel
|
||||
assert panel.value_labels[0].text() not in ("0", "1118"), "the contact was lost"
|
||||
|
||||
|
||||
def test_a_resting_raw_channel_reads_zero_and_draws_a_flat_curve(qt_app):
|
||||
"""FN 9555 / FX -13368 at rest must read 0 and draw a line on zero."""
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
|
||||
assert panel.presentation.ready, "the quiet window never closed"
|
||||
for index, label in enumerate(panel.value_labels):
|
||||
assert label.text() == "0", (index, label.text())
|
||||
for channel in (0, 1, 2):
|
||||
values = _curve(panel, channel)
|
||||
assert values.size, channel
|
||||
assert set(np.unique(values)) == {0.0}, (channel, np.unique(values))
|
||||
|
||||
|
||||
def test_a_resting_newton_channel_is_baselined_too(qt_app):
|
||||
"""The same aid in newtons: it is a display problem in either unit."""
|
||||
panel = _panel("N")
|
||||
_rest(panel, NEWTON_BIAS, unit="N")
|
||||
|
||||
assert panel.presentation.ready
|
||||
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3
|
||||
assert set(np.unique(_curve(panel, 0))) == {0.0}
|
||||
|
||||
|
||||
def test_noise_outside_the_estimated_band_is_still_rare(qt_app):
|
||||
"""A p99 band, so a resting *noisy* channel is flat but not rigid.
|
||||
|
||||
The offset removal is what fixes a 13k-count bias; the percentile band holds
|
||||
the remaining jitter at zero. A few idle samples may sit just outside it —
|
||||
that is what the operator's deadband floor in the dialog is for.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, sigma=30.0)
|
||||
|
||||
assert panel.presentation.ready
|
||||
values = _curve(panel, 1)
|
||||
zeroed = float(np.mean(values == 0.0))
|
||||
assert zeroed >= 0.95, f"only {zeroed:.2%} of a resting channel was zeroed"
|
||||
assert abs(float(np.median(values))) < 1.0
|
||||
|
||||
|
||||
def test_the_record_keeps_the_measurement_while_the_display_is_zeroed(qt_app):
|
||||
"""The deadband is a display copy: the buffer and the store see the truth."""
|
||||
panel = _panel("raw")
|
||||
store = SessionStore()
|
||||
panel.set_store(store)
|
||||
for index in range(6):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
store.append(
|
||||
SessionSample(
|
||||
index,
|
||||
index / 30.0,
|
||||
fn_raw=RAW_BIAS[0],
|
||||
fx_raw=RAW_BIAS[1],
|
||||
fy_raw=RAW_BIAS[2],
|
||||
fn_n=NEWTON_BIAS[0],
|
||||
fx_n=NEWTON_BIAS[1],
|
||||
fy_n=NEWTON_BIAS[2],
|
||||
valid=True,
|
||||
)
|
||||
)
|
||||
|
||||
# The very values the customer complained about are still measured, from the
|
||||
# first sample on — in the live buffer...
|
||||
state = panel.history.state()
|
||||
assert state is not None
|
||||
assert abs(float(np.median(state.values[:, 0])) - RAW_BIAS[0]) < 100.0
|
||||
assert abs(float(np.median(state.values[:, 1])) - RAW_BIAS[1]) < 100.0
|
||||
# ...and in the session store (which is what forces.csv is written from).
|
||||
stored = store.data().raw
|
||||
assert stored.shape[0] == 6
|
||||
assert float(np.median(stored[:, 0])) == RAW_BIAS[0]
|
||||
assert float(np.median(stored[:, 1])) == RAW_BIAS[1]
|
||||
assert float(np.median(stored[:, 2])) == RAW_BIAS[2]
|
||||
|
||||
# ...while the cards and the curve drawn from it are already flat on zero.
|
||||
assert [label.text() for label in panel.value_labels] == ["0"] * 3
|
||||
assert set(np.unique(_curve(panel, 1))) == {0.0}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ deadband
|
||||
def test_a_small_excursion_is_suppressed_and_a_larger_one_is_not(qt_app):
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
estimate = panel.presentation.last_estimate
|
||||
assert estimate is not None and estimate.quality == "ok"
|
||||
deadband = float(estimate.deadbands[0])
|
||||
assert deadband > 0.0
|
||||
|
||||
panel.push(3.0, *(RAW_BIAS + [0.5 * deadband, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() == "0", "half the deadband was drawn"
|
||||
|
||||
panel.push(3.1, *(RAW_BIAS + [3.0 * deadband, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() != "0", (
|
||||
"an excursion well outside the deadband was swallowed"
|
||||
)
|
||||
assert abs(_curve(panel, 0)[-1]) > 2.0 * deadband
|
||||
# ...and the other axes, which stayed quiet, are still zero.
|
||||
assert panel.value_labels[1].text() == "0"
|
||||
|
||||
|
||||
def test_the_operator_floor_widens_the_deadband_at_once(qt_app):
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
|
||||
panel.set_deadband_floors(raw=250000.0)
|
||||
panel.push(3.0, *(RAW_BIAS + [50000.0, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() == "0", (
|
||||
"an excursion inside the operator's floor was drawn"
|
||||
)
|
||||
|
||||
panel.set_deadband_floors(raw=0.0) # "auto": the layer's own floor returns
|
||||
assert panel.presentation.min_deadband_for("raw") == 50.0
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- ranges
|
||||
def test_the_configured_ranges_reach_every_axis(qt_app):
|
||||
"""FN is asymmetric and may go below zero; FX/FY are symmetric."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=-5.0,
|
||||
fn_max=15.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=-100000.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
panel.set_fixed_newton(True)
|
||||
|
||||
# The panel starts in raw, so the raw limits are the ones on the axes.
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
|
||||
|
||||
panel.set_units("N")
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert panel._fixed_y_range(0) == (-5.0, 15.0)
|
||||
assert panel._fixed_y_range(1) == (-3.0, 3.0)
|
||||
# A range that starts below zero carries an explicit 0 tick: that is the
|
||||
# "missing origin" the customer could not find.
|
||||
assert 0.0 in _ticks(panel, 0)
|
||||
|
||||
panel.set_units("raw")
|
||||
assert panel._fixed_y_range(0) == (-100000.0, 1000000.0)
|
||||
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
|
||||
|
||||
|
||||
def test_the_zero_line_is_only_drawn_where_zero_is_inside_the_range(qt_app):
|
||||
"""pyqtgraph does not clip a layout item to its own view box."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=0.0,
|
||||
fn_max=20.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=100.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
view_box = panel.plots[0].getViewBox()
|
||||
assert not panel.zero_lines[0].isVisibleTo(view_box), (
|
||||
"a zero line was painted on an axis that does not reach zero"
|
||||
)
|
||||
assert panel.zero_lines[1].isVisibleTo(panel.plots[1].getViewBox())
|
||||
assert 0.0 not in _ticks(panel, 0)
|
||||
|
||||
# Give FN a negative lower bound and the origin appears.
|
||||
panel.set_axis_limits(
|
||||
fn_min=0.0,
|
||||
fn_max=20.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=-100000.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
assert panel.zero_lines[0].isVisibleTo(view_box)
|
||||
assert 0.0 in _ticks(panel, 0)
|
||||
|
||||
|
||||
def test_an_inverted_range_is_widened_rather_than_collapsing_the_axis(qt_app):
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=5.0,
|
||||
fn_max=2.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=0.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
ymin, ymax = panel._fixed_y_range(0)
|
||||
assert ymax > ymin
|
||||
|
||||
|
||||
def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
|
||||
"""Defaults preserve today's behaviour: FN 0…20 N, shear ±20 N (and raw)."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
assert panel._fixed_y_range(0) == (0.0, 1000000.0)
|
||||
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
|
||||
|
||||
panel.set_units("N")
|
||||
panel.set_fixed_newton(True)
|
||||
assert panel._fixed_y_range(0) == (0.0, 20.0)
|
||||
assert panel._fixed_y_range(1) == (-20.0, 20.0)
|
||||
# With the lower bound at 0 nothing changes about the tick list either.
|
||||
assert _nice_ticks(0.0, 20.0, 5) == [
|
||||
(0.0, "0"),
|
||||
(5.0, "5"),
|
||||
(10.0, "10"),
|
||||
(15.0, "15"),
|
||||
(20.0, "20"),
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""The live Pause control: what it stops, and what it deliberately leaves alone.
|
||||
|
||||
Pause means "stop computing and stop the picture, keep the camera". It must never be
|
||||
confused with 停止 (which releases the device) or with the chart's own browse pause, and
|
||||
it must not survive a session.
|
||||
|
||||
It lives on the rail's transport button — the same control the replay view uses to play
|
||||
and pause a recording. ``checked`` means "acquiring and not paused", so unchecking the
|
||||
button *while a session exists* is the pause, and checking it again is the resume. That
|
||||
is how one two-state toggle carries a three-state transport: the session state, not the
|
||||
button, is what tells stopped from paused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_transport_is_idle_with_no_session(make_window):
|
||||
"""Nothing to pause: the button says it runs, and there is no pause to reach."""
|
||||
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
assert not window.act_pause.isEnabled()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_unchecking_the_transport_pauses_and_never_stops_the_device(make_window):
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
assert window.toolbar.btn_run.isChecked(), "the transport does not show acquiring"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True
|
||||
assert stuck.is_paused is True, "the worker was not asked to pause"
|
||||
assert stuck.pause_requests == 1
|
||||
assert window.act_pause.isChecked(), "the menu action did not follow the transport"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
|
||||
# The device is still open and the session is still the session.
|
||||
assert window._running and window._session_phase == "running", (
|
||||
"pausing ended the session instead of holding it"
|
||||
)
|
||||
assert "暂停" in window.statusBar().currentMessage() or (
|
||||
"Paus" in window.statusBar().currentMessage()
|
||||
)
|
||||
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
assert window._display_paused is False
|
||||
assert stuck.is_paused is False, "the worker was not asked to resume"
|
||||
assert stuck.pause_requests == 2
|
||||
assert not window.act_pause.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_unchecking_the_transport_with_no_session_does_nothing(make_window):
|
||||
"""The unchecked state only means paused *while a session exists*."""
|
||||
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
window._on_run_toggled(False)
|
||||
assert window._display_paused is False
|
||||
assert not window._running
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stop_button_is_what_ends_a_paused_session(make_window):
|
||||
"""Pause holds the session; only Stop releases the device."""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True and window._running
|
||||
assert window.toolbar.btn_stop.isEnabled(), "no way out of the paused session"
|
||||
|
||||
window.toolbar.btn_stop.click()
|
||||
assert not window._running, "Stop did not end the session"
|
||||
assert window._session_phase == "idle"
|
||||
finally:
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_the_shortcut_drives_the_same_state_as_the_transport(make_window):
|
||||
"""One authoritative control, so the two can never disagree."""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
window.act_pause.setChecked(True)
|
||||
assert window._display_paused is True
|
||||
assert stuck.is_paused is True
|
||||
assert not window.toolbar.btn_run.isChecked(), (
|
||||
"the transport did not follow the action"
|
||||
)
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
|
||||
|
||||
window.act_pause.setChecked(False)
|
||||
assert window._display_paused is False
|
||||
assert stuck.is_paused is False
|
||||
assert window.toolbar.btn_run.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_the_transport_says_which_way_it_goes(make_window):
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
acquiring = window.toolbar.btn_run.text()
|
||||
acquiring_tip = window.toolbar.btn_run.toolTip()
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
paused = window.toolbar.btn_run.text()
|
||||
paused_tip = window.toolbar.btn_run.toolTip()
|
||||
assert acquiring and paused and acquiring != paused, (acquiring, paused)
|
||||
assert acquiring == t("toolbar.pause")
|
||||
assert paused == t("toolbar.resume_label")
|
||||
# The tooltips explain what each state does (the label cannot) — the pause
|
||||
# one including the gap a pause leaves in a recording.
|
||||
assert acquiring_tip == t("toolbar.pause_tip")
|
||||
assert paused_tip == t("toolbar.resume_label_tip")
|
||||
gap_tip = t("toolbar.pause_tip")
|
||||
assert "gap" in gap_tip or "空档" in gap_tip, gap_tip
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_stopping_a_paused_session_clears_the_pause(make_window):
|
||||
"""The next run must compute: a latched pause would look like a dead device."""
|
||||
window, stuck = running_window(make_window)
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True
|
||||
|
||||
window._stop_run(reason="user_stop")
|
||||
assert window._display_paused is False
|
||||
assert not window.act_pause.isChecked()
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
# The session is over, so the transport is idle again — not paused.
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
stuck.release()
|
||||
@@ -13,7 +13,6 @@ window-level tests): these are the *rules*, not the decoder.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio import replay_controller
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user