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/
|
||||||
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
|
# IDE / OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.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
|
- **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.
|
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
|
- **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
|
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
|
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:
|
Both work with no display, no controller and no DLL:
|
||||||
|
|
||||||
```powershell
|
```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 --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 --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 .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
|
||||||
python -m pytest apps\autocal\tests # offscreen suite
|
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
|
`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
|
`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
|
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
|
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
|
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
|
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
|
`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
|
1920×1080, asserts that no widget escapes its parent, that motion is
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"file": "File",
|
"file": "File",
|
||||||
|
"device_setup": "Device setup…",
|
||||||
"open_bas": "Download BAS init program…",
|
"open_bas": "Download BAS init program…",
|
||||||
"reveal_config": "Show config in Explorer…",
|
"reveal_config": "Show config in Explorer…",
|
||||||
"quit": "Quit",
|
"quit": "Quit",
|
||||||
@@ -27,6 +28,45 @@
|
|||||||
"help": "Help",
|
"help": "Help",
|
||||||
"about": "About AutoCal"
|
"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": {
|
"connection": {
|
||||||
"title": "Controller connection",
|
"title": "Controller connection",
|
||||||
"mode": "Connection",
|
"mode": "Connection",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
|
"device_setup": "设备设置…",
|
||||||
"open_bas": "下载 BAS 初始化程序…",
|
"open_bas": "下载 BAS 初始化程序…",
|
||||||
"reveal_config": "在资源管理器中显示配置…",
|
"reveal_config": "在资源管理器中显示配置…",
|
||||||
"quit": "退出",
|
"quit": "退出",
|
||||||
@@ -27,6 +28,45 @@
|
|||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"about": "关于 AutoCal"
|
"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": {
|
"connection": {
|
||||||
"title": "连接控制器",
|
"title": "连接控制器",
|
||||||
"mode": "连接方式",
|
"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,
|
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.
|
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
|
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
|
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
|
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
|
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.
|
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
|
**Re-initialising without a download:** `STOPTASK <task>` then
|
||||||
`RUN "<file>",<task>`, on the task from `bas_task` in `preference.json`
|
`RUN "<file>",<task>`, on the task from `bas_task` in `preference.json`
|
||||||
(default 4 — the task the vendor reference uses; stopping another task restarts
|
(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
|
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||||
guard violation.
|
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
|
## 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:]))
|
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||||
_force_utf8_streams()
|
_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.compat import check_shared_compat
|
||||||
from orisys_app_shared.i18n import set_locale
|
from orisys_app_shared.i18n import set_locale
|
||||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||||
from orisys_app_shared.theme import build_qss
|
from orisys_app_shared.theme import build_qss
|
||||||
from PySide6.QtWidgets import QApplication
|
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 .motion import ConnectionSpec, DemoTransport
|
||||||
from .preferences import load_preferences
|
from .preferences import load_preferences
|
||||||
from .ui.window import AutoCalMainWindow
|
from .ui.window import AutoCalMainWindow
|
||||||
|
|||||||
@@ -16,7 +16,22 @@ from .models import (
|
|||||||
Snapshot,
|
Snapshot,
|
||||||
)
|
)
|
||||||
from .poller import MotionPoller
|
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 .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||||
from .units import (
|
from .units import (
|
||||||
DEFAULT_ACCEL,
|
DEFAULT_ACCEL,
|
||||||
@@ -33,7 +48,9 @@ from .units import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AXIS_ROLES",
|
||||||
"DEFAULT_ACCEL",
|
"DEFAULT_ACCEL",
|
||||||
|
"DEFAULT_AXIS_ROLES",
|
||||||
"DEFAULT_BAS_TASK",
|
"DEFAULT_BAS_TASK",
|
||||||
"DEFAULT_SPEED",
|
"DEFAULT_SPEED",
|
||||||
"DEFAULT_UNITS",
|
"DEFAULT_UNITS",
|
||||||
@@ -55,12 +72,18 @@ __all__ = [
|
|||||||
"MotionPoller",
|
"MotionPoller",
|
||||||
"MotionResult",
|
"MotionResult",
|
||||||
"MotionService",
|
"MotionService",
|
||||||
|
"MovePlan",
|
||||||
|
"MoveStep",
|
||||||
"NativeTransport",
|
"NativeTransport",
|
||||||
|
"PoseMover",
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
"ZmcTransport",
|
"ZmcTransport",
|
||||||
"clamp_bas_task",
|
"clamp_bas_task",
|
||||||
"deg_to_user_units",
|
"deg_to_user_units",
|
||||||
"mm_to_user_units",
|
"mm_to_user_units",
|
||||||
|
"normalize_axis_roles",
|
||||||
|
"plan_pose_move",
|
||||||
|
"plan_return_to_initial",
|
||||||
"safe_float",
|
"safe_float",
|
||||||
"units_per_degree",
|
"units_per_degree",
|
||||||
"user_units_to_deg",
|
"user_units_to_deg",
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ class PlatformStack:
|
|||||||
|
|
||||||
def alignment_branches(
|
def alignment_branches(
|
||||||
self, unit: np.ndarray, tool: np.ndarray
|
self, unit: np.ndarray, tool: np.ndarray
|
||||||
) -> list["Branch"]:
|
) -> list[Branch]:
|
||||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||||
|
|
||||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
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))
|
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||||
|
|
||||||
Every branch aligns to within floating point, so alignment itself rarely
|
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
|
stages is to align. The tie only decides between poses that are the same
|
||||||
to any tolerance the machine can hold.
|
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
|
# Alignment first: if one branch genuinely aligns better, that is the
|
||||||
# answer, because aligning is what the stages are for. Reachability and
|
# answer, because aligning is what the stages are for. Reachability and
|
||||||
# orientation decide between branches that align the same, which is the
|
# orientation decide between branches that align the same, which is the
|
||||||
@@ -1051,7 +1051,7 @@ class PlatformStack:
|
|||||||
def _better_pose(
|
def _better_pose(
|
||||||
candidate: tuple[float, float, float],
|
candidate: tuple[float, float, float],
|
||||||
best: tuple[float, float, float],
|
best: tuple[float, float, float],
|
||||||
stack: "PlatformStack",
|
stack: PlatformStack,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Rank two ``(residual, yaw, pitch)`` candidates.
|
"""Rank two ``(residual, yaw, pitch)`` candidates.
|
||||||
|
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ class MotionResult:
|
|||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def success(cls, message: str = "") -> "MotionResult":
|
def success(cls, message: str = "") -> MotionResult:
|
||||||
return cls(ok=True, code=0, message=message)
|
return cls(ok=True, code=0, message=message)
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""The command was not sent: a guard rejected it before any motion."""
|
||||||
return cls(ok=False, code=0, message=message)
|
return cls(ok=False, code=0, message=message)
|
||||||
|
|
||||||
@classmethod
|
@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)
|
return cls(ok=False, code=int(code), message=message)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Contract:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QMetaObject,
|
QMetaObject,
|
||||||
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
|
|||||||
# closes immediately and leaves the platform coasting.
|
# closes immediately and leaves the platform coasting.
|
||||||
service.stop_all()
|
service.stop_all()
|
||||||
service.disconnect()
|
service.disconnect()
|
||||||
return None
|
|
||||||
|
|
||||||
return self.submit(_disconnect)
|
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.
|
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
|
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 —
|
for a stated reason — bus not initialised, axis not idle, non-numeric input, a
|
||||||
and :meth:`MotionService.jog` and friends return that reason as a
|
target past the P0 travel limit, an axis mapping that does not add up — and
|
||||||
``MotionResult`` instead of silently doing nothing.
|
:meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
|
||||||
|
instead of silently doing nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
|
||||||
import os
|
import os
|
||||||
from typing import Callable, Iterable
|
import time
|
||||||
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
|
|
||||||
from orisys_app_shared.zmotion import ZAuxError
|
from orisys_app_shared.zmotion import ZAuxError
|
||||||
|
|
||||||
@@ -34,13 +35,14 @@ from .models import (
|
|||||||
MotionResult,
|
MotionResult,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
)
|
)
|
||||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
from .transport import NativeTransport, ZmcTransport
|
||||||
from .units import (
|
from .units import (
|
||||||
DEFAULT_ACCEL,
|
|
||||||
DEFAULT_SPEED,
|
|
||||||
DEFAULT_UNITS,
|
DEFAULT_UNITS,
|
||||||
PULSES_PER_MM,
|
PULSES_PER_MM,
|
||||||
|
deg_to_user_units,
|
||||||
|
mm_to_user_units,
|
||||||
safe_float,
|
safe_float,
|
||||||
|
units_per_degree,
|
||||||
user_units_to_mm,
|
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.
|
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||||
PLATFORM_AXES = 5
|
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`` values written by the vendor ``.bas`` programs.
|
||||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
_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
|
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:
|
def _online_failure(reply: str) -> str:
|
||||||
"""The controller's own refusal text from an online command, or ``""``.
|
"""The controller's own refusal text from an online command, or ``""``.
|
||||||
|
|
||||||
@@ -107,6 +156,8 @@ class MotionService:
|
|||||||
pulses_per_mm: float = PULSES_PER_MM,
|
pulses_per_mm: float = PULSES_PER_MM,
|
||||||
language: str = "zh_CN",
|
language: str = "zh_CN",
|
||||||
bas_task: int = DEFAULT_BAS_TASK,
|
bas_task: int = DEFAULT_BAS_TASK,
|
||||||
|
axis_roles: Mapping[str, int] | None = None,
|
||||||
|
limits: Mapping[str, float] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._transport: ZmcTransport = transport or NativeTransport()
|
self._transport: ZmcTransport = transport or NativeTransport()
|
||||||
self.axis_count = int(axis_count)
|
self.axis_count = int(axis_count)
|
||||||
@@ -114,6 +165,14 @@ class MotionService:
|
|||||||
self._pulses_per_mm = pulses_per_mm
|
self._pulses_per_mm = pulses_per_mm
|
||||||
self.language = language
|
self.language = language
|
||||||
self.bas_task = clamp_bas_task(bas_task)
|
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._spec: ConnectionSpec | None = None
|
||||||
self._bus = BusStatus()
|
self._bus = BusStatus()
|
||||||
@@ -151,6 +210,152 @@ class MotionService:
|
|||||||
def params(self, axis: int) -> AxisParams:
|
def params(self, axis: int) -> AxisParams:
|
||||||
return self._params.get(axis, 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:
|
def set_language(self, language: str) -> None:
|
||||||
self.language = language
|
self.language = language
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from typing import Any
|
|||||||
from orisys_app_shared import prefs as _prefs
|
from orisys_app_shared import prefs as _prefs
|
||||||
from orisys_app_shared.paths import preference_path
|
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
|
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||||
|
|
||||||
#: Connection modes, in the order the combo offers them.
|
#: 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
|
``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.
|
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 {
|
return {
|
||||||
"max_travel_mm": 100.0,
|
"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
|
#: How far the axis may descend looking for contact before the run is
|
||||||
#: aborted (workflow P7, no-contact abort).
|
#: aborted (workflow P7, no-contact abort).
|
||||||
"contact_search_mm": 3.0,
|
"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",
|
"sensor_type": "box",
|
||||||
"config_ref": "",
|
"config_ref": "",
|
||||||
"geometry_id": "",
|
"geometry_id": "",
|
||||||
#: Which axis presses, and the probe tip offset from the platform's
|
#: The probe tip's offset from the platform's reference point. Empty offset
|
||||||
#: reference point. Empty offset means "not set", which blocks P5.
|
#: means "not set", which blocks P5. Which axis presses is **not** here: it
|
||||||
"press_axis": 2,
|
#: 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_offset_mm": [0.0, 0.0, 0.0],
|
||||||
"probe_tip_set": False,
|
"probe_tip_set": False,
|
||||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
#: 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) --------------
|
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||||
#: P0: limits enforced from P2 onward.
|
#: P0: limits enforced from P2 onward.
|
||||||
"safety_limits": _default_safety_limits(),
|
"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.
|
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||||
"sensor_setup": _default_sensor_setup(),
|
"sensor_setup": _default_sensor_setup(),
|
||||||
#: P5: grid generation and run parameters.
|
#: 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]:
|
def load_preferences() -> dict[str, Any]:
|
||||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
"""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.
|
# A stored document from an older build may miss a newly added axis block.
|
||||||
axis_params = prefs.setdefault("axis_params", {})
|
axis_params = prefs.setdefault("axis_params", {})
|
||||||
for axis, values in _default_axis_params().items():
|
for axis, values in _default_axis_params().items():
|
||||||
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
|
|||||||
prefs["finger_tool_xy_mm"] = tool
|
prefs["finger_tool_xy_mm"] = tool
|
||||||
# A hand-edited task number must not reach the controller as typed.
|
# A hand-edited task number must not reach the controller as typed.
|
||||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
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
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
|
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
|
||||||
|
|
||||||
from .export_bundle import BundleError, SensorBundle, load_bundle
|
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 (
|
from .uv_flat import (
|
||||||
DEFAULT_FLAT_SIZE,
|
DEFAULT_FLAT_SIZE,
|
||||||
FlatSurface,
|
FlatSurface,
|
||||||
@@ -12,8 +13,8 @@ from .uv_flat import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AXIS_MAP",
|
"AXIS_MAP",
|
||||||
"BundleError",
|
|
||||||
"DEFAULT_FLAT_SIZE",
|
"DEFAULT_FLAT_SIZE",
|
||||||
|
"BundleError",
|
||||||
"FlatSurface",
|
"FlatSurface",
|
||||||
"SensorBundle",
|
"SensorBundle",
|
||||||
"describe_frames",
|
"describe_frames",
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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.
|
#: The version this build writes, and the highest it will read.
|
||||||
FORMAT_VERSION = "1.0"
|
FORMAT_VERSION = "1.0"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QBrush, QColor
|
from PySide6.QtGui import QBrush, QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from ..motion import (
|
from ..motion import (
|
||||||
DEFAULT_ACCEL,
|
DEFAULT_ACCEL,
|
||||||
DEFAULT_SPEED,
|
DEFAULT_SPEED,
|
||||||
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
|
|||||||
if not units:
|
if not units:
|
||||||
return "—"
|
return "—"
|
||||||
millimetres = user_units_to_mm(user_units, units=units)
|
millimetres = user_units_to_mm(user_units, units=units)
|
||||||
if millimetres != millimetres: # NaN
|
if math.isnan(millimetres):
|
||||||
return "—"
|
return "—"
|
||||||
return f"{millimetres:.3f}"
|
return f"{millimetres:.3f}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
"""The permanent axis readout rail.
|
"""The permanent axis readout rail.
|
||||||
|
|
||||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
Read-only apart from four platform-wide actions. Every per-axis *command* lives in
|
||||||
there is exactly one place a move can be issued from — two places to jog from is
|
the Axis-debug tab, so there is exactly one place a single-axis move can be issued
|
||||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
from — two places to jog from is how an operator moves the wrong axis. What this rail
|
||||||
operator needs while working in any other tab: whether each drive is enabled,
|
adds is the thing the operator needs while working in any other tab: whether each
|
||||||
where it is, and whether it is moving.
|
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
|
The four global actions are here rather than in a tab because they are the ones you
|
||||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
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
|
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.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
from orisys_app_shared.theme import load_colors
|
|
||||||
|
|
||||||
from ..motion import Snapshot, user_units_to_mm
|
from ..motion import Snapshot, user_units_to_mm
|
||||||
|
|
||||||
#: Columns of the compact readout: axis, enable state, position.
|
#: Columns of the compact readout: axis, enable state, position.
|
||||||
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
|
|||||||
enableAllRequested = Signal(bool)
|
enableAllRequested = Signal(bool)
|
||||||
stopAllRequested = Signal()
|
stopAllRequested = Signal()
|
||||||
clearAllRequested = 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:
|
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
|
|||||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||||
self.clear_all_button = QPushButton(self)
|
self.clear_all_button = QPushButton(self)
|
||||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
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)
|
button.setProperty("rail", True)
|
||||||
outer.addWidget(button)
|
outer.addWidget(button)
|
||||||
|
|
||||||
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
|
|||||||
|
|
||||||
def set_connected(self, connected: bool) -> None:
|
def set_connected(self, connected: bool) -> None:
|
||||||
self._connected = bool(connected)
|
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)
|
button.setEnabled(self._connected)
|
||||||
|
|
||||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
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
|
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||||
units_text = f"{state.dpos:.3f}"
|
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}")
|
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||||
widgets["position"].setText(units_text)
|
widgets["position"].setText(units_text)
|
||||||
widgets["position"].setToolTip(
|
widgets["position"].setToolTip(
|
||||||
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
|
|||||||
self.setTitle(t("readout.title"))
|
self.setTitle(t("readout.title"))
|
||||||
self.stop_all_button.setText(t("axes.stop_all"))
|
self.stop_all_button.setText(t("axes.stop_all"))
|
||||||
self.clear_all_button.setText(t("axes.clear_alarm_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.
|
# The toggle's label depends on current state, so re-derive it.
|
||||||
self.update_snapshot(self._snapshot)
|
self.update_snapshot(self._snapshot)
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ controller is reachable.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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.i18n import t
|
||||||
from orisys_app_shared.paths import logo_topbar_path
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
from orisys_app_shared.theme import load_colors
|
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.
|
#: Chip tone -> colour token name.
|
||||||
_TONE_TOKENS = {
|
_TONE_TOKENS = {
|
||||||
@@ -27,6 +26,11 @@ _TONE_TOKENS = {
|
|||||||
class BrandBar(QFrame):
|
class BrandBar(QFrame):
|
||||||
"""The identity strip, with a state chip that reports the connection."""
|
"""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:
|
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("brandBar")
|
self.setObjectName("brandBar")
|
||||||
@@ -67,10 +71,21 @@ class BrandBar(QFrame):
|
|||||||
|
|
||||||
self.state_chip = QLabel(self)
|
self.state_chip = QLabel(self)
|
||||||
self.state_chip.setObjectName("stateChip")
|
self.state_chip.setObjectName("stateChip")
|
||||||
|
self.state_chip.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
layout.addWidget(self.state_chip)
|
layout.addWidget(self.state_chip)
|
||||||
|
|
||||||
self.retranslate()
|
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
|
# ----------------------------------------------------------------- content
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
@@ -78,6 +93,7 @@ class BrandBar(QFrame):
|
|||||||
self.name.setText(t("brand.name"))
|
self.name.setText(t("brand.name"))
|
||||||
self.detail.setText(t("brand.detail"))
|
self.detail.setText(t("brand.detail"))
|
||||||
self.product.setText(t("app.title"))
|
self.product.setText(t("app.title"))
|
||||||
|
self.state_chip.setToolTip(t("setup.chip_hint", name=t("connection.title")))
|
||||||
self.set_connected(self._connected)
|
self.set_connected(self._connected)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
#: 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
|
#: 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.
|
#: 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 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.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
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
|
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||||
|
|
||||||
_STATE_KEYS = {
|
_STATE_KEYS = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from ..motion import ConnectionSpec
|
from ..motion import ConnectionSpec
|
||||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
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 ..motion.kinematics import PlatformStack, Pose, rotation_about
|
||||||
from ..sensor.export_bundle import SensorBundle
|
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 drag sensitivity and zoom step, matching the design repo's preview.
|
||||||
ORBIT_RAD_PER_PX = 0.008
|
ORBIT_RAD_PER_PX = 0.008
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import math
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import QPoint, Qt, Signal
|
from PySide6.QtCore import QPoint, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from ..motion.kinematics import PlatformStack
|
from ..motion.kinematics import PlatformStack
|
||||||
from ..sensor.export_bundle import SensorBundle
|
from ..sensor.export_bundle import SensorBundle
|
||||||
from ..sensor.frames import describe as describe_frames
|
from ..sensor.frames import describe as describe_frames
|
||||||
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
|
|||||||
loadRequested = Signal()
|
loadRequested = Signal()
|
||||||
pickRequested = Signal(int, int)
|
pickRequested = Signal(int, int)
|
||||||
stackChanged = Signal()
|
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
|
#: 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 —
|
#: 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.
|
# colour call hid the base plane and the whole trajectory without a word.
|
||||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||||
controls.addWidget(self.sim_button)
|
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)
|
controls.addStretch(1)
|
||||||
column.addLayout(controls)
|
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
|
# 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
|
# composites a long overflowing string as an overlap of itself, and the
|
||||||
# legend was unreadable on the bench because of it. A label wraps.
|
# 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")
|
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:
|
def _sync_sim_button(self) -> None:
|
||||||
self.sim_button.setText(
|
self.sim_button.setText(
|
||||||
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play")
|
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.trajectory_check.setText(t("finger.show_trajectory"))
|
||||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||||
self._sync_sim_button()
|
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(
|
self.table.setHorizontalHeaderLabels(
|
||||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
[""] + [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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||||
from ..sequence_io import (
|
from ..sequence_io import (
|
||||||
SequencePoint,
|
SequencePoint,
|
||||||
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
|
|||||||
value = float(str(text).strip())
|
value = float(str(text).strip())
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return fallback
|
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:
|
def _format_number(value: Any) -> str:
|
||||||
|
|||||||
@@ -20,19 +20,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
from orisys_app_shared.theme import load_colors
|
|
||||||
|
|
||||||
from ..motion import Snapshot
|
from ..motion import Snapshot
|
||||||
|
|
||||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||||
@@ -66,7 +65,17 @@ _FAILED_INTERVALS = 10.0
|
|||||||
|
|
||||||
|
|
||||||
class _Chip(QLabel):
|
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:
|
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -75,6 +84,18 @@ class _Chip(QLabel):
|
|||||||
self._tone = "muted"
|
self._tone = "muted"
|
||||||
self.set_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:
|
def set_theme(self, theme: str) -> None:
|
||||||
self._theme = theme
|
self._theme = theme
|
||||||
self.set_tone(self._tone)
|
self.set_tone(self._tone)
|
||||||
@@ -93,6 +114,9 @@ class StatusStrip(QFrame):
|
|||||||
|
|
||||||
pauseRequested = Signal()
|
pauseRequested = Signal()
|
||||||
stopAllRequested = 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:
|
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -133,6 +157,24 @@ class StatusStrip(QFrame):
|
|||||||
self.action_label.setObjectName("stripAction")
|
self.action_label.setObjectName("stripAction")
|
||||||
layout.addWidget(self.action_label)
|
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 = QPushButton(self)
|
||||||
self.pause_button.setProperty("rail", True)
|
self.pause_button.setProperty("rail", True)
|
||||||
self.pause_button.setCheckable(True)
|
self.pause_button.setCheckable(True)
|
||||||
@@ -268,8 +310,15 @@ class StatusStrip(QFrame):
|
|||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
self.title.setText(t("status.motion"))
|
self.title.setText(t("status.motion"))
|
||||||
|
self.setup_button.setText(t("setup.open"))
|
||||||
self._refresh_pause_text()
|
self._refresh_pause_text()
|
||||||
self.stop_button.setText(t("axes.stop_all"))
|
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.set_snapshot(self._snapshot)
|
||||||
self.refresh_health()
|
self.refresh_health()
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from orisys_app_shared import API_VERSION
|
||||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
from orisys_app_shared import __version__ as shared_version
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
from orisys_app_shared.paths import outputs_dir
|
from orisys_app_shared.paths import outputs_dir
|
||||||
from orisys_app_shared.theme import build_qss, load_colors
|
from orisys_app_shared.theme import build_qss, load_colors
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PySide6.QtGui import QAction, QCloseEvent
|
from PySide6.QtGui import QAction, QCloseEvent
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import __version__ as app_version
|
from .. import __version__ as app_version
|
||||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||||
from ..motion.kinematics import PlatformStack
|
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.service import clamp_bas_task
|
||||||
|
from ..motion.units import DEFAULT_UNITS
|
||||||
from ..preferences import load_preferences, save_preferences
|
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 ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
||||||
from ..sequence_io import (
|
from ..sequence_io import (
|
||||||
FILE_SUFFIX,
|
FILE_SUFFIX,
|
||||||
@@ -56,8 +63,8 @@ from .axis_panel import AxisPanel
|
|||||||
from .axis_readout import AxisReadoutPanel
|
from .axis_readout import AxisReadoutPanel
|
||||||
from .brand_bar import BrandBar
|
from .brand_bar import BrandBar
|
||||||
from .bus_data_panel import BusDataPanel
|
from .bus_data_panel import BusDataPanel
|
||||||
from .bus_panel import BusPanel, default_bas_dir
|
from .bus_panel import default_bas_dir
|
||||||
from .connection_panel import ConnectionPanel
|
from .device_setup import DeviceSetupDialog
|
||||||
from .sensor_panel import FingerPanel
|
from .sensor_panel import FingerPanel
|
||||||
from .sequence_panel import SequencePanel
|
from .sequence_panel import SequencePanel
|
||||||
from .status_strip import RunBar, StatusStrip
|
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
|
#: 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
|
#: difference between a workable sequence page and a cramped one at the minimum
|
||||||
#: window width.
|
#: 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, ...]:
|
def _triple(value: object, length: int) -> tuple[float, ...]:
|
||||||
@@ -138,6 +149,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self._build_poller()
|
self._build_poller()
|
||||||
|
|
||||||
self._restore_geometry()
|
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()
|
self.retranslate()
|
||||||
|
|
||||||
# Load the remembered bundle, so the finger debugger comes up working.
|
# Load the remembered bundle, so the finger debugger comes up working.
|
||||||
@@ -174,31 +189,45 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
layout.addWidget(body, stretch=1)
|
layout.addWidget(body, stretch=1)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- rail
|
# ----------------------------------------------------------------- rail
|
||||||
# One rail, not two. It holds setup, the axis readout and the collapsed
|
# One rail, not two, and it holds only what is read while working: the axis
|
||||||
# bus data, and it scrolls: at 900 px high those three groups do not fit,
|
# readout and the collapsed bus-data monitor. Connection and bus
|
||||||
# and a rail that clips its own controls is worse than one that scrolls.
|
# 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()
|
self.rail_content = QWidget()
|
||||||
left = QVBoxLayout(self.rail_content)
|
left = QVBoxLayout(self.rail_content)
|
||||||
left.setContentsMargins(0, 0, 0, 0)
|
left.setContentsMargins(0, 0, 0, 0)
|
||||||
left.setSpacing(8)
|
left.setSpacing(8)
|
||||||
|
|
||||||
connection = self.prefs.get("connection") or {}
|
connection = self.prefs.get("connection") or {}
|
||||||
self.connection_panel = ConnectionPanel(
|
self.spec = ConnectionSpec(
|
||||||
ConnectionSpec(
|
|
||||||
mode=str(connection.get("mode") or "eth"),
|
mode=str(connection.get("mode") or "eth"),
|
||||||
ip=str(connection.get("ip") or ""),
|
ip=str(connection.get("ip") or ""),
|
||||||
com_id=int(connection.get("com_id") or 0),
|
com_id=int(connection.get("com_id") or 0),
|
||||||
card_num=int(connection.get("card_num") or 0),
|
card_num=int(connection.get("card_num") or 0),
|
||||||
local_string=str(connection.get("local_string") or "0"),
|
local_string=str(connection.get("local_string") or "0"),
|
||||||
),
|
|
||||||
self.rail_content,
|
|
||||||
)
|
)
|
||||||
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(
|
self.axis_readout = AxisReadoutPanel(
|
||||||
MotionService().axis_count, self._theme, self.rail_content
|
MotionService().axis_count, self._theme, self.rail_content
|
||||||
)
|
)
|
||||||
left.addWidget(self.connection_panel)
|
|
||||||
left.addWidget(self.bus_panel)
|
|
||||||
left.addWidget(self.axis_readout)
|
left.addWidget(self.axis_readout)
|
||||||
|
|
||||||
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
|
# 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.setWidgetResizable(True)
|
||||||
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||||
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.left_rail.setMinimumWidth(300)
|
self.left_rail.setMinimumWidth(280)
|
||||||
|
|
||||||
# ------------------------------------------------------------ workspace
|
# ------------------------------------------------------------ workspace
|
||||||
params = {
|
params = {
|
||||||
@@ -303,12 +332,23 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
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
|
# The readout rail's own batch buttons, connected once here. They were
|
||||||
# connected from inside the finger screen's stack-changed handler, which
|
# connected from inside the finger screen's stack-changed handler, which
|
||||||
# meant every edit on that screen added another copy.
|
# meant every edit on that screen added another copy.
|
||||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
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.runRequested.connect(self._on_run_requested)
|
||||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
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.pickRequested.connect(self._on_pick_point)
|
||||||
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
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
|
# ---------------------------------------------------------- finger debug
|
||||||
|
|
||||||
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
|
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()
|
menu_bar = self.menuBar()
|
||||||
|
|
||||||
self.menu_file = menu_bar.addMenu("")
|
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 = QAction("", self)
|
||||||
self.act_download_bas.triggered.connect(self._on_download_bas)
|
self.act_download_bas.triggered.connect(self._on_download_bas)
|
||||||
self.act_reveal_config = QAction("", self)
|
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_reveal_outputs.triggered.connect(self._on_reveal_outputs)
|
||||||
self.act_quit = QAction("", self)
|
self.act_quit = QAction("", self)
|
||||||
self.act_quit.triggered.connect(self.close)
|
self.act_quit.triggered.connect(self.close)
|
||||||
|
self.menu_file.addAction(self.act_device_setup)
|
||||||
|
self.menu_file.addSeparator()
|
||||||
for action in (
|
for action in (
|
||||||
self.act_download_bas,
|
self.act_download_bas,
|
||||||
self.act_reveal_config,
|
self.act_reveal_config,
|
||||||
@@ -688,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
MotionService(
|
MotionService(
|
||||||
language=self._language,
|
language=self._language,
|
||||||
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
|
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),
|
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.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))
|
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
|
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||||
# so a stopped poll becomes visible instead of leaving stale chips
|
# 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.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||||
self.axis_panel.update_snapshot(snapshot)
|
self.axis_panel.update_snapshot(snapshot)
|
||||||
self.axis_readout.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
|
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||||
# of which tab is showing.
|
# of which tab is showing.
|
||||||
self.status_strip.set_snapshot(snapshot)
|
self.status_strip.set_snapshot(snapshot)
|
||||||
@@ -760,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
if self.diagnostics_box.isVisible():
|
if self.diagnostics_box.isVisible():
|
||||||
self._refresh_diagnostics()
|
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:
|
def _refresh_status_bar(self) -> None:
|
||||||
snapshot = self._last_snapshot
|
snapshot = self._last_snapshot
|
||||||
if not snapshot.connected:
|
if not snapshot.connected:
|
||||||
@@ -911,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
def _stop_all(service: MotionService) -> object:
|
def _stop_all(service: MotionService) -> object:
|
||||||
return service.stop_all()
|
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)
|
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:
|
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||||
|
|
||||||
@@ -1039,8 +1253,9 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.brand_bar.retranslate()
|
self.brand_bar.retranslate()
|
||||||
self.status_strip.retranslate()
|
self.status_strip.retranslate()
|
||||||
self.run_bar.retranslate()
|
self.run_bar.retranslate()
|
||||||
self.connection_panel.retranslate()
|
# One call into the dialog, which retranslates its own title, tabs and both
|
||||||
self.bus_panel.retranslate()
|
# 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_group.setTitle(t("busdata.group"))
|
||||||
self.bus_data_panel.retranslate()
|
self.bus_data_panel.retranslate()
|
||||||
self.axis_panel.retranslate()
|
self.axis_panel.retranslate()
|
||||||
@@ -1050,6 +1265,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.workspace.retranslate()
|
self.workspace.retranslate()
|
||||||
|
|
||||||
self.menu_file.setTitle(t("menu.file"))
|
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_download_bas.setText(t("menu.open_bas"))
|
||||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||||
self.act_reveal_outputs.setText(t("menu.outputs"))
|
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["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
|
||||||
self.prefs["bas_path"] = self.bus_panel.bas_path
|
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["units_per_mm"] = self.axis_panel._units_per_mm
|
||||||
self.prefs["axis_params"] = {
|
self.prefs["axis_params"] = {
|
||||||
str(axis): _params_to_dict(params)
|
str(axis): _params_to_dict(params)
|
||||||
@@ -1136,7 +1356,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.poller.shutdown()
|
self.poller.shutdown()
|
||||||
except Exception: # noqa: BLE001 - closing must always finish
|
except Exception: # noqa: BLE001 - closing must always finish
|
||||||
logger.exception("Shutdown reported a problem")
|
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._persist_prefs()
|
||||||
|
self.device_setup.close()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ will fill it, so nobody mistakes an empty page for a broken one.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from .axis_panel import AxisPanel
|
from .axis_panel import AxisPanel
|
||||||
from .sequence_panel import SequencePanel
|
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 paths as paths_mod
|
||||||
from orisys_app_shared import prefs as prefs_pkg
|
from orisys_app_shared import prefs as prefs_pkg
|
||||||
|
|
||||||
from orisys_autocal import preferences as prefs_mod
|
from orisys_autocal import preferences as prefs_mod
|
||||||
|
|
||||||
scratch = _scratch_dir()
|
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 paths as paths_mod
|
||||||
from orisys_app_shared import prefs as prefs_pkg
|
from orisys_app_shared import prefs as prefs_pkg
|
||||||
|
|
||||||
from orisys_autocal import preferences as prefs_mod
|
from orisys_autocal import preferences as prefs_mod
|
||||||
from orisys_autocal.motion import DemoTransport
|
from orisys_autocal.motion import DemoTransport
|
||||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ _SCOPED_ENUMS = {
|
|||||||
"QMessageBox": ("StandardButton", "Icon"),
|
"QMessageBox": ("StandardButton", "Icon"),
|
||||||
"QDialog": ("DialogCode",),
|
"QDialog": ("DialogCode",),
|
||||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||||
"QDialogButtonBox": ("StandardButton",),
|
"QDialogButtonBox": ("StandardButton", "ButtonRole"),
|
||||||
"QPainter": ("RenderHint", "CompositionMode"),
|
"QPainter": ("RenderHint", "CompositionMode"),
|
||||||
"QImage": ("Format",),
|
"QImage": ("Format",),
|
||||||
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.motion import (
|
from orisys_autocal.motion import (
|
||||||
DEFAULT_BAS_TASK,
|
DEFAULT_BAS_TASK,
|
||||||
INIT_OK,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.motion import errors
|
from orisys_autocal.motion import errors
|
||||||
|
|
||||||
DOCUMENTED = sorted(errors.MESSAGES)
|
DOCUMENTED = sorted(errors.MESSAGES)
|
||||||
|
|||||||
@@ -11,17 +11,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import QPointF
|
|
||||||
|
|
||||||
from orisys_autocal.motion.kinematics import PlatformStack
|
from orisys_autocal.motion.kinematics import PlatformStack
|
||||||
from orisys_autocal.sensor import (
|
from orisys_autocal.sensor import (
|
||||||
DEFAULT_FLAT_SIZE,
|
DEFAULT_FLAT_SIZE,
|
||||||
load_bundle,
|
load_bundle,
|
||||||
rasterise,
|
rasterise,
|
||||||
to_platform,
|
|
||||||
to_sensor,
|
to_sensor,
|
||||||
)
|
)
|
||||||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||||||
|
from PySide6.QtCore import QPointF
|
||||||
|
|
||||||
BUNDLE = Path(
|
BUNDLE = Path(
|
||||||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
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 paths as paths_mod
|
||||||
from orisys_app_shared import prefs as prefs_pkg
|
from orisys_app_shared import prefs as prefs_pkg
|
||||||
from orisys_app_shared.i18n import set_locale
|
from orisys_app_shared.i18n import set_locale
|
||||||
|
|
||||||
from orisys_autocal import preferences as prefs_mod
|
from orisys_autocal import preferences as prefs_mod
|
||||||
from orisys_autocal.ui import window as window_mod
|
from orisys_autocal.ui import window as window_mod
|
||||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
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.
|
right, because getting it wrong draws the tool axis pointing down.
|
||||||
"""
|
"""
|
||||||
from orisys_autocal.sensor import to_sensor
|
from orisys_autocal.sensor import to_sensor
|
||||||
|
|
||||||
from orisys_autocal.ui.pose_preview import PosePreview
|
from orisys_autocal.ui.pose_preview import PosePreview
|
||||||
|
|
||||||
preview = 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):
|
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."""
|
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||||||
from orisys_autocal.sensor import to_sensor
|
from orisys_autocal.sensor import to_sensor
|
||||||
|
|
||||||
from orisys_autocal.ui.pose_preview import PosePreview
|
from orisys_autocal.ui.pose_preview import PosePreview
|
||||||
|
|
||||||
preview = PosePreview()
|
preview = PosePreview()
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.motion.kinematics import (
|
from orisys_autocal.motion.kinematics import (
|
||||||
PlatformStack,
|
|
||||||
Branch,
|
Branch,
|
||||||
|
PlatformStack,
|
||||||
rotation_about,
|
rotation_about,
|
||||||
)
|
)
|
||||||
from orisys_autocal.sensor import load_bundle
|
from orisys_autocal.sensor import load_bundle
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import dataclasses
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.motion.kinematics import (
|
from orisys_autocal.motion.kinematics import (
|
||||||
ALIGNED_TOL_DEG,
|
ALIGNED_TOL_DEG,
|
||||||
PlatformStack,
|
PlatformStack,
|
||||||
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
|
|||||||
rotation_about,
|
rotation_about,
|
||||||
)
|
)
|
||||||
from orisys_autocal.sensor.frames import (
|
from orisys_autocal.sensor.frames import (
|
||||||
AXIS_MAP,
|
|
||||||
describe,
|
describe,
|
||||||
mount_rotation,
|
mount_rotation,
|
||||||
to_platform,
|
to_platform,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.preferences import load_preferences
|
from orisys_autocal.preferences import load_preferences
|
||||||
|
|
||||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||||
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
|
|||||||
other = _flatten(_load(language))
|
other = _flatten(_load(language))
|
||||||
assert other == reference, {
|
assert other == reference, {
|
||||||
f"{language}_only": sorted(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
|
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 = ""):
|
def _walk(node, prefix: str = ""):
|
||||||
for key, value in node.items():
|
for key, value in node.items():
|
||||||
path = f"{prefix}{key}"
|
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 time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from orisys_autocal.motion import (
|
||||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
ConnectionSpec,
|
||||||
|
DemoTransport,
|
||||||
|
MotionPoller,
|
||||||
|
MotionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecordingTransport(DemoTransport):
|
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 numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.sensor.export_bundle import (
|
from orisys_autocal.sensor.export_bundle import (
|
||||||
BundleError,
|
BundleError,
|
||||||
load_bundle,
|
load_bundle,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.sequence_io import (
|
from orisys_autocal.sequence_io import (
|
||||||
DOCUMENT_KIND,
|
DOCUMENT_KIND,
|
||||||
FORMAT_VERSION,
|
FORMAT_VERSION,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.motion.units import (
|
from orisys_autocal.motion.units import (
|
||||||
PULSES_PER_MM,
|
PULSES_PER_MM,
|
||||||
ROTARY_UNITS_PER_90_DEG,
|
ROTARY_UNITS_PER_90_DEG,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_autocal.sensor import (
|
from orisys_autocal.sensor import (
|
||||||
DEFAULT_FLAT_SIZE,
|
DEFAULT_FLAT_SIZE,
|
||||||
load_bundle,
|
load_bundle,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import math
|
import math
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -63,7 +62,10 @@ BRAND_H = 36
|
|||||||
STRIP_H = 44
|
STRIP_H = 44
|
||||||
RUNBAR_H = 40
|
RUNBAR_H = 40
|
||||||
STATUS_H = 26
|
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
|
WORKSPACE_W = PAGE_W - RAIL_LEFT - 3 * MARGIN
|
||||||
|
|
||||||
PREVIEW_W, PREVIEW_H = 640, 340
|
PREVIEW_W, PREVIEW_H = 640, 340
|
||||||
@@ -110,6 +112,20 @@ STRINGS = {
|
|||||||
"nodes": "节点数量",
|
"nodes": "节点数量",
|
||||||
"axes_count": "轴数量",
|
"axes_count": "轴数量",
|
||||||
"busdata_group": "总线数据 (SDO / RTEX) ▶",
|
"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": ["轴调试", "测试序列", "手指调试", "传感器", "表面分布", "标定点"],
|
"tabs": ["轴调试", "测试序列", "手指调试", "传感器", "表面分布", "标定点"],
|
||||||
"readout_title": "轴状态",
|
"readout_title": "轴状态",
|
||||||
"enabled": "已使能",
|
"enabled": "已使能",
|
||||||
@@ -188,6 +204,11 @@ STRINGS = {
|
|||||||
"finger_preview_title": "三维预览",
|
"finger_preview_title": "三维预览",
|
||||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||||
"finger_play": "播放动作",
|
"finger_play": "播放动作",
|
||||||
|
"move_execute": "执行动作",
|
||||||
|
"move_stop": "停止",
|
||||||
|
"move_return": "回初始位",
|
||||||
|
"move_confirm": "移动前确认",
|
||||||
|
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
|
||||||
"finger_preview_notes": [
|
"finger_preview_notes": [
|
||||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||||
@@ -247,6 +268,20 @@ STRINGS = {
|
|||||||
"nodes": "Nodes",
|
"nodes": "Nodes",
|
||||||
"axes_count": "Axes",
|
"axes_count": "Axes",
|
||||||
"busdata_group": "Bus data (SDO / RTEX) ▶",
|
"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"],
|
"tabs": ["Axis debug", "Test sequence", "Finger debug", "Sensor", "Surface map", "Calibration points"],
|
||||||
"readout_title": "Axis state",
|
"readout_title": "Axis state",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
@@ -325,6 +360,11 @@ STRINGS = {
|
|||||||
"finger_preview_title": "3D preview",
|
"finger_preview_title": "3D preview",
|
||||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||||
"finger_play": "Play motion",
|
"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": [
|
"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.",
|
"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).",
|
"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(
|
_text(
|
||||||
painter,
|
painter,
|
||||||
QRect(PAGE_W - 600, top, 380, STRIP_H),
|
QRect(PAGE_W - 600, top, 300, STRIP_H),
|
||||||
"上次动作: 已使能轴0 / last action: enable axis 0",
|
"上次动作: 已使能轴0 / last action: enable axis 0",
|
||||||
palette,
|
palette,
|
||||||
size=10,
|
size=10,
|
||||||
muted=True,
|
muted=True,
|
||||||
align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
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 - 210, top + 10, 96, 24), s["pause"], palette)
|
||||||
_button(painter, QRect(PAGE_W - 106, top + 10, 92, 24), s["stop_all"], palette, danger=True)
|
_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:
|
def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
|
||||||
conn = QRect(rect.x(), rect.y(), rect.width(), 210)
|
"""The rail as it is now: only what is read while working.
|
||||||
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)
|
|
||||||
|
|
||||||
bus = QRect(rect.x(), rect.y() + 222, rect.width(), 250)
|
Connection and bus initialisation are in the device-setup dialog, so the rail
|
||||||
inner = _panel(painter, bus, palette)
|
starts with the axis readout instead of burying it under ~460 px of start-up
|
||||||
_box_title(painter, bus, s["bus_title"], palette)
|
controls.
|
||||||
y = inner.y() + 6
|
"""
|
||||||
_text(painter, QRect(inner.x(), y, 60, 22), s["bus_file"], palette, size=10, muted=True)
|
readout = QRect(rect.x(), rect.y(), rect.width(), 268)
|
||||||
_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)
|
|
||||||
inner = _panel(painter, readout, palette)
|
inner = _panel(painter, readout, palette)
|
||||||
_box_title(painter, readout, s["readout_title"], palette)
|
_box_title(painter, readout, s["readout_title"], palette)
|
||||||
y = inner.y() + 6
|
y = inner.y() + 6
|
||||||
@@ -629,7 +632,57 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) ->
|
|||||||
y += 24
|
y += 24
|
||||||
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette)
|
_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 + 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:
|
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)
|
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)
|
_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)
|
_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"]:
|
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)
|
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||||
notes_y += 14
|
notes_y += 14
|
||||||
@@ -1256,6 +1325,22 @@ TAB_PAINTERS = {
|
|||||||
#: Tab index -> painter key, for the tab strip labels.
|
#: Tab index -> painter key, for the tab strip labels.
|
||||||
TAB_KEYS = ("axes", "sequence", "finger", "sensor", "surface", "points")
|
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(
|
def draw_page(
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
@@ -1265,12 +1350,18 @@ def draw_page(
|
|||||||
*,
|
*,
|
||||||
active: str = "axes",
|
active: str = "axes",
|
||||||
with_chrome: bool = True,
|
with_chrome: bool = True,
|
||||||
|
with_rail: bool = True,
|
||||||
) -> None:
|
) -> 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)
|
painter.fillRect(rect, palette.window)
|
||||||
body_top = rect.y() + MENU_H + BRAND_H + STRIP_H + RUNBAR_H
|
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
|
body_h = rect.height() - (MENU_H + BRAND_H + STRIP_H + RUNBAR_H) - STATUS_H
|
||||||
|
|
||||||
|
if with_rail:
|
||||||
draw_left_rail(
|
draw_left_rail(
|
||||||
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
|
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
|
||||||
)
|
)
|
||||||
@@ -1294,6 +1385,14 @@ def draw_page(
|
|||||||
draw_status_bar(painter, palette, s, rect.bottom() - STATUS_H)
|
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:
|
def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None:
|
||||||
"""The run strip: phase, progress and the three controls."""
|
"""The run strip: phase, progress and the three controls."""
|
||||||
painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window)
|
painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window)
|
||||||
@@ -1356,7 +1455,7 @@ def main() -> int:
|
|||||||
default=TAB_KEYS[0],
|
default=TAB_KEYS[0],
|
||||||
# Derived, not listed: a literal here silently rejects any tab added to
|
# Derived, not listed: a literal here silently rejects any tab added to
|
||||||
# TAB_KEYS, which is how the finger page first went missing.
|
# 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",
|
help="which page to draw; 'all' writes one full-size image per page",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -1367,7 +1466,7 @@ def main() -> int:
|
|||||||
base = Path(args.out).resolve()
|
base = Path(args.out).resolve()
|
||||||
base.parent.mkdir(parents=True, exist_ok=True)
|
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
|
failures = 0
|
||||||
for key in pages:
|
for key in pages:
|
||||||
target = base if args.page != "all" else base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
|
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 = QPainter(pixmap)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
try:
|
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:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
if pixmap.save(str(target)):
|
if pixmap.save(str(target)):
|
||||||
|
|||||||
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
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.i18n import set_locale, t
|
||||||
from orisys_app_shared.theme import build_qss, load_colors
|
from orisys_app_shared.theme import build_qss, load_colors
|
||||||
|
|
||||||
from orisys_autocal.motion import ConnectionSpec
|
from orisys_autocal.motion import ConnectionSpec
|
||||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||||
|
from PySide6 import __version__ as pyside_version
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
checker = Checker()
|
checker = Checker()
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
@@ -115,7 +113,14 @@ def main() -> int:
|
|||||||
set_locale("zh_CN")
|
set_locale("zh_CN")
|
||||||
|
|
||||||
# ---- window ----------------------------------------------------------
|
# ---- 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"):
|
for theme in ("dark", "light"):
|
||||||
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
||||||
@@ -132,28 +137,30 @@ def main() -> int:
|
|||||||
for key in window.workspace.page_keys():
|
for key in window.workspace.page_keys():
|
||||||
window.workspace.set_current_key(key)
|
window.workspace.set_current_key(key)
|
||||||
app.processEvents()
|
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(
|
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")
|
window.workspace.set_current_key("axes")
|
||||||
print(f" {width}x{height}: every tab fits")
|
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 ----------------------------------------
|
# ---- the permanent shell ----------------------------------------
|
||||||
checker.that(
|
checker.that(
|
||||||
window.workspace.page_keys()
|
window.workspace.page_keys()
|
||||||
@@ -243,6 +250,102 @@ def main() -> int:
|
|||||||
window.left_rail.widget() is window.rail_content,
|
window.left_rail.widget() is window.rail_content,
|
||||||
"the setup rail is not scrollable",
|
"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(
|
checker.that(
|
||||||
window.run_bar.isVisible(),
|
window.run_bar.isVisible(),
|
||||||
"the run bar is not visible",
|
"the run bar is not visible",
|
||||||
@@ -347,14 +450,14 @@ def main() -> int:
|
|||||||
|
|
||||||
# A full simulated session: connect, initialise from the shipped BAS.
|
# A full simulated session: connect, initialise from the shipped BAS.
|
||||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
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(
|
checker.that(
|
||||||
window._last_snapshot.connected, "the simulated controller did not connect"
|
window._last_snapshot.connected, "the simulated controller did not connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||||
window._on_download_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")
|
checker.that(ok, "the simulated bus did not report initialised")
|
||||||
if ok:
|
if ok:
|
||||||
checker.that(
|
checker.that(
|
||||||
@@ -399,12 +502,167 @@ def main() -> int:
|
|||||||
or "5" in strip.chip_axes.text(),
|
or "5" in strip.chip_axes.text(),
|
||||||
f"strip axis chip says {strip.chip_axes.text()!r}",
|
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")
|
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:
|
try:
|
||||||
window.poller.shutdown()
|
window.poller.shutdown()
|
||||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||||
@@ -415,6 +673,31 @@ def main() -> int:
|
|||||||
return checker.report()
|
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:
|
def _inside_scroll_area(widget) -> bool:
|
||||||
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
|
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
|
||||||
from PySide6.QtWidgets import QAbstractScrollArea
|
from PySide6.QtWidgets import QAbstractScrollArea
|
||||||
|
|||||||
@@ -50,18 +50,22 @@ def main() -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="drive a simulated session so the shot shows live values",
|
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()
|
args = parser.parse_args()
|
||||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
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.i18n import set_locale
|
||||||
from orisys_app_shared.theme import build_qss
|
from orisys_app_shared.theme import build_qss
|
||||||
|
|
||||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
||||||
from orisys_autocal.preferences import load_preferences
|
from orisys_autocal.preferences import load_preferences
|
||||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
set_locale(args.language)
|
set_locale(args.language)
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
@@ -89,6 +93,25 @@ def main() -> int:
|
|||||||
window._on_move_abs(0, 1000.0)
|
window._on_move_abs(0, 1000.0)
|
||||||
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.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()
|
app.processEvents()
|
||||||
|
|
||||||
if args.compare:
|
if args.compare:
|
||||||
|
|||||||
@@ -96,14 +96,12 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from orisys_app_shared.i18n import set_locale
|
from orisys_app_shared.i18n import set_locale
|
||||||
from orisys_app_shared.theme import build_qss
|
from orisys_app_shared.theme import build_qss
|
||||||
|
|
||||||
from orisys_autocal.preferences import load_preferences
|
from orisys_autocal.preferences import load_preferences
|
||||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
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
|
# pixels, because a projection that disagrees with the render looks
|
||||||
# perfectly healthy on its own — that is how the labels drifted off
|
# 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.
|
# 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):
|
for label, point, colour in _landmarks(panel):
|
||||||
screen = panel.preview.project(point)
|
screen = panel.preview.project(point)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
"stop": "Cancel acquisition",
|
"stop": "Cancel acquisition",
|
||||||
"preview": "Preview / Stop preview",
|
"preview": "Preview / Stop preview",
|
||||||
"reset": "Reset tracker",
|
"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",
|
"help": "Help",
|
||||||
"about": "About"
|
"about": "About"
|
||||||
},
|
},
|
||||||
@@ -53,14 +56,22 @@
|
|||||||
"plot_settings_title": "Plot settings",
|
"plot_settings_title": "Plot settings",
|
||||||
"plot_window": "Time window",
|
"plot_window": "Time window",
|
||||||
"plot_window_tip": "Force plot time window (seconds)",
|
"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": "FN max",
|
||||||
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
|
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
|
||||||
"plot_shear_max": "Shear max",
|
"plot_shear_max": "Shear max",
|
||||||
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
|
"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": "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_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": "Shear raw max",
|
||||||
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
|
"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",
|
"reset_defaults": "Reset to defaults",
|
||||||
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
|
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
|
||||||
"filter_preset": "Preset",
|
"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_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)",
|
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
|
||||||
"reset": "Reset",
|
"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": "Stop",
|
||||||
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
|
"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}",
|
"stopped": "Stopped: {what}",
|
||||||
@@ -400,6 +418,9 @@
|
|||||||
"camera_disconnected": "Camera disconnected",
|
"camera_disconnected": "Camera disconnected",
|
||||||
"reset_ok": "Tracker reset",
|
"reset_ok": "Tracker reset",
|
||||||
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
|
"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",
|
"finger_run_unavailable": "Run is not available for this sensor type",
|
||||||
"video_required": "Select a video file first",
|
"video_required": "Select a video file first",
|
||||||
"config_missing": "Sensor config not found for {type}",
|
"config_missing": "Sensor config not found for {type}",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
"stop": "取消采集",
|
"stop": "取消采集",
|
||||||
"preview": "预览 / 停止预览",
|
"preview": "预览 / 停止预览",
|
||||||
"reset": "重置追踪器",
|
"reset": "重置追踪器",
|
||||||
|
"reset_view": "复位视图",
|
||||||
|
"reset_view_tip": "把曲线窗口、游标与三维视角恢复为默认(F8)。不改动设备,也不清除零点估计。",
|
||||||
|
"pause": "暂停 / 继续(F7)",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"about": "关于"
|
"about": "关于"
|
||||||
},
|
},
|
||||||
@@ -53,14 +56,22 @@
|
|||||||
"plot_settings_title": "绘图设置",
|
"plot_settings_title": "绘图设置",
|
||||||
"plot_window": "时间窗口",
|
"plot_window": "时间窗口",
|
||||||
"plot_window_tip": "力曲线时间窗口(秒)",
|
"plot_window_tip": "力曲线时间窗口(秒)",
|
||||||
|
"plot_fn_min": "FN 下限",
|
||||||
|
"plot_fn_min_tip": "法向力纵轴下限(牛顿模式)。可以是负数:留出零点以下的空间,静止曲线不会压在 FX 框线上,小压力也更清楚",
|
||||||
"plot_fn_max": "FN 上限",
|
"plot_fn_max": "FN 上限",
|
||||||
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
|
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
|
||||||
"plot_shear_max": "剪切上限",
|
"plot_shear_max": "剪切上限",
|
||||||
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
|
"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": "FN 原始上限",
|
||||||
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
|
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
|
||||||
"plot_shear_raw_max": "原始剪切上限",
|
"plot_shear_raw_max": "原始剪切上限",
|
||||||
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
|
"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": "恢复默认",
|
"reset_defaults": "恢复默认",
|
||||||
"plot_filters": "滤波(作用于采集,界面与记录同步)",
|
"plot_filters": "滤波(作用于采集,界面与记录同步)",
|
||||||
"filter_preset": "预设",
|
"filter_preset": "预设",
|
||||||
@@ -107,7 +118,7 @@
|
|||||||
"run_unavailable": "当前不可运行",
|
"run_unavailable": "当前不可运行",
|
||||||
"run_disabled_video": "请先选择一个视频文件",
|
"run_disabled_video": "请先选择一个视频文件",
|
||||||
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
||||||
"playback": "▶ 回放",
|
"playback": "▶ 播放",
|
||||||
"playback_pause": "⏸ 暂停",
|
"playback_pause": "⏸ 暂停",
|
||||||
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
||||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||||
@@ -115,6 +126,13 @@
|
|||||||
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
|
||||||
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
|
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
|
"reset_tip": "把设备追踪器归零(清零)。只影响零点,不动界面;想恢复视图请用「视图 → 复位视图」。",
|
||||||
|
"pause": "⏸ 暂停",
|
||||||
|
"pause_resume": "▶",
|
||||||
|
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
|
||||||
|
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
||||||
|
"resume_label": "▶ 继续",
|
||||||
|
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
||||||
"stop_playback": "停止",
|
"stop_playback": "停止",
|
||||||
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
|
||||||
"stopped": "已停止:{what}",
|
"stopped": "已停止:{what}",
|
||||||
@@ -400,6 +418,9 @@
|
|||||||
"camera_disconnected": "摄像机已断开",
|
"camera_disconnected": "摄像机已断开",
|
||||||
"reset_ok": "追踪器已重置",
|
"reset_ok": "追踪器已重置",
|
||||||
"reset_pending": "已请求归零,等待采集线程确认…",
|
"reset_pending": "已请求归零,等待采集线程确认…",
|
||||||
|
"view_reset": "视图已复位",
|
||||||
|
"paused": "已暂停:不再计算,设备保持连接",
|
||||||
|
"resumed": "已继续",
|
||||||
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
||||||
"video_required": "请先选择视频文件",
|
"video_required": "请先选择视频文件",
|
||||||
"config_missing": "未找到传感器配置: {type}",
|
"config_missing": "未找到传感器配置: {type}",
|
||||||
|
|||||||
@@ -15,17 +15,21 @@
|
|||||||
"scene3d_display": {
|
"scene3d_display": {
|
||||||
"deform_enabled": false,
|
"deform_enabled": false,
|
||||||
"display_gain": 0.0,
|
"display_gain": 0.0,
|
||||||
"housing_visible": true,
|
"housing_visible": false,
|
||||||
"max_fps": 30
|
"max_fps": 30
|
||||||
},
|
},
|
||||||
"sensor_type": "box",
|
"sensor_type": "box",
|
||||||
"config_name": "",
|
"config_name": "",
|
||||||
"units": "newton",
|
"units": "newton",
|
||||||
"plot_window_sec": 4.0,
|
"plot_window_sec": 4.0,
|
||||||
|
"plot_fn_min": 0.0,
|
||||||
"plot_fn_max": 20.0,
|
"plot_fn_max": 20.0,
|
||||||
"plot_shear_max": 20.0,
|
"plot_shear_max": 20.0,
|
||||||
|
"plot_fn_raw_min": 0.0,
|
||||||
"plot_fn_raw_max": 1000000.0,
|
"plot_fn_raw_max": 1000000.0,
|
||||||
"plot_shear_raw_max": 500000.0,
|
"plot_shear_raw_max": 500000.0,
|
||||||
|
"plot_deadband_n": 0.0,
|
||||||
|
"plot_deadband_raw": 0.0,
|
||||||
"image_view_mode": "arrows_pure",
|
"image_view_mode": "arrows_pure",
|
||||||
"arrow_rotation_deg": 90,
|
"arrow_rotation_deg": 90,
|
||||||
"settings_expanded": false,
|
"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],
|
"resolution_raw": [1280, 720],
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"resolution_stitch": [280, 300],
|
"resolution_stitch": [280, 300],
|
||||||
"source_scale": 0.4,
|
"source_scale": 0.42,
|
||||||
"old":[672, 728, 1,0.5],
|
"old":[672, 728, 1,0.5],
|
||||||
"work": [504, 546,1],
|
"work": [504, 546,1],
|
||||||
"work2": [420, 450,0.8],
|
"work2": [420, 450,0.8],
|
||||||
|
|||||||
@@ -218,8 +218,10 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
|||||||
|
|
||||||
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
|
## 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)
|
### 7.1 记录语义(P1-1)
|
||||||
|
|
||||||
|
|||||||
@@ -9,27 +9,13 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
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.constants import FORCE_SAMPLE_RATE_HZ
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
DEPTH,
|
DEPTH,
|
||||||
SOURCE_LIVE,
|
SOURCE_LIVE,
|
||||||
SOURCE_PREVIEW,
|
SOURCE_PREVIEW,
|
||||||
SOURCE_RECOMPUTE,
|
SOURCE_RECOMPUTE,
|
||||||
|
DomainMaskLatch,
|
||||||
ImageFrame,
|
ImageFrame,
|
||||||
plan_image,
|
plan_image,
|
||||||
render_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.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings
|
from orisys_app_shared.force.filters import FilterSettings
|
||||||
from orisys_app_shared.force.session import SessionStore
|
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 import RecordConfig
|
||||||
from orisys_app_shared.record.sample import to_record_sample
|
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.recording import RecordingCoordinator, record_config_from_prefs
|
||||||
from orisys_studio.replay import ReplayEngine
|
from orisys_studio.replay import ReplayEngine
|
||||||
from orisys_studio.replay_controller import ReplayController
|
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_studio.scene3d.prefs import settings_to_prefs
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_studio.session_controller import SessionController
|
||||||
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.ui import record_status as record_states
|
from orisys_studio.ui import record_status as record_states
|
||||||
from orisys_studio.ui.about_dialog import AboutDialog
|
from orisys_studio.ui.about_dialog import AboutDialog
|
||||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
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.sensor_picker import SensorPickerWidget
|
||||||
from orisys_studio.ui.toolbar import ToolbarWidget
|
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||||
|
from orisys_studio.view_layout import ViewLayoutController
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
|
||||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
#: 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)
|
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
|
||||||
MIN_PANEL_WIDTHS = {
|
MIN_PANEL_WIDTHS = {
|
||||||
# The 3D pane used to be the one panel whose content was a stack of form fields, so
|
# 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)
|
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.
|
# 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)
|
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 = ""
|
self._active_calibration_id = ""
|
||||||
#: Last display-unit decision (``None`` before the first sample): the
|
#: Last display-unit decision (``None`` before the first sample): the
|
||||||
#: status rail's calibration readout is derived from it, because an axis
|
#: status rail's calibration readout is derived from it, because an axis
|
||||||
@@ -214,6 +218,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._last_calibration_export = ""
|
self._last_calibration_export = ""
|
||||||
self._depth_range_value: tuple[float, float] | None = None
|
self._depth_range_value: tuple[float, float] | None = None
|
||||||
self._last_depth_frame = 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()))
|
icon = QIcon(str(logo_path()))
|
||||||
if not icon.isNull():
|
if not icon.isNull():
|
||||||
@@ -240,6 +249,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
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.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
# The charts browse the whole session, not just their rolling buffer.
|
# 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.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
|
||||||
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
|
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
|
||||||
self.toolbar.run_toggled.connect(self._on_run_toggled)
|
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.record_toggled.connect(self._on_record_toggled)
|
||||||
self.toolbar.reset_clicked.connect(self._on_reset)
|
self.toolbar.reset_clicked.connect(self._on_reset)
|
||||||
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
|
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.setShortcut("Ctrl+R")
|
||||||
self.act_reset.triggered.connect(self._on_reset)
|
self.act_reset.triggered.connect(self._on_reset)
|
||||||
self.view_menu.addAction(self.act_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.view_menu.addSeparator()
|
||||||
|
|
||||||
self.theme_menu = self.view_menu.addMenu("")
|
self.theme_menu = self.view_menu.addMenu("")
|
||||||
@@ -1111,6 +1142,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return self.layout_ctl.view_preset
|
return self.layout_ctl.view_preset
|
||||||
|
|
||||||
def _update_run_enabled(self) -> None:
|
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":
|
if self.toolbar.run_role() == "playback":
|
||||||
# In the replay view the button is a transport: it needs something to
|
# 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
|
# 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")
|
reason = t("toolbar.playback_disabled")
|
||||||
self.toolbar.set_run_tooltip_reason(reason)
|
self.toolbar.set_run_tooltip_reason(reason)
|
||||||
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
|
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
|
||||||
# The stop button is offered whenever there is something to stop; the
|
# Stop leaves the replay, whatever it is doing: plain playback, a
|
||||||
# operator must never have to guess whether that is the case.
|
# recompute pass, or a session the pass started. The operator must never
|
||||||
if self.toolbar.reset_role() == "stop":
|
# have to work out which of those is stuck before they can stop it.
|
||||||
self.toolbar.set_reset_enabled(
|
self.toolbar.set_stop_enabled(
|
||||||
self.replay_ctl.is_playing()
|
self.replay_ctl.is_playing()
|
||||||
or self._recompute_running
|
or self._recompute_running
|
||||||
or self._session_active
|
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.toolbar.set_record_enabled(
|
||||||
self._running or self.recorder.is_recording
|
self._running or self.recorder.is_recording
|
||||||
)
|
)
|
||||||
@@ -1155,10 +1194,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
source_ok = False
|
source_ok = False
|
||||||
reason = t("toolbar.run_disabled_video")
|
reason = t("toolbar.run_disabled_video")
|
||||||
self.toolbar.set_run_tooltip_reason(reason)
|
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))
|
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
|
||||||
# Recording needs a live session; while recording the button stays
|
# Recording needs a live session; while recording the button stays
|
||||||
# enabled so it can always be stopped.
|
# enabled so it can always be stopped.
|
||||||
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
|
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:
|
if not self._running:
|
||||||
self.toolbar.refresh_labels()
|
self.toolbar.refresh_labels()
|
||||||
self._sync_shortcut_actions()
|
self._sync_shortcut_actions()
|
||||||
@@ -1189,6 +1234,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_stop.setText(t("menu.stop"))
|
self.act_stop.setText(t("menu.stop"))
|
||||||
self.act_preview.setText(t("menu.preview"))
|
self.act_preview.setText(t("menu.preview"))
|
||||||
self.act_reset.setText(t("menu.reset"))
|
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.theme_menu.setTitle(t("menu.theme"))
|
||||||
self.act_theme_dark.setText(t("menu.theme_dark"))
|
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||||
self.act_theme_light.setText(t("menu.theme_light"))
|
self.act_theme_light.setText(t("menu.theme_light"))
|
||||||
@@ -1494,10 +1543,17 @@ class StudioMainWindow(QMainWindow):
|
|||||||
dialog = PlotSettingsDialog(
|
dialog = PlotSettingsDialog(
|
||||||
self,
|
self,
|
||||||
window_sec=settings["window_sec"],
|
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"],
|
fn_max=settings["fn_max"],
|
||||||
shear_max=settings["shear_max"],
|
shear_max=settings["shear_max"],
|
||||||
|
fn_raw_min=settings["fn_raw_min"],
|
||||||
fn_raw_max=settings["fn_raw_max"],
|
fn_raw_max=settings["fn_raw_max"],
|
||||||
shear_raw_max=settings["shear_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"],
|
units=settings["units"],
|
||||||
filters=self._filter_settings(),
|
filters=self._filter_settings(),
|
||||||
# Asked *now*: the answer is a property of this moment, and the
|
# Asked *now*: the answer is a property of this moment, and the
|
||||||
@@ -1507,16 +1563,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if dialog.exec() != PlotSettingsDialog.Accepted:
|
if dialog.exec() != PlotSettingsDialog.Accepted:
|
||||||
return
|
return
|
||||||
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
# One call for the whole picture: the dialog owns the schema, the controller
|
||||||
self.display_ctl.apply_plot_settings(
|
# writes what it was given and re-reads the rest.
|
||||||
{
|
self.display_ctl.apply_plot_settings(dialog.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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
filters = dialog.filter_values()
|
filters = dialog.filter_values()
|
||||||
# The same keys ``_filter_settings()`` reads back, so what the dialog
|
# The same keys ``_filter_settings()`` reads back, so what the dialog
|
||||||
# shows next time is what the session will actually be given.
|
# 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:
|
if getattr(self, "act_run", None) is None:
|
||||||
return
|
return
|
||||||
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
|
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.
|
# session alone: a replay or a recompute pass is something to stop.
|
||||||
#
|
#
|
||||||
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
|
# 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
|
# 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
|
# 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.
|
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
|
||||||
self.act_stop.setEnabled(
|
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
|
||||||
self._session_active
|
|
||||||
or self.replay_ctl.is_playing()
|
|
||||||
or self._recompute_running
|
|
||||||
)
|
|
||||||
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
|
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
|
||||||
self.act_reset.setEnabled(self.toolbar.btn_reset.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:
|
def _on_play_shortcut(self) -> None:
|
||||||
"""Space plays or pauses the recording, if there is one to move through."""
|
"""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
|
``_sync_shortcut_actions`` enables the action for, so the action can never be
|
||||||
enabled with nothing behind it.
|
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.toolbar.run_role() == "playback":
|
||||||
if (
|
if (
|
||||||
self.replay_ctl.is_playing()
|
self.replay_ctl.is_playing()
|
||||||
@@ -1765,6 +1845,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self._stop_run(reason="user_stop")
|
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:
|
def _on_preview_shortcut(self) -> None:
|
||||||
if not self.toolbar.btn_preview.isEnabled():
|
if not self.toolbar.btn_preview.isEnabled():
|
||||||
return
|
return
|
||||||
@@ -1876,14 +1960,28 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
|
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
|
||||||
|
|
||||||
def _on_run_toggled(self, checked: bool) -> None:
|
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":
|
if self.toolbar.run_role() == "playback":
|
||||||
# The primary button of the replay view is its transport.
|
# The primary button of the replay view is its transport.
|
||||||
self.replay_ctl.on_transport_toggled(bool(checked))
|
self.replay_ctl.on_transport_toggled(bool(checked))
|
||||||
return
|
return
|
||||||
if checked:
|
if checked:
|
||||||
self._start_run()
|
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:
|
else:
|
||||||
self._stop_run(reason="user_stop")
|
self._start_run()
|
||||||
|
return
|
||||||
|
if self._running:
|
||||||
|
self._on_pause_toggled(True)
|
||||||
|
|
||||||
def _start_run(self) -> None:
|
def _start_run(self) -> None:
|
||||||
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
|
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
|
||||||
@@ -1919,11 +2017,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.force_plots.configure_presentation(
|
self.force_plots.configure_presentation(
|
||||||
sensor_type=sensor_type,
|
sensor_type=sensor_type,
|
||||||
unit=units,
|
unit=units,
|
||||||
enabled=(
|
# The operator's own switch, and only that: the aid covers every sensor type and
|
||||||
sensor_type == "finger"
|
# both units, which is the point of it (an offset reads as an offset in newtons
|
||||||
and units == "raw"
|
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
|
||||||
and bool(self.prefs.get("raw_auto_zero", True))
|
# it for every other product.
|
||||||
),
|
enabled=bool(self.prefs.get("raw_auto_zero", True)),
|
||||||
)
|
)
|
||||||
self._last_plot_packet_key = None
|
self._last_plot_packet_key = None
|
||||||
self._last_rendered_view_mode = None
|
self._last_rendered_view_mode = None
|
||||||
@@ -1936,6 +2034,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._last_display_available = None
|
self._last_display_available = None
|
||||||
self._depth_range_value = None
|
self._depth_range_value = None
|
||||||
self._last_depth_frame = 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
|
# 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
|
# 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.
|
# 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
|
# 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).
|
# run timer's cadence (the timer *is* the session, so it lives with it).
|
||||||
self.session.mark_running()
|
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_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||||
self.cal_panel.set_running(True)
|
self.cal_panel.set_running(True)
|
||||||
self.toolbar.set_source_controls_enabled(False)
|
self.toolbar.set_source_controls_enabled(False)
|
||||||
@@ -2013,6 +2121,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.toolbar.refresh_labels()
|
self.toolbar.refresh_labels()
|
||||||
self.cal_panel.set_running(False)
|
self.cal_panel.set_running(False)
|
||||||
self.toolbar.set_run_checked(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_reset_enabled(False)
|
||||||
self.toolbar.set_source_controls_enabled(True)
|
self.toolbar.set_source_controls_enabled(True)
|
||||||
self.sensor_picker.set_picker_enabled(True)
|
self.sensor_picker.set_picker_enabled(True)
|
||||||
@@ -2064,28 +2178,110 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._notify(t("toolbar.nothing_to_stop"))
|
self._notify(t("toolbar.nothing_to_stop"))
|
||||||
|
|
||||||
def _on_reset(self) -> None:
|
def _on_reset(self) -> None:
|
||||||
if self.toolbar.reset_role() == "stop":
|
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
|
||||||
# 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.
|
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
|
||||||
self.replay_ctl.stop_activity()
|
also threw away the history, the window and the cursor on screen — two different
|
||||||
return
|
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:
|
if self._session_phase != "running" or self._active_worker is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
# A request, not the zeroing itself: the acquisition thread applies it
|
# A request, not the zeroing itself: the acquisition thread applies it
|
||||||
# between two frames and reports the frame and time it took effect.
|
# between two frames and reports the frame and time it took effect.
|
||||||
self._active_worker.reset()
|
self._active_worker.reset()
|
||||||
self.force_plots.reset()
|
self.force_plots.request_presentation_reestimate()
|
||||||
self._last_plot_packet_key = None
|
self._last_plot_packet_key = None
|
||||||
self.statusBar().showMessage(t("status.reset_pending"))
|
self.statusBar().showMessage(t("status.reset_pending"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.statusBar().showMessage(t("status.run_failed", error=str(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:
|
def _on_run_tick(self) -> None:
|
||||||
# The 3D pane advances on this timer rather than one of its own: the window's
|
# 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
|
# 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 cannot leave a timer behind. It starts at most one compose on the
|
||||||
# pane's own worker and takes at most one finished frame.
|
# pane's own worker and takes at most one finished frame.
|
||||||
|
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()
|
self.mesh3d_panel.tick()
|
||||||
if self._active_worker is None:
|
if self._active_worker is None:
|
||||||
return
|
return
|
||||||
@@ -2141,7 +2337,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# source slot names the camera, so "运行中(0)" was a second copy of
|
# 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.
|
# 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
|
self._last_plot_packet_key = packet_key
|
||||||
# The live view consumes the *same* standardised measurement as the
|
# The live view consumes the *same* standardised measurement as the
|
||||||
# CSV and the session store: one conversion decides what a value
|
# CSV and the session store: one conversion decides what a value
|
||||||
@@ -2227,8 +2423,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage(dropped_message)
|
self.statusBar().showMessage(dropped_message)
|
||||||
|
|
||||||
mode = self.arrow_panel.view_mode()
|
mode = self.arrow_panel.view_mode()
|
||||||
if (is_new or mode != self._last_rendered_view_mode) and not (
|
if not self._display_paused and (
|
||||||
self._replay_owns_image()
|
(is_new or mode != self._last_rendered_view_mode)
|
||||||
|
and not (self._replay_owns_image())
|
||||||
):
|
):
|
||||||
self._render_run_image(packet)
|
self._render_run_image(packet)
|
||||||
if is_new:
|
if is_new:
|
||||||
@@ -2274,6 +2471,19 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return ""
|
return ""
|
||||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
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:
|
def _replay_owns_image(self) -> bool:
|
||||||
"""True while the 2D panel shows the replay, not the live camera.
|
"""True while the 2D panel shows the replay, not the live camera.
|
||||||
|
|
||||||
@@ -2331,7 +2541,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
draw_style = self._draw_style()
|
draw_style = self._draw_style()
|
||||||
# The legend is decided by the same inputs as the picture: the arrow
|
# The legend is decided by the same inputs as the picture: the arrow
|
||||||
# magnification of this sensor's draw style, and whether this frame
|
# 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_arrow_scale(arrow_display_scale(draw_style))
|
||||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||||
if mode == DEPTH:
|
if mode == DEPTH:
|
||||||
@@ -2347,6 +2560,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
draw_style=draw_style,
|
draw_style=draw_style,
|
||||||
depth_range=self._depth_range(),
|
depth_range=self._depth_range(),
|
||||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
||||||
|
domain_mask=boundary,
|
||||||
)
|
)
|
||||||
if rendered is None:
|
if rendered is None:
|
||||||
# Never substitute another picture here: a missing depth map that showed
|
# Never substitute another picture here: a missing depth map that showed
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from orisys_app_shared.force.calibration_io import (
|
from orisys_app_shared.force.calibration_io import (
|
||||||
CHANNEL_KEYS,
|
CHANNEL_KEYS,
|
||||||
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
|
|||||||
class CalibrationService:
|
class CalibrationService:
|
||||||
"""Owns the calibration session's files and its prepared model."""
|
"""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()
|
self.store = store or CalSessionStore()
|
||||||
#: The one prepared model the panel displays and exports.
|
#: The one prepared model the panel displays and exports.
|
||||||
self.prepared: Any = None
|
self.prepared: Any = None
|
||||||
#: Device identity of a reopened record, while it is open.
|
#: 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.
|
#: The record currently open, if any.
|
||||||
self.open_record: Optional[CalibrationSessionRecord] = None
|
self.open_record: CalibrationSessionRecord | None = None
|
||||||
|
|
||||||
# --------------------------------------------------------------- identity
|
# --------------------------------------------------------------- identity
|
||||||
def export_meta(
|
def export_meta(
|
||||||
|
|||||||
@@ -19,12 +19,42 @@ repaint when the 2D view mode changes.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from PySide6.QtCore import QObject
|
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__)
|
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):
|
class DisplayHost(Protocol):
|
||||||
"""What the display controller needs from the window.
|
"""What the display controller needs from the window.
|
||||||
@@ -36,7 +66,7 @@ class DisplayHost(Protocol):
|
|||||||
prefs: dict
|
prefs: dict
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _latest_fps(self) -> Optional[float]: ...
|
def _latest_fps(self) -> float | None: ...
|
||||||
|
|
||||||
def _persist_prefs(self) -> None: ...
|
def _persist_prefs(self) -> None: ...
|
||||||
|
|
||||||
@@ -64,7 +94,7 @@ class DisplayPrefsController(QObject):
|
|||||||
arrow_rotate_types: frozenset,
|
arrow_rotate_types: frozenset,
|
||||||
mesh_panel: Any = None,
|
mesh_panel: Any = None,
|
||||||
rotate_arrows_action: Any = None,
|
rotate_arrows_action: Any = None,
|
||||||
parent: Optional[QObject] = None,
|
parent: QObject | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._host = host
|
self._host = host
|
||||||
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
|
|||||||
#: Whether the panels explain the pipeline around the picture (see
|
#: Whether the panels explain the pipeline around the picture (see
|
||||||
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
||||||
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
|
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._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
|
||||||
self._apply_diagnostics()
|
self._apply_diagnostics()
|
||||||
self.apply_presentation_prefs()
|
self.apply_presentation_prefs()
|
||||||
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
|
|||||||
|
|
||||||
# --------------------------------------------------------------------- plot
|
# --------------------------------------------------------------------- plot
|
||||||
def plot_settings(self) -> dict:
|
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
|
prefs = self._host.prefs
|
||||||
return {
|
settings = {
|
||||||
"window_sec": float(
|
key: self._number(
|
||||||
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
|
f"plot_{key}", self._plot_defaults.get(key, fallback)
|
||||||
),
|
)
|
||||||
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
|
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
|
||||||
"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["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:
|
def apply_plot_settings(self, values: dict) -> None:
|
||||||
"""Remember the chart range the operator chose, and show it at once."""
|
"""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"):
|
|
||||||
|
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]
|
self._host.prefs[f"plot_{key}"] = values[key]
|
||||||
self._charts.set_window_sec(float(values["window_sec"]))
|
settings = self.plot_settings()
|
||||||
self._charts.set_axis_limits(
|
self._charts.set_window_sec(float(settings["window_sec"]))
|
||||||
float(values["fn_max"]),
|
self._apply_ranges(settings)
|
||||||
float(values["shear_max"]),
|
|
||||||
float(values["fn_raw_max"]),
|
|
||||||
float(values["shear_raw_max"]),
|
|
||||||
)
|
|
||||||
self._host._persist_prefs()
|
self._host._persist_prefs()
|
||||||
|
|
||||||
def apply_plot_prefs_to_panel(self) -> None:
|
def apply_plot_prefs_to_panel(self) -> None:
|
||||||
"""Show the stored chart range without asking anything (startup path)."""
|
"""Show the stored chart range without asking anything (startup path)."""
|
||||||
values = self.plot_settings()
|
settings = self.plot_settings()
|
||||||
self._charts.set_window_sec(float(values["window_sec"]))
|
self._charts.set_window_sec(float(settings["window_sec"]))
|
||||||
self._charts.set_axis_limits(
|
self._apply_ranges(settings)
|
||||||
float(values["fn_max"]),
|
|
||||||
float(values["shear_max"]),
|
|
||||||
float(values["fn_raw_max"]),
|
|
||||||
float(values["shear_raw_max"]),
|
|
||||||
)
|
|
||||||
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
|
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:
|
def device_sample_rate(self) -> float:
|
||||||
"""The frame rate the low-pass coefficients would be designed for."""
|
"""The frame rate the low-pass coefficients would be designed for."""
|
||||||
from orisys_studio import session_metadata
|
from orisys_studio import session_metadata
|
||||||
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
|
|||||||
return bool(self._raw_auto_zero)
|
return bool(self._raw_auto_zero)
|
||||||
|
|
||||||
def set_raw_auto_zero(self, enabled: bool) -> None:
|
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._raw_auto_zero = bool(enabled)
|
||||||
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
|
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
|
||||||
self._host._persist_prefs()
|
self._host._persist_prefs()
|
||||||
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
|
|||||||
self.set_raw_auto_zero(enabled)
|
self.set_raw_auto_zero(enabled)
|
||||||
|
|
||||||
def apply_presentation_prefs(self) -> None:
|
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)
|
configure = getattr(self._charts, "configure_presentation", None)
|
||||||
if not callable(configure):
|
|
||||||
return
|
|
||||||
sensor = (
|
sensor = (
|
||||||
self._picker.sensor_type()
|
self._picker.sensor_type()
|
||||||
if hasattr(self._picker, "sensor_type")
|
if hasattr(self._picker, "sensor_type")
|
||||||
else "box"
|
else "box"
|
||||||
)
|
)
|
||||||
unit = "N" if self._toolbar.units() == "newton" else "raw"
|
unit = "N" if self._toolbar.units() == "newton" else "raw"
|
||||||
configure(
|
if callable(configure):
|
||||||
sensor_type=sensor,
|
configure(sensor_type=sensor, unit=unit)
|
||||||
unit=unit,
|
enable = getattr(self._charts, "set_presentation_enabled", None)
|
||||||
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
|
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:
|
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.
|
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
|
||||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
from orisys_app_shared import API_VERSION
|
||||||
|
from orisys_app_shared import __version__ as shared_version
|
||||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
|
||||||
from orisys_app_shared.compat import check_shared_compat
|
from orisys_app_shared.compat import check_shared_compat
|
||||||
from orisys_app_shared.i18n import set_locale
|
from orisys_app_shared.i18n import set_locale
|
||||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
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.paths import prefer_local_sensor_bundles
|
||||||
from orisys_app_shared.prefs import load_preferences
|
from orisys_app_shared.prefs import load_preferences
|
||||||
from orisys_app_shared.theme import build_qss
|
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
|
from orisys_studio.app_window import StudioMainWindow
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ here, where they can be tested without a display.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
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 import ExperimentRecorder, RecordConfig, RecordReport
|
||||||
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
|
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
|
||||||
@@ -34,7 +35,7 @@ class RecordingCoordinator:
|
|||||||
*,
|
*,
|
||||||
session_store,
|
session_store,
|
||||||
cal_recorder,
|
cal_recorder,
|
||||||
recorder: Optional[ExperimentRecorder] = None,
|
recorder: ExperimentRecorder | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_store = session_store
|
self.session_store = session_store
|
||||||
self.cal_recorder = cal_recorder
|
self.cal_recorder = cal_recorder
|
||||||
@@ -47,7 +48,7 @@ class RecordingCoordinator:
|
|||||||
self._alarms: list[str] = []
|
self._alarms: list[str] = []
|
||||||
#: A stop was requested and the writer has not closed its files yet.
|
#: A stop was requested and the writer has not closed its files yet.
|
||||||
self.report_pending = False
|
self.report_pending = False
|
||||||
self.last_report: Optional[RecordReport] = None
|
self.last_report: RecordReport | None = None
|
||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
self._record_fps = 30.0
|
self._record_fps = 30.0
|
||||||
self.recorder.set_alarm_handler(self._on_alarm)
|
self.recorder.set_alarm_handler(self._on_alarm)
|
||||||
@@ -210,7 +211,7 @@ class RecordingCoordinator:
|
|||||||
self.report_pending = False
|
self.report_pending = False
|
||||||
return Path(directory)
|
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."""
|
"""Stop and return the report, or ``None`` when nothing was started."""
|
||||||
if self.recorder.directory is None and self.recorder.report is None:
|
if self.recorder.directory is None and self.recorder.report is None:
|
||||||
self.report_pending = False
|
self.report_pending = False
|
||||||
@@ -221,7 +222,7 @@ class RecordingCoordinator:
|
|||||||
self.publish(report)
|
self.publish(report)
|
||||||
return 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``."""
|
"""Record a report; a completed one becomes ``last_report``."""
|
||||||
if report is None:
|
if report is None:
|
||||||
return
|
return
|
||||||
@@ -232,7 +233,7 @@ class RecordingCoordinator:
|
|||||||
self.report_pending = False
|
self.report_pending = False
|
||||||
self.last_report = report
|
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."""
|
"""The terminal report, once the writer has actually closed the files."""
|
||||||
if not self.report_pending:
|
if not self.report_pending:
|
||||||
return None
|
return None
|
||||||
@@ -256,7 +257,7 @@ def record_config_from_prefs(
|
|||||||
*,
|
*,
|
||||||
mode: str,
|
mode: str,
|
||||||
fps: float,
|
fps: float,
|
||||||
on_problem: Optional[Callable[[str], None]] = None,
|
on_problem: Callable[[str], None] | None = None,
|
||||||
) -> RecordConfig:
|
) -> RecordConfig:
|
||||||
"""Build the record config the toolbar/prefs describe."""
|
"""Build the record config the toolbar/prefs describe."""
|
||||||
return RecordConfig(
|
return RecordConfig(
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import QObject, Signal
|
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ class ReplayEngine(QObject):
|
|||||||
self._position = 0.0
|
self._position = 0.0
|
||||||
#: The frame cache: one video image per cursor position.
|
#: The frame cache: one video image per cursor position.
|
||||||
self._video_position: float | None = None
|
self._video_position: float | None = None
|
||||||
self._video_bgr: Optional[np.ndarray] = None
|
self._video_bgr: np.ndarray | None = None
|
||||||
self._video_reason = ""
|
self._video_reason = ""
|
||||||
self._warnings = ""
|
self._warnings = ""
|
||||||
|
|
||||||
@@ -209,7 +207,7 @@ class ReplayEngine(QObject):
|
|||||||
return self._unit
|
return self._unit
|
||||||
|
|
||||||
# ----------------------------------------------------------------- frames
|
# ----------------------------------------------------------------- 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 recorded frame under the cursor, as BGR.
|
||||||
|
|
||||||
The engine hands over pixels, not a prepared widget image: the single 2D
|
The engine hands over pixels, not a prepared widget image: the single 2D
|
||||||
|
|||||||
@@ -25,15 +25,14 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import numpy as np
|
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.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.paths import recordings_dir
|
from orisys_app_shared.paths import recordings_dir
|
||||||
|
from PySide6.QtCore import QObject, QTimer
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -111,7 +110,7 @@ class ReplayController(QObject):
|
|||||||
charts: Any,
|
charts: Any,
|
||||||
image_panel: Any,
|
image_panel: Any,
|
||||||
mesh_panel: Any,
|
mesh_panel: Any,
|
||||||
parent: Optional[QObject] = None,
|
parent: QObject | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._engine = engine
|
self._engine = engine
|
||||||
@@ -120,7 +119,7 @@ class ReplayController(QObject):
|
|||||||
self._charts = charts
|
self._charts = charts
|
||||||
self._image_panel = image_panel
|
self._image_panel = image_panel
|
||||||
self._mesh_panel = mesh_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
|
#: 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.
|
#: the acquire pipeline owns the panels and the transport stops the pass.
|
||||||
self._recompute_running = False
|
self._recompute_running = False
|
||||||
@@ -501,7 +500,7 @@ class ReplayController(QObject):
|
|||||||
self._host._notify(t("toolbar.nothing_to_stop"))
|
self._host._notify(t("toolbar.nothing_to_stop"))
|
||||||
|
|
||||||
# ---------------------------------------------------------------- recompute
|
# ---------------------------------------------------------------- recompute
|
||||||
def video_segment(self) -> Optional[Path]:
|
def video_segment(self) -> Path | None:
|
||||||
"""The recorded video a recompute pass should read."""
|
"""The recorded video a recompute pass should read."""
|
||||||
reader = self._engine.reader()
|
reader = self._engine.reader()
|
||||||
segments = sorted(reader.segments()) if reader is not None else []
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.acquisition.models import SdkPacket
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
diagnostic,
|
diagnostic,
|
||||||
error,
|
|
||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.sampling import remap_to_domain
|
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)
|
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|`` as float32, or ``None`` when the packet carries no flow."""
|
||||||
flow = getattr(packet, "flow", None)
|
flow = getattr(packet, "flow", None)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
@@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
|||||||
return None
|
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])
|
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.
|
"""Whether the packet may claim ``field`` for *this* frame.
|
||||||
|
|
||||||
Three answers, and the middle one is the point: ``None`` on the packet means the
|
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(
|
def _device_mask(
|
||||||
packet: SdkPacket, shape_hw: tuple[int, int]
|
packet: SdkPacket,
|
||||||
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
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.
|
"""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
|
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
|
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.
|
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)
|
explicit = getattr(packet, "valid_mask", None)
|
||||||
if explicit is None:
|
if explicit is None:
|
||||||
@@ -161,10 +168,14 @@ def _device_mask(
|
|||||||
mask=_shape_of(array),
|
mask=_shape_of(array),
|
||||||
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
|
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
|
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."""
|
"""Inside the UV island AND the device's own valid area, when it stated one."""
|
||||||
if device is None:
|
if device is None:
|
||||||
return freeze(mask, dtype=np.bool_)
|
return freeze(mask, dtype=np.bool_)
|
||||||
@@ -205,8 +216,8 @@ class FingerFrameAdapter:
|
|||||||
geometry_id: str,
|
geometry_id: str,
|
||||||
field_domain_id: str,
|
field_domain_id: str,
|
||||||
shape_hw: tuple[int, int],
|
shape_hw: tuple[int, int],
|
||||||
map_x: Optional[np.ndarray] = None,
|
map_x: np.ndarray | None = None,
|
||||||
map_y: Optional[np.ndarray] = None,
|
map_y: np.ndarray | None = None,
|
||||||
preferred_field: str = "depth",
|
preferred_field: str = "depth",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.geometry_id = str(geometry_id)
|
self.geometry_id = str(geometry_id)
|
||||||
@@ -214,7 +225,7 @@ class FingerFrameAdapter:
|
|||||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||||
self.map_x = map_x
|
self.map_x = map_x
|
||||||
self.map_y = map_y
|
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
|
#: 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
|
#: 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
|
#: 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)
|
self.preferred_field = _field_name(preferred_field)
|
||||||
#: Set (and cleared) by every snapshot: the reason the picture is not the 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.
|
#: 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:
|
def set_preferred_field(self, name: str) -> None:
|
||||||
self.preferred_field = _field_name(name)
|
self.preferred_field = _field_name(name)
|
||||||
|
|
||||||
@classmethod
|
@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)."""
|
"""Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
|
||||||
return cls(
|
return cls(
|
||||||
geometry_id=geometry_id,
|
geometry_id=geometry_id,
|
||||||
@@ -241,7 +257,7 @@ class FingerFrameAdapter:
|
|||||||
|
|
||||||
def snapshot(
|
def snapshot(
|
||||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> SurfaceFieldFrame | None:
|
||||||
"""Colour the surface with the field the operator chose, or say why not.
|
"""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``);
|
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
|
||||||
@@ -288,7 +304,7 @@ class FingerFrameAdapter:
|
|||||||
identity: FrameIdentity,
|
identity: FrameIdentity,
|
||||||
name: str,
|
name: str,
|
||||||
refused: list[Diagnostic],
|
refused: list[Diagnostic],
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> SurfaceFieldFrame | None:
|
||||||
"""One quantity, or ``None`` with the reason appended to ``refused``."""
|
"""One quantity, or ``None`` with the reason appended to ``refused``."""
|
||||||
if name == "depth":
|
if name == "depth":
|
||||||
depth = getattr(packet, "depth", None)
|
depth = getattr(packet, "depth", None)
|
||||||
@@ -383,11 +399,11 @@ class FingerFrameAdapter:
|
|||||||
mask: np.ndarray,
|
mask: np.ndarray,
|
||||||
descriptor: FieldDescriptor,
|
descriptor: FieldDescriptor,
|
||||||
refused: list[Diagnostic],
|
refused: list[Diagnostic],
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> SurfaceFieldFrame | None:
|
||||||
"""Intersect with the device's own mask and build the frame."""
|
"""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
|
# Inside the UV island is the only place a value means anything: the tracker is
|
||||||
# looking at masked black outside it.
|
# 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:
|
if mask_problem is not None:
|
||||||
refused.append(mask_problem)
|
refused.append(mask_problem)
|
||||||
return None
|
return None
|
||||||
@@ -407,7 +423,7 @@ class FingerFrameAdapter:
|
|||||||
arr = np.asarray(flow, dtype=np.float32)
|
arr = np.asarray(flow, dtype=np.float32)
|
||||||
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
||||||
flow_uv = freeze(arr, dtype=np.float32)
|
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)
|
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)
|
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
||||||
if not np.isfinite(fn):
|
if not np.isfinite(fn):
|
||||||
@@ -451,11 +467,13 @@ class BoxFrameAdapter:
|
|||||||
self.geometry_id = str(geometry_id)
|
self.geometry_id = str(geometry_id)
|
||||||
self.field_domain_id = str(field_domain_id)
|
self.field_domain_id = str(field_domain_id)
|
||||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
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(
|
def snapshot(
|
||||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> SurfaceFieldFrame | None:
|
||||||
if tuple(domain.shape_hw) != self.shape_hw:
|
if tuple(domain.shape_hw) != self.shape_hw:
|
||||||
self.last_diagnostic = diagnostic(
|
self.last_diagnostic = diagnostic(
|
||||||
"field_domain_mismatch",
|
"field_domain_mismatch",
|
||||||
@@ -468,11 +486,11 @@ class BoxFrameAdapter:
|
|||||||
("depth", getattr(packet, "depth", None)),
|
("depth", getattr(packet, "depth", None)),
|
||||||
("flow", flow_magnitude(packet)),
|
("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:
|
if mask_problem is not None:
|
||||||
self.last_diagnostic = mask_problem
|
self.last_diagnostic = mask_problem
|
||||||
return None
|
return None
|
||||||
refused: Optional[Diagnostic] = None
|
refused: Diagnostic | None = None
|
||||||
for name, field in candidates:
|
for name, field in candidates:
|
||||||
if field is None:
|
if field is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
|
||||||
|
|
||||||
from orisys_app_shared.paths import config_dir
|
from orisys_app_shared.paths import config_dir
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
diagnostic,
|
diagnostic,
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
|
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
|
||||||
|
|
||||||
if TYPE_CHECKING: # the description is a value object; the callable returns one
|
if TYPE_CHECKING: # the description is a value object; the callable returns one
|
||||||
@@ -58,7 +59,7 @@ PACKAGE_SUFFIX = ".oryscene"
|
|||||||
COMPILE_HINT = (
|
COMPILE_HINT = (
|
||||||
"python tools/scene_assets/compile_scene.py "
|
"python tools/scene_assets/compile_scene.py "
|
||||||
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
|
"--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 "
|
"--meters-per-source-unit <stated> --output "
|
||||||
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
|
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
|
||||||
)
|
)
|
||||||
@@ -76,7 +77,7 @@ class SceneSource:
|
|||||||
return f"{self.kind}: {self.path}"
|
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."""
|
"""Where a compiled package for this sensor belongs."""
|
||||||
base = Path(root) if root is not None else config_dir()
|
base = Path(root) if root is not None else config_dir()
|
||||||
return base / "sensors" / str(sensor_type) / "scene"
|
return base / "sensors" / str(sensor_type) / "scene"
|
||||||
@@ -86,8 +87,8 @@ def find_package(
|
|||||||
sensor_type: str,
|
sensor_type: str,
|
||||||
*,
|
*,
|
||||||
override: str | Path | None = None,
|
override: str | Path | None = None,
|
||||||
root: Optional[Path] = None,
|
root: Path | None = None,
|
||||||
) -> Optional[SceneSource]:
|
) -> SceneSource | None:
|
||||||
"""The package to load, or ``None``. Never raises: absence is a state."""
|
"""The package to load, or ``None``. Never raises: absence is a state."""
|
||||||
if override:
|
if override:
|
||||||
path = Path(str(override)).expanduser()
|
path = Path(str(override)).expanduser()
|
||||||
@@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
|
|||||||
|
|
||||||
def load_package(
|
def load_package(
|
||||||
source: SceneSource,
|
source: SceneSource,
|
||||||
expected: Optional[ExpectedGeometry] = None,
|
expected: ExpectedGeometry | None = None,
|
||||||
keys: "Optional[SceneKeys]" = None,
|
keys: SceneKeys | None = None,
|
||||||
) -> SceneAssets:
|
) -> SceneAssets:
|
||||||
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
|
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
|
||||||
|
|
||||||
@@ -153,10 +154,10 @@ def load_package(
|
|||||||
|
|
||||||
def _resolve_and_load(
|
def _resolve_and_load(
|
||||||
source: SceneSource,
|
source: SceneSource,
|
||||||
expected: Optional[ExpectedGeometry],
|
expected: ExpectedGeometry | None,
|
||||||
provider: "Optional[Callable[[], DeviceGeometry]]",
|
provider: Callable[[], DeviceGeometry] | None,
|
||||||
keys: "Optional[SceneKeys]" = None,
|
keys: SceneKeys | None = None,
|
||||||
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
|
) -> tuple[SceneAssets, DeviceGeometry | None]:
|
||||||
"""Worker-thread half: build the *device's* description, then read the package.
|
"""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
|
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
|
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||||
failed = Signal(object, int) # Diagnostic, request id
|
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)
|
super().__init__(parent)
|
||||||
self._executor: Optional[ThreadPoolExecutor] = None
|
self._executor: ThreadPoolExecutor | None = None
|
||||||
self._inflight = False
|
self._inflight = False
|
||||||
self._request_id = 0
|
self._request_id = 0
|
||||||
#: The newest request that could not start yet (source, expected, provider, id).
|
#: 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(
|
def load(
|
||||||
self,
|
self,
|
||||||
source: SceneSource,
|
source: SceneSource,
|
||||||
expected: Optional[ExpectedGeometry] = None,
|
expected: ExpectedGeometry | None = None,
|
||||||
*,
|
*,
|
||||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||||
keys: "Optional[SceneKeys]" = None,
|
keys: SceneKeys | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Ask for a scene; returns the request id this call was given."""
|
"""Ask for a scene; returns the request id this call was given."""
|
||||||
self._request_id += 1
|
self._request_id += 1
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
F32,
|
||||||
CameraMatrices,
|
CameraMatrices,
|
||||||
CameraPreset,
|
CameraPreset,
|
||||||
F32,
|
|
||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,7 +52,7 @@ class OrbitCamera:
|
|||||||
vertical_fov_rad: float = 0.6
|
vertical_fov_rad: float = 0.6
|
||||||
aspect: float = 1.0
|
aspect: float = 1.0
|
||||||
_fitted_radius_m: float = 0.05
|
_fitted_radius_m: float = 0.05
|
||||||
_preset_name: Optional[str] = None
|
_preset_name: str | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------- setup
|
# ------------------------------------------------------------------- setup
|
||||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from concurrent.futures import CancelledError, Executor, Future
|
from concurrent.futures import CancelledError, Executor, Future
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -106,27 +107,27 @@ class ThreeDCoordinator:
|
|||||||
#: withdrawn: a stop, a hide, a new session, a new scene. A compose result
|
#: 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.
|
#: whose epoch is not this one is thrown away instead of presented.
|
||||||
self._epoch = 0
|
self._epoch = 0
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: SceneAssets | None = None
|
||||||
self._source_id = ""
|
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
|
#: 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.
|
#: (a seek UI, a queue that reorders) must not put an older frame on screen.
|
||||||
self._last_request_id = 0
|
self._last_request_id = 0
|
||||||
self._visible = False
|
self._visible = False
|
||||||
self._settings = DisplaySettings(max_fps=self._max_fps)
|
self._settings = DisplaySettings(max_fps=self._max_fps)
|
||||||
self._inflight: Optional[_Inflight] = None
|
self._inflight: _Inflight | None = None
|
||||||
self._stale_frames = 0
|
self._stale_frames = 0
|
||||||
self._invalid_frames = 0
|
self._invalid_frames = 0
|
||||||
self._last_diagnostic: Optional[Diagnostic] = None
|
self._last_diagnostic: Diagnostic | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._settings_dirty = 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.
|
#: 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
|
#: 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
|
#: 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.
|
#: 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.
|
#: 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
|
#: Without this the pane re-uploaded the same rest frame on every 16 ms tick
|
||||||
#: while it waited for the first measurement.
|
#: while it waited for the first measurement.
|
||||||
@@ -138,7 +139,7 @@ class ThreeDCoordinator:
|
|||||||
self._last_result_latency_ms = 0.0
|
self._last_result_latency_ms = 0.0
|
||||||
#: The field frame currently on screen, so a gain change can redraw exactly
|
#: The field frame currently on screen, so a gain change can redraw exactly
|
||||||
#: it rather than a newer instant.
|
#: it rather than a newer instant.
|
||||||
self._last_field_frame: Optional[SurfaceFieldFrame] = None
|
self._last_field_frame: SurfaceFieldFrame | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------- state
|
# ------------------------------------------------------------------- state
|
||||||
@property
|
@property
|
||||||
@@ -155,7 +156,7 @@ class ThreeDCoordinator:
|
|||||||
"""The display claim results are accepted under (see :class:`_Inflight`)."""
|
"""The display claim results are accepted under (see :class:`_Inflight`)."""
|
||||||
return self._epoch
|
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."""
|
"""What is on screen, and the monotonic time it was handed to the widget."""
|
||||||
return self._last_presented
|
return self._last_presented
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ class ThreeDCoordinator:
|
|||||||
return self._invalid_frames
|
return self._invalid_frames
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
def last_diagnostic(self) -> Diagnostic | None:
|
||||||
return self._last_diagnostic
|
return self._last_diagnostic
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -355,7 +356,7 @@ class ThreeDCoordinator:
|
|||||||
self._last_request_id = max(self._last_request_id, identity.request_id)
|
self._last_request_id = max(self._last_request_id, identity.request_id)
|
||||||
return self._incoming.offer(frame)
|
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.
|
"""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
|
Called from the acquisition thread by the sink that owns the adapter: when the
|
||||||
@@ -373,7 +374,7 @@ class ThreeDCoordinator:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _reject_reason(
|
def _reject_reason(
|
||||||
frame: SurfaceFieldFrame, assets: SceneAssets
|
frame: SurfaceFieldFrame, assets: SceneAssets
|
||||||
) -> Optional[Diagnostic]:
|
) -> Diagnostic | None:
|
||||||
"""The cheap checks a producer thread may do: identity and shape only."""
|
"""The cheap checks a producer thread may do: identity and shape only."""
|
||||||
if frame.geometry_id != assets.geometry_id:
|
if frame.geometry_id != assets.geometry_id:
|
||||||
return diagnostic(
|
return diagnostic(
|
||||||
@@ -452,7 +453,7 @@ class ThreeDCoordinator:
|
|||||||
# A frame arrived with its own fields: whatever went stale is current
|
# A frame arrived with its own fields: whatever went stale is current
|
||||||
# again, and the grace starts over next time it fails.
|
# again, and the grace starts over next time it fails.
|
||||||
self._stale_since = None
|
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"):
|
elif self._state in ("ready", "loading"):
|
||||||
# Nothing has arrived yet: show the reference model rather than a blank
|
# Nothing has arrived yet: show the reference model rather than a blank
|
||||||
# window, and say that no measurement is being displayed. Only once per
|
# 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.
|
# held field frame so the operator sees the change without waiting.
|
||||||
self._settings_dirty = True
|
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:
|
def show_rest(self) -> None:
|
||||||
"""Put the reference model back on screen and stop claiming a measurement.
|
"""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
|
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
||||||
current — resources are freed by the context that made them.
|
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
|
Two parts, two programs, one draw call per part. The sensing surface's colour comes
|
||||||
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
|
||||||
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
|
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
|
||||||
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
|
builds an RGB array per frame and never gathers the field onto the vertices; the
|
||||||
in the neutral colour rather than in colour zero — the reference model is not a
|
vertex buffer is uploaded once and re-uploaded only when a display deformation
|
||||||
measurement and must not look like one.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CameraMatrices,
|
CameraMatrices,
|
||||||
|
Diagnostic,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
GLCapabilities,
|
GLCapabilities,
|
||||||
MeshPart,
|
MeshPart,
|
||||||
@@ -41,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
error,
|
error,
|
||||||
freeze,
|
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
|
#: 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.
|
#: 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 is enough for a sequential ramp and cheap to rebuild.
|
||||||
LUT_SIZE = 256
|
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:
|
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||||
"""Select the default GL surface format. Call before ``QApplication``.
|
"""Select the default GL surface format. Call before ``QApplication``.
|
||||||
@@ -112,33 +126,42 @@ def profile_from_renderable_type(value: int) -> Profile:
|
|||||||
# --------------------------------------------------------------------- shaders
|
# --------------------------------------------------------------------- shaders
|
||||||
#: Shared bodies: the version header is the only difference, so the maths cannot
|
#: Shared bodies: the version header is the only difference, so the maths cannot
|
||||||
#: drift between the desktop and the embedded build.
|
#: 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 = """
|
_VERTEX_BODY = """
|
||||||
layout(location = 0) in vec3 a_position;
|
layout(location = 0) in vec3 a_position;
|
||||||
layout(location = 1) in vec3 a_normal;
|
layout(location = 1) in vec3 a_normal;
|
||||||
layout(location = 2) in float a_scalar;
|
layout(location = 4) in vec2 a_uv;
|
||||||
layout(location = 3) in float a_valid;
|
|
||||||
|
|
||||||
uniform mat4 u_view;
|
uniform mat4 u_view;
|
||||||
uniform mat4 u_projection;
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
out vec3 v_normal;
|
out vec3 v_normal;
|
||||||
out float v_scalar;
|
out vec2 v_uv;
|
||||||
out float v_valid;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
v_normal = a_normal;
|
v_normal = a_normal;
|
||||||
v_scalar = a_scalar;
|
v_uv = a_uv;
|
||||||
v_valid = a_valid;
|
|
||||||
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_SCALAR_FRAGMENT_BODY = """
|
_SCALAR_FRAGMENT_BODY = """
|
||||||
in vec3 v_normal;
|
in vec3 v_normal;
|
||||||
in float v_scalar;
|
in vec2 v_uv;
|
||||||
in float v_valid;
|
|
||||||
|
|
||||||
uniform sampler2D u_lut;
|
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_lower;
|
||||||
uniform float u_upper;
|
uniform float u_upper;
|
||||||
uniform vec4 u_invalid_color;
|
uniform vec4 u_invalid_color;
|
||||||
@@ -146,20 +169,25 @@ uniform vec4 u_invalid_color;
|
|||||||
out vec4 frag_color;
|
out vec4 frag_color;
|
||||||
|
|
||||||
void main() {
|
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;
|
frag_color = u_invalid_color;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
float span = max(u_upper - u_lower, 1e-12);
|
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);
|
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_DIFFUSE_FRAGMENT_BODY = """
|
_DIFFUSE_FRAGMENT_BODY = """
|
||||||
in vec3 v_normal;
|
in vec3 v_normal;
|
||||||
in float v_scalar;
|
|
||||||
in float v_valid;
|
|
||||||
|
|
||||||
uniform vec3 u_light_direction;
|
uniform vec3 u_light_direction;
|
||||||
uniform vec4 u_base_color;
|
uniform vec4 u_base_color;
|
||||||
@@ -235,7 +263,7 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
|||||||
return header + vertex, header + body
|
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.
|
"""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
|
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(
|
def _blend_scale_range(
|
||||||
previous: Optional[tuple[float, float]],
|
previous: tuple[float, float] | None,
|
||||||
current: Optional[tuple[float, float]],
|
current: tuple[float, float] | None,
|
||||||
*,
|
*,
|
||||||
alpha: float = 0.15,
|
alpha: float = 0.15,
|
||||||
) -> Optional[tuple[float, float]]:
|
) -> tuple[float, float] | None:
|
||||||
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
|
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
|
||||||
if current is None:
|
if current is None:
|
||||||
return previous
|
return previous
|
||||||
@@ -284,7 +312,7 @@ def _blend_scale_range(
|
|||||||
(1.0 - a) * float(previous[1]) + a * float(current[1]),
|
(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.
|
"""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
|
``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:
|
else:
|
||||||
fallback = (float(scale.lower), float(scale.upper))
|
fallback = (float(scale.lower), float(scale.upper))
|
||||||
lower, upper = float(fallback[0]), float(fallback[1])
|
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
|
return 0.0, 1.0
|
||||||
if upper <= lower:
|
if upper <= lower:
|
||||||
# A constant field still has to be visible rather than all one clipped colour.
|
# 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
|
return lower, upper
|
||||||
|
|
||||||
|
|
||||||
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
|
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
||||||
"""A 256x1 RGBA8 lookup table.
|
"""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
|
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
|
||||||
dependency for one texture, and the ramp is part of what the colours *mean*.
|
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
|
||||||
``sequential`` is perceptually increasing in lightness (safe for magnitude);
|
which is the dark theme's own. Either way the table is the *same* array the 2D
|
||||||
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
|
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
|
||||||
signed quantity, and never used for an unsigned one).
|
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(
|
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
|
||||||
[
|
|
||||||
[0.267, 0.005, 0.329],
|
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
|
||||||
[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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- vertex packing
|
# ---------------------------------------------------------------- vertex packing
|
||||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
|
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
|
||||||
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
|
#: 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
|
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,
|
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
|
||||||
valid: Optional[np.ndarray] = None) -> np.ndarray:
|
#: 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.
|
"""Interleave one part into the layout the shaders read.
|
||||||
|
|
||||||
The static case (no scalar yet) marks every vertex invalid, which the shader
|
``uvs`` are the render-vertex UVs on the domain's pixel grid
|
||||||
draws as the neutral colour: a scene that has never received a measurement
|
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
|
||||||
must not be coloured as if it had.
|
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])
|
count = int(part.positions_sensor_m.shape[0])
|
||||||
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
||||||
rows[:, 0:3] = np.asarray(part.positions_sensor_m, 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)
|
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
|
||||||
if scalar is None:
|
if uvs is None:
|
||||||
rows[:, 6] = np.nan
|
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
|
||||||
else:
|
else:
|
||||||
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
|
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
|
||||||
if values.size != count:
|
if values.shape[0] != count:
|
||||||
raise error(
|
raise error(
|
||||||
"geometry_mismatch",
|
"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
|
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 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)
|
|
||||||
return freeze(rows, dtype=np.float32)
|
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."""
|
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
||||||
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
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:
|
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
|
total = 0
|
||||||
for part in assets.parts:
|
for part in assets.parts:
|
||||||
vertices = int(part.positions_sensor_m.shape[0])
|
vertices = int(part.positions_sensor_m.shape[0])
|
||||||
indices = int(part.triangles.size)
|
indices = int(part.triangles.size)
|
||||||
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
||||||
if part.part_id == dynamic_part_id:
|
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 += vertices * STRIDE_FLOATS * 4
|
||||||
total += LUT_SIZE * 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
|
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.
|
"""The asset's own geometry, with nothing measured.
|
||||||
|
|
||||||
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
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
|
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
|
# ------------------------------------------------------------------- dispatch
|
||||||
@@ -543,9 +599,20 @@ def _leading_int(text: str) -> int:
|
|||||||
class _PartBuffers:
|
class _PartBuffers:
|
||||||
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
"""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.part_id = part.part_id
|
||||||
self.role = part.role
|
self.role = part.role
|
||||||
self.vao = vao
|
self.vao = vao
|
||||||
@@ -553,23 +620,34 @@ class _PartBuffers:
|
|||||||
self.ebo = ebo
|
self.ebo = ebo
|
||||||
self.triangles = int(part.triangles.shape[0])
|
self.triangles = int(part.triangles.shape[0])
|
||||||
self.vertices = int(part.positions_sensor_m.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:
|
class GLRenderer:
|
||||||
"""Shaders, buffers and draw calls for one scene."""
|
"""Shaders, buffers and draw calls for one scene."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._capabilities: Optional[GLCapabilities] = None
|
self._capabilities: GLCapabilities | None = None
|
||||||
self._programs: dict[str, int] = {}
|
self._programs: dict[str, int] = {}
|
||||||
self._uniforms: dict[str, dict[str, int]] = {}
|
self._uniforms: dict[str, dict[str, int]] = {}
|
||||||
self._parts: dict[str, _PartBuffers] = {}
|
self._parts: dict[str, _PartBuffers] = {}
|
||||||
self._lut: int = 0
|
self._lut: int = 0
|
||||||
self._lut_id: str = ""
|
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.
|
#: 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.
|
#: 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._last_scale_mode = ""
|
||||||
self._sensing_part_id: str = ""
|
self._sensing_part_id: str = ""
|
||||||
self._estimated_gpu_bytes: int = 0
|
self._estimated_gpu_bytes: int = 0
|
||||||
@@ -621,8 +699,11 @@ class GLRenderer:
|
|||||||
self._scalar_range = None
|
self._scalar_range = None
|
||||||
self._last_scale = None
|
self._last_scale = None
|
||||||
self._last_scale_mode = ""
|
self._last_scale_mode = ""
|
||||||
|
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
|
||||||
for part in assets.parts:
|
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))
|
vao = int(GL.glGenVertexArrays(1))
|
||||||
vbo = int(GL.glGenBuffers(1))
|
vbo = int(GL.glGenBuffers(1))
|
||||||
ebo = int(GL.glGenBuffers(1))
|
ebo = int(GL.glGenBuffers(1))
|
||||||
@@ -639,13 +720,24 @@ class GLRenderer:
|
|||||||
GL.glBindVertexArray(0)
|
GL.glBindVertexArray(0)
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
GL.glBindBuffer(GL.GL_ELEMENT_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(
|
self._estimated_gpu_bytes = estimate_gpu_bytes(
|
||||||
assets, dynamic_part_id=self._sensing_part_id
|
assets, dynamic_part_id=self._sensing_part_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_surface(self, frame: RenderFrame) -> None:
|
def update_field(self, frame: RenderFrame) -> None:
|
||||||
"""Update the sensing part's vertices in place: no index rebuild, ever."""
|
"""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
|
from OpenGL import GL
|
||||||
|
|
||||||
self._require_initialized()
|
self._require_initialized()
|
||||||
@@ -655,26 +747,132 @@ class GLRenderer:
|
|||||||
"geometry_mismatch",
|
"geometry_mismatch",
|
||||||
f"the scene has no part {frame.sensing_part_id!r} to update",
|
f"the scene has no part {frame.sensing_part_id!r} to update",
|
||||||
)
|
)
|
||||||
rows = pack_part(
|
field = frame.field
|
||||||
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
|
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
|
||||||
scalar=frame.scalar,
|
|
||||||
valid=frame.valid,
|
|
||||||
)
|
|
||||||
if int(rows.shape[0]) != buffers.vertices:
|
|
||||||
raise error(
|
raise error(
|
||||||
"geometry_mismatch",
|
"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",
|
f"{buffers.vertices} uploaded",
|
||||||
)
|
)
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
# 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.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
|
self._upload_field_textures(field)
|
||||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
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._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
||||||
self._upload_overlays(frame)
|
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:
|
def _upload_overlays(self, frame: RenderFrame) -> None:
|
||||||
"""Upload merged flow / contact arrow meshes for this frame."""
|
"""Upload merged flow / contact arrow meshes for this frame."""
|
||||||
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
||||||
@@ -737,8 +935,12 @@ class GLRenderer:
|
|||||||
GL.glDeleteVertexArrays(1, [int(vao)])
|
GL.glDeleteVertexArrays(1, [int(vao)])
|
||||||
GL.glDeleteBuffers(1, [int(vbo)])
|
GL.glDeleteBuffers(1, [int(vbo)])
|
||||||
GL.glDeleteBuffers(1, [int(ebo)])
|
GL.glDeleteBuffers(1, [int(ebo)])
|
||||||
except Exception: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001 - best-effort delete, reported below
|
||||||
pass
|
# 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(
|
def draw(
|
||||||
self,
|
self,
|
||||||
@@ -768,7 +970,9 @@ class GLRenderer:
|
|||||||
# panel has to be able to ask what the picture means.
|
# panel has to be able to ask what the picture means.
|
||||||
self._last_scale = (float(lower), float(upper))
|
self._last_scale = (float(lower), float(upper))
|
||||||
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
|
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():
|
for part in self._parts.values():
|
||||||
if part.role == "housing" and not settings.housing_visible:
|
if part.role == "housing" and not settings.housing_visible:
|
||||||
continue
|
continue
|
||||||
@@ -781,10 +985,7 @@ class GLRenderer:
|
|||||||
_set_float(GL, locations.get("u_lower"), lower)
|
_set_float(GL, locations.get("u_lower"), lower)
|
||||||
_set_float(GL, locations.get("u_upper"), upper)
|
_set_float(GL, locations.get("u_upper"), upper)
|
||||||
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
||||||
if locations.get("u_lut") is not None:
|
self._bind_field_textures(GL, locations)
|
||||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
||||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
|
||||||
GL.glUniform1i(locations["u_lut"], 0)
|
|
||||||
else:
|
else:
|
||||||
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
|
_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))
|
_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,
|
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)."""
|
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||||
return self._last_scale
|
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:
|
def release(self) -> None:
|
||||||
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
@@ -858,6 +1102,7 @@ class GLRenderer:
|
|||||||
pass
|
pass
|
||||||
self._lut = 0
|
self._lut = 0
|
||||||
self._lut_id = ""
|
self._lut_id = ""
|
||||||
|
self._release_field_textures()
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._estimated_gpu_bytes = 0
|
self._estimated_gpu_bytes = 0
|
||||||
self._scalar_range = None
|
self._scalar_range = None
|
||||||
@@ -865,6 +1110,20 @@ class GLRenderer:
|
|||||||
self._last_scale_mode = ""
|
self._last_scale_mode = ""
|
||||||
self._paint_calls = 0
|
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:
|
def release_parts(self) -> None:
|
||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
|
|
||||||
@@ -890,7 +1149,7 @@ class GLRenderer:
|
|||||||
if self._lut:
|
if self._lut:
|
||||||
GL.glDeleteTextures([int(self._lut)])
|
GL.glDeleteTextures([int(self._lut)])
|
||||||
self._lut = 0
|
self._lut = 0
|
||||||
data = lut_rgba(lut_id)
|
data = lut_rgba(lut_id, self._lut_ramp)
|
||||||
self._lut = int(GL.glGenTextures(1))
|
self._lut = int(GL.glGenTextures(1))
|
||||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
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)
|
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||||
self._lut_id = lut_id
|
self._lut_id = lut_id
|
||||||
|
|
||||||
def capabilities(self) -> Optional[GLCapabilities]:
|
def capabilities(self) -> GLCapabilities | None:
|
||||||
return self._capabilities
|
return self._capabilities
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -916,17 +1175,6 @@ class GLRenderer:
|
|||||||
return self._initialized
|
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
|
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
|
||||||
#: it never competes with the measurement colour scale.
|
#: it never competes with the measurement colour scale.
|
||||||
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
|
_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:
|
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
|
stride = STRIDE_FLOATS * 4
|
||||||
GL.glEnableVertexAttribArray(0)
|
GL.glEnableVertexAttribArray(0)
|
||||||
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
||||||
GL.glEnableVertexAttribArray(1)
|
GL.glEnableVertexAttribArray(1)
|
||||||
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
||||||
GL.glEnableVertexAttribArray(2)
|
GL.glEnableVertexAttribArray(4)
|
||||||
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
|
GL.glVertexAttribPointer(
|
||||||
GL.glEnableVertexAttribArray(3)
|
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
|
||||||
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
|
)
|
||||||
|
|
||||||
|
|
||||||
def _offset(floats: int) -> Any:
|
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]:
|
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
|
||||||
names = (
|
names = (
|
||||||
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
|
"u_view", "u_projection", "u_lower", "u_upper",
|
||||||
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
|
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
|
||||||
|
"u_light_direction", "u_base_color",
|
||||||
)
|
)
|
||||||
out: dict[str, int] = {}
|
out: dict[str, int] = {}
|
||||||
for name in names:
|
for name in names:
|
||||||
@@ -1002,7 +1259,7 @@ def _decode(value: Any) -> str:
|
|||||||
return str(value).strip()
|
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:
|
if location is None:
|
||||||
return
|
return
|
||||||
# Column-major upload, explicitly: the layout of the NumPy array must not
|
# 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))
|
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:
|
if location is not None:
|
||||||
GL.glUniform1f(location, float(value))
|
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:
|
if location is not None:
|
||||||
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2]))
|
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:
|
if location is not None:
|
||||||
GL.glUniform4f(
|
GL.glUniform4f(
|
||||||
location, float(values[0]), float(values[1]), float(values[2]), float(values[3])
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Optional
|
from typing import Any
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
|
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
|
|||||||
class SceneKeys:
|
class SceneKeys:
|
||||||
"""What the loader needs to authenticate a package — and how strict to be."""
|
"""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
|
provider: Any = None
|
||||||
require_signature: bool = False
|
require_signature: bool = False
|
||||||
#: Paths/ids only, for logs and for the recording: never material.
|
#: 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.
|
"""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
|
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 {})
|
prefs = dict(prefs or {})
|
||||||
sources: dict[str, str] = {}
|
sources: dict[str, str] = {}
|
||||||
verify_pem: Optional[bytes] = None
|
verify_pem: bytes | None = None
|
||||||
provider = None
|
provider = None
|
||||||
|
|
||||||
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip()
|
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.
|
"""Refuse an unsigned package when the application is in release mode.
|
||||||
|
|
||||||
Development builds accept a plain package (the shipped engineering one is exactly
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import numpy as np
|
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.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QImage
|
from PySide6.QtGui import QImage
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -38,18 +48,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES
|
||||||
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.ui.fit_text import ShrinkableLabel
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
|
||||||
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
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.
|
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)
|
super().__init__(parent)
|
||||||
self.setObjectName("colorScaleBar")
|
self.setObjectName("colorScaleBar")
|
||||||
self.setFixedHeight(12)
|
self.setFixedHeight(12)
|
||||||
@@ -90,6 +89,9 @@ class ColorScaleBar(QLabel):
|
|||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
self._lut_id = "sequential"
|
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:
|
def set_lut(self, lut_id: str) -> None:
|
||||||
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
||||||
@@ -98,6 +100,13 @@ class ColorScaleBar(QLabel):
|
|||||||
self._lut_id = wanted
|
self._lut_id = wanted
|
||||||
self.update()
|
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
|
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
from PySide6.QtGui import QPainter
|
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,
|
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||||
# which is what QImage's borrowed buffer needs.
|
# 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)
|
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.drawImage(self.rect(), image)
|
painter.drawImage(self.rect(), image)
|
||||||
@@ -124,23 +135,23 @@ class ThreeDPanel(QFrame):
|
|||||||
self,
|
self,
|
||||||
coordinator: ThreeDCoordinator,
|
coordinator: ThreeDCoordinator,
|
||||||
widget: Sensor3DWidget,
|
widget: Sensor3DWidget,
|
||||||
parent: Optional[QWidget] = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("contentPanel")
|
self.setObjectName("contentPanel")
|
||||||
self._coordinator = coordinator
|
self._coordinator = coordinator
|
||||||
self._widget = widget
|
self._widget = widget
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: SceneAssets | None = None
|
||||||
self._descriptor: Optional[FieldDescriptor] = None
|
self._descriptor: FieldDescriptor | None = None
|
||||||
self._state: SceneState = "disabled"
|
self._state: SceneState = "disabled"
|
||||||
self._source_kind = "live"
|
self._source_kind = "live"
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
#: What the data source can provide (see ``contracts.SceneCapabilities``): an
|
#: What the data source can provide (see ``contracts.SceneCapabilities``): an
|
||||||
#: unavailable capability disables its control and says why, rather than
|
#: unavailable capability disables its control and says why, rather than
|
||||||
#: letting the operator operate an input that does not exist.
|
#: 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).
|
#: 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
|
#: Whether the pane explains the pipeline around the picture (the window's
|
||||||
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
||||||
self._show_diagnostics = False
|
self._show_diagnostics = False
|
||||||
@@ -180,8 +191,9 @@ class ThreeDPanel(QFrame):
|
|||||||
|
|
||||||
self.housing_check = QCheckBox()
|
self.housing_check = QCheckBox()
|
||||||
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
|
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
|
||||||
# starting state and the settings it reports must be the same thing.
|
# starting state and the settings it reports must be the same thing. That default is
|
||||||
self.housing_check.setChecked(True)
|
# now unchecked — the housing is a mechanical check, not the measurement.
|
||||||
|
self.housing_check.setChecked(False)
|
||||||
self.housing_check.toggled.connect(self._emit_settings)
|
self.housing_check.toggled.connect(self._emit_settings)
|
||||||
form.addRow(self.housing_check)
|
form.addRow(self.housing_check)
|
||||||
|
|
||||||
@@ -342,7 +354,7 @@ class ThreeDPanel(QFrame):
|
|||||||
self.set_state("disabled")
|
self.set_state("disabled")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ inputs
|
# ------------------------------------------------------------------ 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."""
|
"""Fill the preset list from the asset: its default view is the asset's."""
|
||||||
self._assets = assets
|
self._assets = assets
|
||||||
self._syncing = True
|
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.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
|
||||||
self.reset_button.setEnabled(assets is not None)
|
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."""
|
"""The measurement currently on screen — or none, which hides the bar."""
|
||||||
if descriptor != self._descriptor:
|
if descriptor != self._descriptor:
|
||||||
self._effective_scale = None
|
self._effective_scale = None
|
||||||
@@ -392,7 +404,19 @@ class ThreeDPanel(QFrame):
|
|||||||
self._render_scale_bounds()
|
self._render_scale_bounds()
|
||||||
self._render_legend()
|
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.
|
"""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
|
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")
|
else t("scene3d.contact_arrow_tip")
|
||||||
)
|
)
|
||||||
|
|
||||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
def capabilities(self) -> SceneCapabilities | None:
|
||||||
return self._capabilities
|
return self._capabilities
|
||||||
|
|
||||||
def _render_legend(self) -> None:
|
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.setToolTip(t(f"scene3d.source_{kind}_tip"))
|
||||||
self.source_label.setVisible(self._show_diagnostics)
|
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.
|
"""One diagnostic line. The 2D views stay usable behind it.
|
||||||
|
|
||||||
Warnings and errors are always stated: they are claims about the *measurement*
|
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 __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 (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
UNAVAILABLE_DESCRIPTOR,
|
||||||
ColorScale,
|
ColorScale,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
FieldDescriptor,
|
FieldDescriptor,
|
||||||
UNAVAILABLE_DESCRIPTOR,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Frame rates the pane offers. A saved value outside this set is repaired to the
|
#: 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)
|
number = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return fallback
|
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 fallback
|
||||||
return number
|
return number
|
||||||
|
|
||||||
@@ -102,9 +104,9 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def settings_from_prefs(
|
def settings_from_prefs(
|
||||||
raw: Optional[Mapping[str, Any]],
|
raw: Mapping[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
descriptor: Optional[FieldDescriptor] = None,
|
descriptor: FieldDescriptor | None = None,
|
||||||
) -> DisplaySettings:
|
) -> DisplaySettings:
|
||||||
"""Rebuild the display settings, repairing anything unusable.
|
"""Rebuild the display settings, repairing anything unusable.
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@ def settings_from_prefs(
|
|||||||
field = str(raw.get("field") or DEFAULT_FIELD)
|
field = str(raw.get("field") or DEFAULT_FIELD)
|
||||||
if field not in FIELD_CHOICES:
|
if field not in FIELD_CHOICES:
|
||||||
field = DEFAULT_FIELD
|
field = DEFAULT_FIELD
|
||||||
scale: Optional[ColorScale] = None
|
scale: ColorScale | None = None
|
||||||
saved = raw.get("color_scale")
|
saved = raw.get("color_scale")
|
||||||
if isinstance(saved, Mapping):
|
if isinstance(saved, Mapping):
|
||||||
mode = str(saved.get("mode") or "fixed")
|
mode = str(saved.get("mode") or "fixed")
|
||||||
@@ -158,7 +160,7 @@ def settings_from_prefs(
|
|||||||
display_gain=gain,
|
display_gain=gain,
|
||||||
deform_enabled=_flag(raw.get("deform_enabled"), False),
|
deform_enabled=_flag(raw.get("deform_enabled"), False),
|
||||||
color_scale=scale,
|
color_scale=scale,
|
||||||
housing_visible=_flag(raw.get("housing_visible"), True),
|
housing_visible=_flag(raw.get("housing_visible"), False),
|
||||||
max_fps=max_fps,
|
max_fps=max_fps,
|
||||||
field=field,
|
field=field,
|
||||||
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
|
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 __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.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -25,8 +26,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
||||||
from orisys_studio.scene3d.panel import ThreeDPanel
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
"""A modeless home for the 3D pane's controls."""
|
"""A modeless home for the 3D pane's controls."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
|
self, panel: ThreeDPanel, parent: QWidget | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("scene3dSettings")
|
self.setObjectName("scene3dSettings")
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ description disagrees with the asset is refused with both shapes named.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Signal
|
|
||||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
diagnostic,
|
diagnostic,
|
||||||
validate_assets,
|
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.adapters import BoxFrameAdapter, FingerFrameAdapter
|
||||||
from orisys_studio.scene3d.camera import OrbitCamera
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
@@ -90,17 +91,24 @@ class Scene3DView(QWidget):
|
|||||||
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||||
enabledChanged = Signal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._available, self._reason = gl_available()
|
self._available, self._reason = gl_available()
|
||||||
self._widget: Optional[Sensor3DWidget] = None
|
self._widget: Sensor3DWidget | None = None
|
||||||
self._panel: Optional[ThreeDPanel] = None
|
self._panel: ThreeDPanel | None = None
|
||||||
self._coordinator: Optional[ThreeDCoordinator] = None
|
self._coordinator: ThreeDCoordinator | None = None
|
||||||
self._camera = OrbitCamera()
|
self._camera = OrbitCamera()
|
||||||
self._executor: Optional[ThreadPoolExecutor] = None
|
self._executor: ThreadPoolExecutor | None = None
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: SceneAssets | None = None
|
||||||
self._expected: Optional[ExpectedGeometry] = None
|
self._composer: SceneComposer | None = None
|
||||||
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = 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,
|
#: 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
|
#: 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.
|
#: offer. Kept rather than closed over so those answers can be asked.
|
||||||
@@ -108,11 +116,11 @@ class Scene3DView(QWidget):
|
|||||||
self._source_id = ""
|
self._source_id = ""
|
||||||
self._enabled = False
|
self._enabled = False
|
||||||
self._source_kind = "live"
|
self._source_kind = "live"
|
||||||
self._last_diagnostic: Optional[Diagnostic] = None
|
self._last_diagnostic: Diagnostic | None = None
|
||||||
self._last_descriptor: Optional[FieldDescriptor] = None
|
self._last_descriptor: FieldDescriptor | None = None
|
||||||
self._last_stats: Optional[RenderStats] = None
|
self._last_stats: RenderStats | None = None
|
||||||
#: The coordinator's last diagnostic as *this* view has already reported it.
|
#: 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.
|
#: Whether the loaded asset was compared against the device's own description.
|
||||||
self._device_verified = False
|
self._device_verified = False
|
||||||
#: Frames the producer did not even build (a frame was already waiting).
|
#: 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._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
|
||||||
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
||||||
|
self._composer = SceneComposer()
|
||||||
self._coordinator = ThreeDCoordinator(
|
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)
|
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
||||||
# The picture must not be squeezed out of existence by the controls above it:
|
# The picture must not be squeezed out of existence by the controls above it:
|
||||||
@@ -165,7 +174,7 @@ class Scene3DView(QWidget):
|
|||||||
self,
|
self,
|
||||||
assets: SceneAssets,
|
assets: SceneAssets,
|
||||||
source_id: str,
|
source_id: str,
|
||||||
expected: Optional[ExpectedGeometry] = None,
|
expected: ExpectedGeometry | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Adopt a validated scene. Returns whether the view accepted it.
|
"""Adopt a validated scene. Returns whether the view accepted it.
|
||||||
|
|
||||||
@@ -234,8 +243,8 @@ class Scene3DView(QWidget):
|
|||||||
return not violations
|
return not violations
|
||||||
|
|
||||||
def _make_snapshot(
|
def _make_snapshot(
|
||||||
self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
|
self, assets: SceneAssets, expected: ExpectedGeometry | None
|
||||||
) -> Optional[Callable[[Any, FrameIdentity], Any]]:
|
) -> Callable[[Any, FrameIdentity], Any] | None:
|
||||||
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
|
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
|
||||||
|
|
||||||
The two adapters have different call signatures because they need different
|
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 "")
|
asset_id = str(getattr(assets, "asset_id", "") or "")
|
||||||
self._adapter = None
|
self._adapter = None
|
||||||
device = expected
|
device = expected
|
||||||
common = {
|
common = { "geometry_id": str(
|
||||||
"geometry_id": str(
|
|
||||||
device.geometry_id if device is not None and device.geometry_id
|
device.geometry_id if device is not None and device.geometry_id
|
||||||
else assets.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
|
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
|
||||||
# passed here. Remapping it again would index the flow at source pixels.
|
# passed here. Remapping it again would index the flow at source pixels.
|
||||||
adapter = FingerFrameAdapter(**common)
|
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
|
self._adapter = adapter
|
||||||
return lambda packet, identity: adapter.snapshot(packet, None, identity)
|
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
|
# --------------------------------------------------------------- producer
|
||||||
def submit_packet(self, packet: Any) -> None:
|
def submit_packet(self, packet: Any) -> None:
|
||||||
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
|
"""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
|
except Exception: # noqa: BLE001 - a display sink must never break capture
|
||||||
logger.exception("the 3D sink failed for one packet")
|
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)."""
|
"""Why the adapter refused the last packet (the adapter owns the answer)."""
|
||||||
item = getattr(self._adapter, "last_diagnostic", None)
|
item = getattr(self._adapter, "last_diagnostic", None)
|
||||||
return item if isinstance(item, Diagnostic) else 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."""
|
"""The identity a field frame built from this packet would carry."""
|
||||||
if self._coordinator is None or self._assets is None:
|
if self._coordinator is None or self._assets is None:
|
||||||
return None
|
return None
|
||||||
@@ -406,7 +449,7 @@ class Scene3DView(QWidget):
|
|||||||
self._panel.set_source_kind(kind)
|
self._panel.set_source_kind(kind)
|
||||||
self._publish_capabilities()
|
self._publish_capabilities()
|
||||||
|
|
||||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
def capabilities(self) -> SceneCapabilities | None:
|
||||||
"""What this data source can provide, for the panel's controls.
|
"""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
|
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
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def assets(self) -> Optional[SceneAssets]:
|
def assets(self) -> SceneAssets | None:
|
||||||
return self._assets
|
return self._assets
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def coordinator(self) -> Optional[ThreeDCoordinator]:
|
def coordinator(self) -> ThreeDCoordinator | None:
|
||||||
return self._coordinator
|
return self._coordinator
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gl_widget(self) -> Optional[Sensor3DWidget]:
|
def gl_widget(self) -> Sensor3DWidget | None:
|
||||||
return self._widget
|
return self._widget
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def panel(self) -> Optional[ThreeDPanel]:
|
def panel(self) -> ThreeDPanel | None:
|
||||||
return self._panel
|
return self._panel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
def last_diagnostic(self) -> Diagnostic | None:
|
||||||
return self._last_diagnostic
|
return self._last_diagnostic
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_descriptor(self) -> Optional[FieldDescriptor]:
|
def last_descriptor(self) -> FieldDescriptor | None:
|
||||||
return self._last_descriptor
|
return self._last_descriptor
|
||||||
|
|
||||||
def state(self) -> SceneState:
|
def state(self) -> SceneState:
|
||||||
@@ -526,7 +569,7 @@ class Scene3DView(QWidget):
|
|||||||
return "disabled"
|
return "disabled"
|
||||||
return self._coordinator.state
|
return self._coordinator.state
|
||||||
|
|
||||||
def render_stats(self) -> Optional[RenderStats]:
|
def render_stats(self) -> RenderStats | None:
|
||||||
return self._last_stats
|
return self._last_stats
|
||||||
|
|
||||||
def describe(self) -> dict:
|
def describe(self) -> dict:
|
||||||
@@ -709,7 +752,7 @@ class Scene3DView(QWidget):
|
|||||||
self._panel.set_state(state)
|
self._panel.set_state(state)
|
||||||
self.stateChanged.emit(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."""
|
"""Raise a coordinator diagnostic once, when it changes."""
|
||||||
if item is None or item == self._reported_diagnostic:
|
if item is None or item == self._reported_diagnostic:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,23 +17,24 @@ two-dimensional application carries on.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
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 (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
|
FrameIdentity,
|
||||||
GLCapabilities,
|
GLCapabilities,
|
||||||
RenderFrame,
|
RenderFrame,
|
||||||
RenderStats,
|
RenderStats,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
diagnostic,
|
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.camera import OrbitCamera
|
||||||
from orisys_studio.scene3d.gl_backend import (
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
GLDispatch,
|
GLDispatch,
|
||||||
@@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
self,
|
self,
|
||||||
renderer: GLRenderer,
|
renderer: GLRenderer,
|
||||||
camera: OrbitCamera,
|
camera: OrbitCamera,
|
||||||
parent: Optional[QWidget] = None,
|
parent: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
dispatch: Optional[GLDispatch] = None,
|
dispatch: GLDispatch | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._renderer = renderer
|
self._renderer = renderer
|
||||||
self._camera = camera
|
self._camera = camera
|
||||||
self._dispatch = dispatch or GLDispatch()
|
self._dispatch = dispatch or GLDispatch()
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: SceneAssets | None = None
|
||||||
self._frame: Optional[RenderFrame] = None
|
self._frame: RenderFrame | None = None
|
||||||
self._settings = DisplaySettings()
|
self._settings = DisplaySettings()
|
||||||
self._capabilities: Optional[GLCapabilities] = None
|
self._capabilities: GLCapabilities | None = None
|
||||||
self._diagnostic: Optional[Diagnostic] = None
|
self._diagnostic: Diagnostic | None = None
|
||||||
self._needs_scene_upload = False
|
self._needs_scene_upload = False
|
||||||
self._needs_surface_upload = False
|
self._needs_surface_upload = False
|
||||||
self._last_stats_at = 0.0
|
self._last_stats_at = 0.0
|
||||||
#: What reached the screen, counted by identity, and what it cost to compose.
|
#: What reached the screen, counted by identity, and what it cost to compose.
|
||||||
self._unique_frames = 0
|
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_compose_ms = 0.0
|
||||||
self._last_press: Optional[tuple[float, float]] = None
|
self._last_press: tuple[float, float] | None = None
|
||||||
self._unavailable = False
|
self._unavailable = False
|
||||||
#: Frames the display never showed, set by the coordinator. Never a
|
#: Frames the display never showed, set by the coordinator. Never a
|
||||||
#: recording figure.
|
#: recording figure.
|
||||||
@@ -109,7 +110,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
self._last_frame_identity = frame.identity
|
self._last_frame_identity = frame.identity
|
||||||
self._unique_frames += 1
|
self._unique_frames += 1
|
||||||
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
|
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()
|
self.update()
|
||||||
|
|
||||||
def set_settings(self, settings: DisplaySettings) -> None:
|
def set_settings(self, settings: DisplaySettings) -> None:
|
||||||
@@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
def camera(self) -> OrbitCamera:
|
def camera(self) -> OrbitCamera:
|
||||||
return self._camera
|
return self._camera
|
||||||
|
|
||||||
def capabilities(self) -> Optional[GLCapabilities]:
|
def capabilities(self) -> GLCapabilities | None:
|
||||||
return self._capabilities
|
return self._capabilities
|
||||||
|
|
||||||
def displayed_frames(self) -> int:
|
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."""
|
"""What the frame on screen cost to compose, measured where it was composed."""
|
||||||
return float(self._last_compose_ms)
|
return float(self._last_compose_ms)
|
||||||
|
|
||||||
def diagnostic(self) -> Optional[Diagnostic]:
|
def diagnostic(self) -> Diagnostic | None:
|
||||||
return self._diagnostic
|
return self._diagnostic
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -216,11 +217,11 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._assets is not None:
|
if self._assets is not None:
|
||||||
self._renderer.update_surface(
|
self._renderer.update_field(
|
||||||
rest_surface(self._assets, frame.identity)
|
rest_surface(self._assets, frame.identity)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._renderer.update_surface(frame)
|
self._renderer.update_field(frame)
|
||||||
stats = self._renderer.draw(
|
stats = self._renderer.draw(
|
||||||
self._camera.matrices(self.viewport_pixels()),
|
self._camera.matrices(self.viewport_pixels()),
|
||||||
self._settings,
|
self._settings,
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Optional
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
from PySide6.QtCore import QObject, QTimer, Signal
|
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import QObject, QTimer, Signal
|
||||||
|
|
||||||
from orisys_studio import session_metadata
|
from orisys_studio import session_metadata
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,7 +59,7 @@ class SessionController(QObject):
|
|||||||
*,
|
*,
|
||||||
recording: Any,
|
recording: Any,
|
||||||
packet_for_3d: Callable[[Any], None],
|
packet_for_3d: Callable[[Any], None],
|
||||||
packet_sink: Optional[Callable[[Any], None]] = None,
|
packet_sink: Callable[[Any], None] | None = None,
|
||||||
sensor_type: str = "box",
|
sensor_type: str = "box",
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -82,9 +83,9 @@ class SessionController(QObject):
|
|||||||
self._connect_slow_notified = False
|
self._connect_slow_notified = False
|
||||||
self._raw_fallback_notified = False
|
self._raw_fallback_notified = False
|
||||||
# --- per-session telemetry (read by the rail, the plots and the record) -----
|
# --- per-session telemetry (read by the rail, the plots and the record) -----
|
||||||
self.last_sample_valid: Optional[bool] = None
|
self.last_sample_valid: bool | None = None
|
||||||
self.latest_fps: Optional[float] = None
|
self.latest_fps: float | None = None
|
||||||
self.latest_process_ms: Optional[float] = None
|
self.latest_process_ms: float | None = None
|
||||||
self.latest_capture_fps = 0.0
|
self.latest_capture_fps = 0.0
|
||||||
self.latest_capture_dropped = 0
|
self.latest_capture_dropped = 0
|
||||||
#: How many readouts failed this session, for the status line's count.
|
#: 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
|
return consumer() if callable(consumer) else None
|
||||||
|
|
||||||
# ------------------------------------------------------------- presenter help
|
# ------------------------------------------------------------- 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.
|
"""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:
|
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)."""
|
"""The status line for a failed readout (the phase is deliberately untouched)."""
|
||||||
return t("status.sample_invalid", count=self.record_error_count, reason=reason)
|
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."""
|
"""One message per new batch of samples the memory bound discarded."""
|
||||||
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
|
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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.force.filters import FilterSettings, settings_from_mapping
|
||||||
from orisys_app_shared.record import RecordConfig
|
from orisys_app_shared.record import RecordConfig
|
||||||
@@ -198,7 +199,8 @@ class SessionFacts:
|
|||||||
|
|
||||||
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
|
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
|
||||||
"""The ``session.json`` metadata written when a recording starts."""
|
"""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
|
from orisys_studio import __version__ as app_version
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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.QtCore import Qt
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
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
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
|
ARROW_PURE,
|
||||||
from PySide6.QtCore import QSize, Qt, Signal
|
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.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -23,15 +28,6 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
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
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
|
||||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
||||||
@@ -340,7 +336,7 @@ class ArrowPanel(QFrame):
|
|||||||
self._placeholder = self._empty_message()
|
self._placeholder = self._empty_message()
|
||||||
self.image_label.setText(self._placeholder)
|
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.
|
"""Adopt what the frame on screen can be shown as.
|
||||||
|
|
||||||
The modes come from the frame's own fields (see ``image_backend.plan_image``),
|
The modes come from the frame's own fields (see ``image_backend.plan_image``),
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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.i18n import t
|
||||||
from orisys_app_shared.paths import logo_topbar_path
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
from orisys_app_shared.theme import color
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
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.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -28,19 +34,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
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.calibration_service import CalibrationService
|
||||||
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
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.QtGui import QFont, QFontDatabase
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -17,10 +18,6 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.force import NA
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
from orisys_app_shared.theme import color
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
except ImportError as exc: # pragma: no cover
|
except ImportError as exc: # pragma: no cover
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import numpy as np
|
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.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -15,14 +27,6 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
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 import icons
|
||||||
from orisys_studio.ui.icons import ICON_SIZE
|
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]]:
|
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:
|
if hi <= lo:
|
||||||
return [(lo, f"{lo:g}")]
|
return [(lo, f"{lo:g}")]
|
||||||
step = (hi - lo) / max(1, count - 1)
|
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(
|
def format_force(
|
||||||
@@ -102,11 +129,11 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
|
|||||||
class _Sync:
|
class _Sync:
|
||||||
"""Marks a block of range changes as programmatic (not a user gesture)."""
|
"""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._panel = panel
|
||||||
self._previous = panel._syncing
|
self._previous = panel._syncing
|
||||||
|
|
||||||
def __enter__(self) -> "_Sync":
|
def __enter__(self) -> _Sync:
|
||||||
self._panel._syncing = True
|
self._panel._syncing = True
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -133,8 +160,12 @@ class ForcePlotsPanel(QFrame):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("contentPanel")
|
self.setObjectName("contentPanel")
|
||||||
self._theme = theme
|
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._fn_max = 20.0
|
||||||
self._shear_max = 20.0
|
self._shear_max = 20.0
|
||||||
|
self._fn_raw_min = 0.0
|
||||||
self._fn_raw_max = 1000000.0
|
self._fn_raw_max = 1000000.0
|
||||||
self._shear_raw_max = 500000.0
|
self._shear_raw_max = 500000.0
|
||||||
self._fixed_newton = True
|
self._fixed_newton = True
|
||||||
@@ -142,8 +173,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
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?
|
#: Per axis: does ``_last_values`` hold a real value in the active unit?
|
||||||
self._available: tuple[bool, bool, bool] = (True, True, True)
|
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)
|
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._sensor_type = "box"
|
||||||
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
|
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
|
||||||
@@ -261,6 +297,10 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.plots = []
|
self.plots = []
|
||||||
self.curves = []
|
self.curves = []
|
||||||
self.cursors = []
|
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)
|
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
|
||||||
for i, _name in enumerate(CHANNEL_LABELS):
|
for i, _name in enumerate(CHANNEL_LABELS):
|
||||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||||
@@ -283,6 +323,14 @@ class ForcePlotsPanel(QFrame):
|
|||||||
)
|
)
|
||||||
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
|
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
|
||||||
plot.addItem(cursor, ignoreBounds=True)
|
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
|
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
|
||||||
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
|
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
|
||||||
# also fires when a resize propagates through the X links, which is
|
# also fires when a resize propagates through the X links, which is
|
||||||
@@ -291,6 +339,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.plots.append(plot)
|
self.plots.append(plot)
|
||||||
self.curves.append(curve)
|
self.curves.append(curve)
|
||||||
self.cursors.append(cursor)
|
self.cursors.append(cursor)
|
||||||
|
self.zero_lines.append(zero_line)
|
||||||
|
|
||||||
# Whole-session overview: the full envelope plus the current window.
|
# Whole-session overview: the full envelope plus the current window.
|
||||||
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
|
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))
|
self.overview_region.setBrush(_window_brush(theme))
|
||||||
for cursor in (*self.cursors, self.overview_cursor):
|
for cursor in (*self.cursors, self.overview_cursor):
|
||||||
cursor.setPen(pg.mkPen(muted, width=1))
|
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)
|
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
|
||||||
for curve, pen in zip(self.curves, pens):
|
for curve, pen in zip(self.curves, pens):
|
||||||
curve.setPen(pg.mkPen(pen, width=2))
|
curve.setPen(pg.mkPen(pen, width=2))
|
||||||
@@ -473,31 +524,77 @@ class ForcePlotsPanel(QFrame):
|
|||||||
|
|
||||||
def set_axis_limits(
|
def set_axis_limits(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
|
fn_min: float,
|
||||||
fn_max: float,
|
fn_max: float,
|
||||||
shear_max: float,
|
shear_max: float,
|
||||||
fn_raw_max: float | None = None,
|
fn_raw_min: float,
|
||||||
shear_raw_max: float | None = None,
|
fn_raw_max: float,
|
||||||
|
shear_raw_max: float,
|
||||||
) -> None:
|
) -> 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._fn_max = max(0.1, float(fn_max))
|
||||||
self._shear_max = max(0.1, float(shear_max))
|
self._shear_max = max(0.1, float(shear_max))
|
||||||
if fn_raw_max is not None:
|
self._fn_raw_min = float(fn_raw_min)
|
||||||
self._fn_raw_max = max(0.0, float(fn_raw_max))
|
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._shear_raw_max = max(0.0, float(shear_raw_max))
|
||||||
self._apply_axis_mode()
|
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:
|
def set_fixed_newton(self, enabled: bool) -> None:
|
||||||
self._fixed_newton = bool(enabled)
|
self._fixed_newton = bool(enabled)
|
||||||
self._apply_axis_mode()
|
self._apply_axis_mode()
|
||||||
|
|
||||||
def reset(self) -> None:
|
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.history.reset()
|
||||||
self._clear_curves()
|
self._clear_curves()
|
||||||
self._last_values = (0.0, 0.0, 0.0)
|
self._last_values = (0.0, 0.0, 0.0)
|
||||||
self._display_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._available = (True, True, True)
|
||||||
self.presentation.reset_session(unit=self._unit)
|
|
||||||
self._unit = "raw"
|
self._unit = "raw"
|
||||||
|
self.presentation.unit = "raw"
|
||||||
self._mode = "follow"
|
self._mode = "follow"
|
||||||
self._window = (0.0, 0.0)
|
self._window = (0.0, 0.0)
|
||||||
self._cursor_t = 0.0
|
self._cursor_t = 0.0
|
||||||
@@ -519,20 +616,29 @@ class ForcePlotsPanel(QFrame):
|
|||||||
unit: str,
|
unit: str,
|
||||||
enabled: bool | None = None,
|
enabled: bool | None = 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")
|
self._sensor_type = str(sensor_type or "box")
|
||||||
use = (
|
if enabled is not None:
|
||||||
bool(enabled)
|
self._presentation_enabled = bool(enabled)
|
||||||
if enabled is not None
|
self.presentation.enabled = bool(self._presentation_enabled)
|
||||||
else (self._sensor_type == "finger" and unit != "N")
|
|
||||||
)
|
|
||||||
self.presentation.enabled = use
|
|
||||||
self.presentation.reset_session(unit=unit)
|
self.presentation.reset_session(unit=unit)
|
||||||
|
|
||||||
def request_presentation_reestimate(self) -> None:
|
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.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()
|
self._render_value_cards()
|
||||||
|
|
||||||
def observe_for_baseline(
|
def observe_for_baseline(
|
||||||
@@ -543,8 +649,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
valid: bool = True,
|
valid: bool = True,
|
||||||
flow_p95: float | None = None,
|
flow_p95: float | None = None,
|
||||||
contact_found: bool = False,
|
contact_found: bool = False,
|
||||||
|
unit: str | None = 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:
|
if not self.presentation.enabled:
|
||||||
return
|
return
|
||||||
self.presentation.observe(
|
self.presentation.observe(
|
||||||
@@ -553,6 +664,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
valid=valid,
|
valid=valid,
|
||||||
flow_p95=flow_p95,
|
flow_p95=flow_p95,
|
||||||
contact_found=contact_found,
|
contact_found=contact_found,
|
||||||
|
unit=unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
def push(
|
def push(
|
||||||
@@ -596,6 +708,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
valid=bool(forces_valid),
|
valid=bool(forces_valid),
|
||||||
flow_p95=flow_p95,
|
flow_p95=flow_p95,
|
||||||
contact_found=contact_found,
|
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(
|
displayed = self.presentation.accept(
|
||||||
self._last_values, unit=unit, available=self._available
|
self._last_values, unit=unit, available=self._available
|
||||||
@@ -617,8 +732,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
self._syncing = True
|
self._syncing = True
|
||||||
try:
|
try:
|
||||||
|
values = self._curve_values(state.values)
|
||||||
for i, curve in enumerate(self.curves):
|
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)
|
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||||
finally:
|
finally:
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
@@ -648,7 +764,10 @@ class ForcePlotsPanel(QFrame):
|
|||||||
return
|
return
|
||||||
self._syncing = True
|
self._syncing = True
|
||||||
try:
|
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):
|
for i, curve in enumerate(self.curves):
|
||||||
curve.setData(state.times, values[:, i])
|
curve.setData(state.times, values[:, i])
|
||||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||||
@@ -661,6 +780,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
else:
|
else:
|
||||||
self.plots[i].getAxis("left").setTicks(None)
|
self.plots[i].getAxis("left").setTicks(None)
|
||||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||||
|
self._sync_zero_lines()
|
||||||
finally:
|
finally:
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
# The buffer already carries session-relative time, so the cursor is the
|
# The buffer already carries session-relative time, so the cursor is the
|
||||||
@@ -686,6 +806,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
if unit == self._unit:
|
if unit == self._unit:
|
||||||
return
|
return
|
||||||
self._unit = unit
|
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
|
# ``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.
|
# is in the same session-relative base, so the X axis must not restart.
|
||||||
self.history.clear()
|
self.history.clear()
|
||||||
@@ -696,6 +819,17 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.refresh_from_store()
|
self.refresh_from_store()
|
||||||
else:
|
else:
|
||||||
self._reload_from_store()
|
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._render_value_cards()
|
||||||
self._apply_axis_mode()
|
self._apply_axis_mode()
|
||||||
# Re-read the point under the cursor in the new unit: while browsing, or
|
# Re-read the point under the cursor in the new unit: while browsing, or
|
||||||
@@ -735,6 +869,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
scale.
|
scale.
|
||||||
"""
|
"""
|
||||||
self._unit = unit
|
self._unit = unit
|
||||||
|
self.presentation.unit = unit
|
||||||
self.history.clear()
|
self.history.clear()
|
||||||
self._clear_curves()
|
self._clear_curves()
|
||||||
self._overview_dirty = True
|
self._overview_dirty = True
|
||||||
@@ -826,9 +961,14 @@ class ForcePlotsPanel(QFrame):
|
|||||||
for curve in self.curves:
|
for curve in self.curves:
|
||||||
curve.setData([], [])
|
curve.setData([], [])
|
||||||
else:
|
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):
|
for index, curve in enumerate(self.curves):
|
||||||
times, values = window.plottable_axis(index)
|
column = mask[:, index]
|
||||||
curve.setData(times, values)
|
curve.setData(window.times[column], presented[column, index])
|
||||||
self._availability_from_window(window)
|
self._availability_from_window(window)
|
||||||
for plot in self.plots:
|
for plot in self.plots:
|
||||||
plot.setXRange(t0, t1, padding=0.0)
|
plot.setXRange(t0, t1, padding=0.0)
|
||||||
@@ -913,7 +1053,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._window = (x0, x1)
|
self._window = (x0, x1)
|
||||||
self.refresh_from_store()
|
self.refresh_from_store()
|
||||||
|
|
||||||
def _guard(self) -> "_Sync":
|
def _guard(self) -> _Sync:
|
||||||
"""Context manager marking range changes as programmatic."""
|
"""Context manager marking range changes as programmatic."""
|
||||||
return _Sync(self)
|
return _Sync(self)
|
||||||
|
|
||||||
@@ -962,6 +1102,14 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._available = tuple(
|
self._available = tuple(
|
||||||
bool(valid and np.isfinite(value)) for value in values
|
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()
|
self._render_value_cards()
|
||||||
|
|
||||||
def _update_selection_readout(self) -> None:
|
def _update_selection_readout(self) -> None:
|
||||||
@@ -1056,6 +1204,10 @@ class ForcePlotsPanel(QFrame):
|
|||||||
buckets=self._overview_points(), unit=self._unit
|
buckets=self._overview_points(), unit=self._unit
|
||||||
)
|
)
|
||||||
values = np.asarray(window.values, dtype=float)
|
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]
|
column = values[:, 0]
|
||||||
finite = np.isfinite(column)
|
finite = np.isfinite(column)
|
||||||
self.overview_curve.setData(window.times[finite], column[finite])
|
self.overview_curve.setData(window.times[finite], column[finite])
|
||||||
@@ -1164,8 +1316,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
state = self.history.state()
|
state = self.history.state()
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
|
values = self._curve_values(state.values)
|
||||||
for i, curve in enumerate(self.curves):
|
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)
|
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||||
|
|
||||||
def _availability_from_window(self, window) -> None:
|
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
|
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]:
|
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":
|
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:
|
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:
|
if channel_index == 0:
|
||||||
return 0.0, fn_max
|
return _ordered_range(fn_min, fn_max)
|
||||||
return -shear_max, shear_max
|
return -shear_max, shear_max
|
||||||
|
|
||||||
def _preview_fixed_y(self) -> bool:
|
def _preview_fixed_y(self) -> bool:
|
||||||
@@ -1223,6 +1384,49 @@ class ForcePlotsPanel(QFrame):
|
|||||||
if self._mode == "follow":
|
if self._mode == "follow":
|
||||||
for plot in self.plots:
|
for plot in self.plots:
|
||||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
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:
|
def _clear_curves(self) -> None:
|
||||||
for curve in self.curves:
|
for curve in self.curves:
|
||||||
@@ -1252,7 +1456,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
peak = float(selecting.peaks[index])
|
peak = float(selecting.peaks[index])
|
||||||
available = bool(np.isfinite(peak))
|
available = bool(np.isfinite(peak))
|
||||||
value = 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]
|
value = self._display_values[index]
|
||||||
if not available:
|
if not available:
|
||||||
# An axis with no curve in this unit: a dash, never a raw count
|
# An axis with no curve in this unit: a dash, never a raw count
|
||||||
@@ -1273,7 +1483,6 @@ class ForcePlotsPanel(QFrame):
|
|||||||
zero_zone = (
|
zero_zone = (
|
||||||
selecting is None
|
selecting is None
|
||||||
and self.presentation.enabled
|
and self.presentation.enabled
|
||||||
and self.presentation.ready
|
|
||||||
and abs(float(value)) == 0.0
|
and abs(float(value)) == 0.0
|
||||||
and index < len(self._display_suppressed)
|
and index < len(self._display_suppressed)
|
||||||
and self._display_suppressed[index]
|
and self._display_suppressed[index]
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
from PySide6.QtCore import QRectF, Qt
|
from PySide6.QtCore import QRectF, Qt
|
||||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
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 of the icons this module can paint.
|
||||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
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.QtCore import QSize, Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -27,13 +33,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
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 (
|
from orisys_studio.scene3d.asset_source import (
|
||||||
SceneAssetLoader,
|
SceneAssetLoader,
|
||||||
SceneSource,
|
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
|
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||||
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
||||||
|
|
||||||
from orisys_studio.scene3d.view import Scene3DView
|
from orisys_studio.scene3d.view import Scene3DView
|
||||||
|
|
||||||
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
|
#: 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)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
self, parent: QWidget | None = None, *, theme: str = "dark"
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("contentPanel")
|
self.setObjectName("contentPanel")
|
||||||
self._theme = str(theme)
|
self._theme = str(theme)
|
||||||
self._view: Optional["Scene3DView"] = None
|
self._view: Scene3DView | None = None
|
||||||
self._loader: Optional[SceneAssetLoader] = None
|
self._loader: SceneAssetLoader | None = None
|
||||||
self._source: Optional[SceneSource] = None
|
self._source: SceneSource | None = None
|
||||||
self._sensor_type = ""
|
self._sensor_type = ""
|
||||||
self._expected: Optional[ExpectedGeometry] = None
|
self._expected: ExpectedGeometry | None = None
|
||||||
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None
|
self._expected_provider: Callable[[], DeviceGeometry] | None = None
|
||||||
self._source_kind = "live"
|
self._source_kind = "live"
|
||||||
self._enabled = False
|
self._enabled = False
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
@@ -103,14 +103,41 @@ class Mesh3dPanel(QFrame):
|
|||||||
self._keys = None
|
self._keys = None
|
||||||
#: The package whose scene is in the view, so switching sensor away and back
|
#: 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).
|
#: does not reload it (and does not pretend to be loading it either).
|
||||||
self._loaded_path: Optional[Path] = None
|
self._loaded_path: Path | None = None
|
||||||
self._reported: Optional[Diagnostic] = None
|
self._reported: Diagnostic | None = None
|
||||||
self._pending_settings = None
|
self._pending_settings = None
|
||||||
self._message_kind = _UNAVAILABLE
|
self._message_kind = _UNAVAILABLE
|
||||||
self._message_args: dict[str, str] = {"reason": ""}
|
self._message_args: dict[str, str] = {"reason": ""}
|
||||||
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
||||||
#: until the view exists (see ``set_diagnostics_visible``).
|
#: until the view exists (see ``set_diagnostics_visible``).
|
||||||
self._show_diagnostics = False
|
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 = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -189,9 +216,26 @@ class Mesh3dPanel(QFrame):
|
|||||||
panel.set_diagnostics_visible(self._show_diagnostics)
|
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
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._theme = str(theme)
|
||||||
self._apply_icons()
|
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:
|
def _apply_icons(self) -> None:
|
||||||
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||||
@@ -205,8 +249,8 @@ class Mesh3dPanel(QFrame):
|
|||||||
self,
|
self,
|
||||||
sensor_type: str,
|
sensor_type: str,
|
||||||
*,
|
*,
|
||||||
expected: Optional[ExpectedGeometry] = None,
|
expected: ExpectedGeometry | None = None,
|
||||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||||
package_override: str | Path | None = None,
|
package_override: str | Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Tell the pane which sensor it is looking at. Cheap; no IO.
|
"""Tell the pane which sensor it is looking at. Cheap; no IO.
|
||||||
@@ -256,7 +300,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
return
|
return
|
||||||
self._show_message(_LOADING, name=self._source.path.name)
|
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.
|
"""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
|
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)
|
self._stack.addWidget(view)
|
||||||
view.diagnosticRaised.connect(self._on_diagnostic)
|
view.diagnosticRaised.connect(self._on_diagnostic)
|
||||||
view.enabledChanged.connect(self._on_view_enabled)
|
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:
|
if self._pending_settings is not None:
|
||||||
view.restore_settings(self._pending_settings)
|
view.restore_settings(self._pending_settings)
|
||||||
# The kind was decided before the view existed (the window sets it when the
|
# 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:
|
if not view.available:
|
||||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
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."""
|
"""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":
|
if self._view is not None or self._sensor_type != "finger":
|
||||||
return self._view
|
return self._view
|
||||||
@@ -294,7 +348,6 @@ class Mesh3dPanel(QFrame):
|
|||||||
if self._loaded or self._source is None or self._view is None:
|
if self._loaded or self._source is None or self._view is None:
|
||||||
return
|
return
|
||||||
self._loaded = True
|
self._loaded = True
|
||||||
view = self._view
|
|
||||||
if self._loader is None:
|
if self._loader is None:
|
||||||
self._loader = SceneAssetLoader(self)
|
self._loader = SceneAssetLoader(self)
|
||||||
self._loader.loaded.connect(self._on_loaded)
|
self._loader.loaded.connect(self._on_loaded)
|
||||||
@@ -418,11 +471,11 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ inputs
|
# ------------------------------------------------------------------ inputs
|
||||||
@property
|
@property
|
||||||
def view(self) -> Optional["Scene3DView"]:
|
def view(self) -> Scene3DView | None:
|
||||||
return self._view
|
return self._view
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
def last_diagnostic(self) -> Diagnostic | None:
|
||||||
return self._reported
|
return self._reported
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -441,7 +494,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
"""Whether a 3D picture is possible here at all (GL and an asset)."""
|
"""Whether a 3D picture is possible here at all (GL and an asset)."""
|
||||||
return self._view is not None and bool(self._view.available)
|
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
|
return None if self._source is None else self._source.path
|
||||||
|
|
||||||
def set_source_kind(self, kind: str) -> None:
|
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
|
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
|
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
|
``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``).
|
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 __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 (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -24,21 +31,24 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
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
|
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_FN_MAX = 20.0
|
||||||
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
||||||
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
|
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
|
||||||
# stays sub-pixel while a full contact still fits (see prefs store).
|
# 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_FN_RAW_MAX = 1000000.0
|
||||||
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.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):
|
class PlotSettingsDialog(QDialog):
|
||||||
@@ -47,10 +57,14 @@ class PlotSettingsDialog(QDialog):
|
|||||||
parent=None,
|
parent=None,
|
||||||
*,
|
*,
|
||||||
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
||||||
|
fn_min: float = DEFAULT_PLOT_FN_MIN,
|
||||||
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
||||||
shear_max: float = DEFAULT_PLOT_SHEAR_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,
|
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
|
||||||
shear_raw_max: float = DEFAULT_PLOT_SHEAR_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",
|
units: str = "newton",
|
||||||
filters: FilterSettings | None = None,
|
filters: FilterSettings | None = None,
|
||||||
filters_locked: bool = False,
|
filters_locked: bool = False,
|
||||||
@@ -58,7 +72,7 @@ class PlotSettingsDialog(QDialog):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.resize(420, 520)
|
self.resize(460, 640)
|
||||||
self._sample_rate_hz = float(sample_rate_hz)
|
self._sample_rate_hz = float(sample_rate_hz)
|
||||||
#: True while the widgets are being filled in from a preset, so the
|
#: True while the widgets are being filled in from a preset, so the
|
||||||
#: "edited -> custom" handler does not fire on our own writes.
|
#: "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.setSuffix(" s")
|
||||||
self.window_spin.setValue(float(window_sec))
|
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_label = QLabel()
|
||||||
self.fn_max_spin = QDoubleSpinBox()
|
self.fn_max_spin = QDoubleSpinBox()
|
||||||
self.fn_max_spin.setRange(1.0, 100.0)
|
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.setSuffix(" N")
|
||||||
self.shear_max_spin.setValue(float(shear_max))
|
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".
|
# Raw limits: 0 means "auto range on the data".
|
||||||
self.fn_raw_max_label = QLabel()
|
self.fn_raw_max_label = QLabel()
|
||||||
self.fn_raw_max_spin = QDoubleSpinBox()
|
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.setDecimals(0)
|
||||||
self.shear_raw_max_spin.setValue(float(shear_raw_max))
|
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.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.fn_max_label, self.fn_max_spin)
|
||||||
form.addRow(self.shear_max_label, self.shear_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.fn_raw_max_label, self.fn_raw_max_spin)
|
||||||
form.addRow(self.shear_raw_max_label, self.shear_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)
|
root.addLayout(form)
|
||||||
|
|
||||||
newton = str(units or "newton").lower().startswith("n")
|
newton = str(units or "newton").lower().startswith("n")
|
||||||
raw_widgets = (
|
raw_widgets = (
|
||||||
|
self.fn_raw_min_spin,
|
||||||
self.fn_raw_max_spin,
|
self.fn_raw_max_spin,
|
||||||
self.shear_raw_max_spin,
|
self.shear_raw_max_spin,
|
||||||
|
self.deadband_raw_spin,
|
||||||
|
self.fn_raw_min_label,
|
||||||
self.fn_raw_max_label,
|
self.fn_raw_max_label,
|
||||||
self.shear_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,
|
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
|
||||||
self.shear_max_label):
|
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
|
||||||
|
self.shear_max_label, self.deadband_n_label):
|
||||||
widget.setEnabled(newton)
|
widget.setEnabled(newton)
|
||||||
for widget in raw_widgets:
|
for widget in raw_widgets:
|
||||||
widget.setEnabled(not newton)
|
widget.setEnabled(not newton)
|
||||||
@@ -330,6 +386,7 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.filter_note.setText("")
|
self.filter_note.setText("")
|
||||||
|
|
||||||
def values(self) -> tuple[float, float, float, float, float]:
|
def values(self) -> tuple[float, float, float, float, float]:
|
||||||
|
"""The original five-values shape, kept for the existing caller."""
|
||||||
return (
|
return (
|
||||||
float(self.window_spin.value()),
|
float(self.window_spin.value()),
|
||||||
float(self.fn_max_spin.value()),
|
float(self.fn_max_spin.value()),
|
||||||
@@ -338,12 +395,41 @@ class PlotSettingsDialog(QDialog):
|
|||||||
float(self.shear_raw_max_spin.value()),
|
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:
|
def _reset_defaults(self) -> None:
|
||||||
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
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.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
|
||||||
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_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.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
||||||
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_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
|
# "Reset" means the shipped defaults, and those filter nothing — the same
|
||||||
# answer the dialog gives for a preference file that says nothing.
|
# answer the dialog gives for a preference file that says nothing.
|
||||||
self.flow_ema_spin.setValue(1.0)
|
self.flow_ema_spin.setValue(1.0)
|
||||||
@@ -358,14 +444,22 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.setWindowTitle(t("dialog.plot_settings_title"))
|
self.setWindowTitle(t("dialog.plot_settings_title"))
|
||||||
self.window_label.setText(t("dialog.plot_window"))
|
self.window_label.setText(t("dialog.plot_window"))
|
||||||
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
|
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_label.setText(t("dialog.plot_fn_max"))
|
||||||
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
||||||
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
||||||
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
|
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_label.setText(t("dialog.plot_fn_raw_max"))
|
||||||
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
|
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_label.setText(t("dialog.plot_shear_raw_max"))
|
||||||
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
|
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.btn_reset.setText(t("dialog.reset_defaults"))
|
||||||
self.filters_group.setTitle(t("dialog.plot_filters"))
|
self.filters_group.setTitle(t("dialog.plot_filters"))
|
||||||
self.filter_preset_label.setText(t("dialog.filter_preset"))
|
self.filter_preset_label.setText(t("dialog.filter_preset"))
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ timer without touching the writer thread.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtWidgets import QLabel
|
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.theme import color
|
from orisys_app_shared.theme import color
|
||||||
|
from PySide6.QtWidgets import QLabel
|
||||||
|
|
||||||
#: The recording channel is idle, never started in this session.
|
#: The recording channel is idle, never started in this session.
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -26,8 +27,6 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
|
|
||||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
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 __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.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -18,9 +20,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
|
||||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
|
||||||
|
|
||||||
CONTROL_HEIGHT = 30
|
CONTROL_HEIGHT = 30
|
||||||
#: Both combos stay narrow: this row shares one line with the session state.
|
#: Both combos stay narrow: this row shares one line with the session state.
|
||||||
TYPE_WIDTH = 76
|
TYPE_WIDTH = 76
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"""Top of the studio shell: status rail and a collapsible settings area.
|
"""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,
|
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
|
calibration, data validity and the record channel — plus the actions that must
|
||||||
must never be more than one click away (record, run/stop, reset).
|
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,
|
Everything that is *configuration* rather than state — source, camera number,
|
||||||
video file, units and the sensor/config pickers — lives in the settings area,
|
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 __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.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -26,9 +35,6 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
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.fit_text import ShrinkableLabel
|
||||||
from orisys_studio.ui.record_status import RecordStatusChip
|
from orisys_studio.ui.record_status import RecordStatusChip
|
||||||
|
|
||||||
@@ -81,7 +87,10 @@ class ToolbarWidget(QWidget):
|
|||||||
replay_video_clicked = Signal()
|
replay_video_clicked = Signal()
|
||||||
#: Replay view: re-run the pipeline over that video (the only processed picture).
|
#: Replay view: re-run the pipeline over that video (the only processed picture).
|
||||||
recompute_clicked = Signal()
|
recompute_clicked = Signal()
|
||||||
|
#: The transport: True when a live session should acquire, or a replay play.
|
||||||
run_toggled = Signal(bool)
|
run_toggled = Signal(bool)
|
||||||
|
#: Stop: end the live session (releasing the device), or leave the replay.
|
||||||
|
stop_clicked = Signal()
|
||||||
reset_clicked = Signal()
|
reset_clicked = Signal()
|
||||||
units_changed = Signal(str)
|
units_changed = Signal(str)
|
||||||
record_toggled = Signal(bool)
|
record_toggled = Signal(bool)
|
||||||
@@ -109,7 +118,6 @@ class ToolbarWidget(QWidget):
|
|||||||
self._video_path = ""
|
self._video_path = ""
|
||||||
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
||||||
self._run_role = "acquire"
|
self._run_role = "acquire"
|
||||||
self._reset_role = "reset"
|
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(2, 2, 2, 2)
|
outer.setContentsMargins(2, 2, 2, 2)
|
||||||
@@ -242,6 +250,16 @@ class ToolbarWidget(QWidget):
|
|||||||
self.btn_run.setCheckable(True)
|
self.btn_run.setCheckable(True)
|
||||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
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 = QPushButton()
|
||||||
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
||||||
self.btn_reset.setEnabled(False)
|
self.btn_reset.setEnabled(False)
|
||||||
@@ -266,6 +284,7 @@ class ToolbarWidget(QWidget):
|
|||||||
rail.addWidget(self.record_chip)
|
rail.addWidget(self.record_chip)
|
||||||
rail.addWidget(_rail(self.btn_record))
|
rail.addWidget(_rail(self.btn_record))
|
||||||
rail.addWidget(_rail(self.btn_run))
|
rail.addWidget(_rail(self.btn_run))
|
||||||
|
rail.addWidget(_rail(self.btn_stop))
|
||||||
rail.addWidget(_rail(self.btn_reset))
|
rail.addWidget(_rail(self.btn_reset))
|
||||||
rail.addWidget(_rail(self.btn_settings))
|
rail.addWidget(_rail(self.btn_settings))
|
||||||
return rail
|
return rail
|
||||||
@@ -386,7 +405,11 @@ class ToolbarWidget(QWidget):
|
|||||||
self._apply_source_mode_ui()
|
self._apply_source_mode_ui()
|
||||||
|
|
||||||
def set_run_role(self, role: str) -> None:
|
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"
|
wanted = "playback" if str(role) == "playback" else "acquire"
|
||||||
if wanted == self._run_role:
|
if wanted == self._run_role:
|
||||||
return
|
return
|
||||||
@@ -397,38 +420,11 @@ class ToolbarWidget(QWidget):
|
|||||||
self.btn_run.setChecked(False)
|
self.btn_run.setChecked(False)
|
||||||
self.btn_run.blockSignals(False)
|
self.btn_run.blockSignals(False)
|
||||||
self._refresh_run_label()
|
self._refresh_run_label()
|
||||||
|
self._refresh_stop_label()
|
||||||
|
|
||||||
def run_role(self) -> str:
|
def run_role(self) -> str:
|
||||||
return self._run_role
|
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:
|
def source_view(self) -> str:
|
||||||
return self._source_view
|
return self._source_view
|
||||||
|
|
||||||
@@ -467,6 +463,12 @@ class ToolbarWidget(QWidget):
|
|||||||
self.refresh_labels()
|
self.refresh_labels()
|
||||||
|
|
||||||
def set_run_checked(self, checked: bool) -> None:
|
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.blockSignals(True)
|
||||||
self.btn_run.setChecked(bool(checked))
|
self.btn_run.setChecked(bool(checked))
|
||||||
self.btn_run.blockSignals(False)
|
self.btn_run.blockSignals(False)
|
||||||
@@ -474,6 +476,14 @@ class ToolbarWidget(QWidget):
|
|||||||
if not checked:
|
if not checked:
|
||||||
self.btn_reset.setEnabled(False)
|
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:
|
def set_reset_enabled(self, enabled: bool) -> None:
|
||||||
self.btn_reset.setEnabled(bool(enabled))
|
self.btn_reset.setEnabled(bool(enabled))
|
||||||
|
|
||||||
@@ -517,7 +527,13 @@ class ToolbarWidget(QWidget):
|
|||||||
self._apply_run_tooltip()
|
self._apply_run_tooltip()
|
||||||
|
|
||||||
def set_run_phase(self, phase: str) -> None:
|
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._run_phase = str(phase or "idle")
|
||||||
self.refresh_labels()
|
self.refresh_labels()
|
||||||
|
|
||||||
@@ -531,6 +547,7 @@ class ToolbarWidget(QWidget):
|
|||||||
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
|
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
|
||||||
self._refresh_preview_label()
|
self._refresh_preview_label()
|
||||||
self._refresh_run_label()
|
self._refresh_run_label()
|
||||||
|
self._refresh_stop_label()
|
||||||
|
|
||||||
# ------------------------------------------------------------- internals
|
# ------------------------------------------------------------- internals
|
||||||
def _populate_units_combo(self) -> None:
|
def _populate_units_combo(self) -> None:
|
||||||
@@ -636,9 +653,15 @@ class ToolbarWidget(QWidget):
|
|||||||
self.preview_toggled.emit(bool(checked))
|
self.preview_toggled.emit(bool(checked))
|
||||||
|
|
||||||
def _on_run_toggled(self, checked: bool) -> None:
|
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()
|
self._refresh_run_label()
|
||||||
if not checked:
|
|
||||||
self.btn_reset.setEnabled(False)
|
|
||||||
self.run_toggled.emit(bool(checked))
|
self.run_toggled.emit(bool(checked))
|
||||||
|
|
||||||
def _on_units_index_changed(self, _index: int) -> None:
|
def _on_units_index_changed(self, _index: int) -> None:
|
||||||
@@ -650,28 +673,52 @@ class ToolbarWidget(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.btn_preview.setText(t("toolbar.preview"))
|
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:
|
def _refresh_run_label(self) -> None:
|
||||||
if self._run_role == "playback":
|
if self._run_role == "playback":
|
||||||
# The primary action of the replay view is playing the recording —
|
# 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(
|
self.btn_run.setText(
|
||||||
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
|
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
|
||||||
)
|
)
|
||||||
self._refresh_emphasis()
|
elif self.btn_run.isChecked():
|
||||||
self._apply_run_tooltip()
|
# checked = acquiring and not paused, so the action is Pause.
|
||||||
return
|
self.btn_run.setText(t("toolbar.pause"))
|
||||||
if self.btn_run.isChecked():
|
elif self._session_running():
|
||||||
# While connecting, cancel is the action the user actually needs —
|
# unchecked *with* a live session is the paused state: the device is
|
||||||
# a device open can hang, and this is the way out of it.
|
# open and the computation is stopped, so the action is Resume.
|
||||||
if self._run_phase == "connecting":
|
self.btn_run.setText(t("toolbar.resume_label"))
|
||||||
self.btn_run.setText(t("toolbar.cancel_label"))
|
|
||||||
else:
|
|
||||||
self.btn_run.setText(t("toolbar.stop_label"))
|
|
||||||
else:
|
else:
|
||||||
self.btn_run.setText(t("toolbar.run_label"))
|
self.btn_run.setText(t("toolbar.run_label"))
|
||||||
self._refresh_emphasis()
|
self._refresh_emphasis()
|
||||||
self._apply_run_tooltip()
|
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:
|
def _refresh_emphasis(self) -> None:
|
||||||
"""Exactly one *filled* button at a time.
|
"""Exactly one *filled* button at a time.
|
||||||
|
|
||||||
@@ -681,31 +728,36 @@ class ToolbarWidget(QWidget):
|
|||||||
|
|
||||||
* not recording yet → the record action is the filled primary;
|
* not recording yet → the record action is the filled primary;
|
||||||
* recording → stopping the recording is the filled danger;
|
* 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()
|
recording = self.btn_record.isChecked()
|
||||||
|
playing = self.btn_run.isChecked()
|
||||||
|
if self._run_role == "playback":
|
||||||
|
# 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:
|
||||||
# The phase counts as running even if the button has not been repainted
|
# 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.
|
# yet: the rail must describe the session, not the widget's lag.
|
||||||
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
|
connecting = self._run_phase == "connecting"
|
||||||
if self._run_role == "playback":
|
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
|
||||||
# The transport is the replay view's own action, not a session stop:
|
if recording:
|
||||||
# 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_record.setObjectName("dangerButton")
|
||||||
self.btn_run.setObjectName("dangerOutline")
|
self.btn_run.setObjectName("ghostButton")
|
||||||
else:
|
else:
|
||||||
self.btn_record.setObjectName("primaryButton")
|
self.btn_record.setObjectName("primaryButton")
|
||||||
if running and self._run_phase != "connecting":
|
self.btn_run.setObjectName(
|
||||||
self.btn_run.setObjectName("dangerOutline")
|
"ghostButton" if playing or self._session_running() else "primaryButton"
|
||||||
elif running:
|
)
|
||||||
self.btn_run.setObjectName("dangerButton")
|
for button in (self.btn_record, self.btn_run, self.btn_stop):
|
||||||
else:
|
|
||||||
self.btn_run.setObjectName("primaryButton")
|
|
||||||
for button in (self.btn_record, self.btn_run):
|
|
||||||
button.style().unpolish(button)
|
button.style().unpolish(button)
|
||||||
button.style().polish(button)
|
button.style().polish(button)
|
||||||
|
|
||||||
@@ -723,10 +775,12 @@ class ToolbarWidget(QWidget):
|
|||||||
self.btn_run.setToolTip(t(key))
|
self.btn_run.setToolTip(t(key))
|
||||||
return
|
return
|
||||||
if self.btn_run.isChecked():
|
if self.btn_run.isChecked():
|
||||||
if self._run_phase == "connecting":
|
# The tip is where the pause semantics are stated: computing stops, the
|
||||||
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
|
# picture holds, the device stays open — and a recording that spans the
|
||||||
else:
|
# pause has a gap.
|
||||||
self.btn_run.setToolTip(t("toolbar.stop"))
|
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:
|
elif not self._run_available and self._run_tooltip_reason:
|
||||||
self.btn_run.setToolTip(self._run_tooltip_reason)
|
self.btn_run.setToolTip(self._run_tooltip_reason)
|
||||||
elif not self._run_available:
|
elif not self._run_available:
|
||||||
@@ -737,6 +791,13 @@ class ToolbarWidget(QWidget):
|
|||||||
tip = f"{tip} — {self._run_tooltip_reason}"
|
tip = f"{tip} — {self._run_tooltip_reason}"
|
||||||
self.btn_run.setToolTip(tip)
|
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:
|
def retranslate(self) -> None:
|
||||||
self.cam_id_label.setText(t("toolbar.cam_id"))
|
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from PySide6.QtCore import QObject
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class ViewLayoutController(QObject):
|
|||||||
default_splitter_sizes: tuple,
|
default_splitter_sizes: tuple,
|
||||||
min_panel_widths: dict,
|
min_panel_widths: dict,
|
||||||
default_3d_pane_width: int,
|
default_3d_pane_width: int,
|
||||||
parent: Optional[QObject] = None,
|
parent: QObject | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._host = host
|
self._host = host
|
||||||
@@ -103,7 +103,7 @@ class ViewLayoutController(QObject):
|
|||||||
self._view_preset = preset if preset in self._presets else "custom"
|
self._view_preset = preset if preset in self._presets else "custom"
|
||||||
self._show_cal = bool(host.prefs.get("show_cal_panel", False))
|
self._show_cal = bool(host.prefs.get("show_cal_panel", False))
|
||||||
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
|
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
|
||||||
self._actions: Optional[LayoutActions] = None
|
self._actions: LayoutActions | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------ wiring
|
# ------------------------------------------------------------------ wiring
|
||||||
def bind_actions(
|
def bind_actions(
|
||||||
@@ -244,7 +244,7 @@ class ViewLayoutController(QObject):
|
|||||||
self._set_check(self._actions.settings, self._toolbar.settings_expanded())
|
self._set_check(self._actions.settings, self._toolbar.settings_expanded())
|
||||||
|
|
||||||
@staticmethod
|
@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."""
|
"""Set a checkable entry without letting it drive its own handler."""
|
||||||
if action is None:
|
if action is None:
|
||||||
return
|
return
|
||||||
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
|
|||||||
self._replay.pause_playback()
|
self._replay.pause_playback()
|
||||||
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
|
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
|
||||||
# The rail's primary button is this view's main action: acquire while
|
# 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
|
# measuring, play back while replaying. The button beside it is the same in
|
||||||
# own reset — or, in the replay view, an unconditional stop.
|
# 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_run_role("playback" if preset == "replay" else "acquire")
|
||||||
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
|
|
||||||
self.apply_panel_visibility()
|
self.apply_panel_visibility()
|
||||||
if apply_sizes and preset in self._splitter_sizes:
|
if apply_sizes and preset in self._splitter_sizes:
|
||||||
self._splitter.setSizes(self.splitter_sizes_for(preset))
|
self._splitter.setSizes(self.splitter_sizes_for(preset))
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ def qt_app():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def make_window(qt_app, tmp_path, monkeypatch):
|
def make_window(qt_app, tmp_path, monkeypatch):
|
||||||
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
|
"""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 paths as paths_mod
|
||||||
|
from orisys_app_shared import prefs as prefs_pkg
|
||||||
from orisys_studio.app_window import StudioMainWindow
|
from orisys_studio.app_window import StudioMainWindow
|
||||||
|
|
||||||
prefs_file = tmp_path / "preference.json"
|
prefs_file = tmp_path / "preference.json"
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
|
|||||||
|
|
||||||
PLOT_DEFAULTS = {
|
PLOT_DEFAULTS = {
|
||||||
"window_sec": 10.0,
|
"window_sec": 10.0,
|
||||||
|
"fn_min": 0.0,
|
||||||
"fn_max": 5.0,
|
"fn_max": 5.0,
|
||||||
"shear_max": 5.0,
|
"shear_max": 5.0,
|
||||||
|
"fn_raw_min": 0.0,
|
||||||
"fn_raw_max": 1e5,
|
"fn_raw_max": 1e5,
|
||||||
"shear_raw_max": 1e5,
|
"shear_raw_max": 1e5,
|
||||||
}
|
}
|
||||||
@@ -46,7 +48,10 @@ class _Charts:
|
|||||||
self.fixed_newton: list[bool] = []
|
self.fixed_newton: list[bool] = []
|
||||||
self.units: list[str] = []
|
self.units: list[str] = []
|
||||||
self.window_sec: list[float] = []
|
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:
|
def unit(self) -> str:
|
||||||
return self._unit
|
return self._unit
|
||||||
@@ -61,8 +66,17 @@ class _Charts:
|
|||||||
def set_window_sec(self, value: float) -> None: # noqa: N802
|
def set_window_sec(self, value: float) -> None: # noqa: N802
|
||||||
self.window_sec.append(float(value))
|
self.window_sec.append(float(value))
|
||||||
|
|
||||||
def set_axis_limits(self, *values) -> None: # noqa: N802
|
def set_axis_limits(self, **values) -> None: # noqa: N802
|
||||||
self.limits.append(tuple(float(v) for v in values))
|
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:
|
class _ArrowPanel:
|
||||||
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
|
|||||||
settings = controller.plot_settings()
|
settings = controller.plot_settings()
|
||||||
assert settings["window_sec"] == 3.0
|
assert settings["window_sec"] == 3.0
|
||||||
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
|
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"
|
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():
|
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
|
||||||
controller, parts = _controller(prefs={})
|
controller, parts = _controller(prefs={})
|
||||||
controller.apply_plot_settings(
|
controller.apply_plot_settings(
|
||||||
{
|
{
|
||||||
"window_sec": 20.0,
|
"window_sec": 20.0,
|
||||||
|
"fn_min": -2.0,
|
||||||
"fn_max": 8.0,
|
"fn_max": 8.0,
|
||||||
"shear_max": 9.0,
|
"shear_max": 9.0,
|
||||||
|
"fn_raw_min": -1000.0,
|
||||||
"fn_raw_max": 2e5,
|
"fn_raw_max": 2e5,
|
||||||
"shear_raw_max": 3e5,
|
"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_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_shear_raw_max"] == 3e5
|
||||||
|
assert parts["host"].prefs["plot_deadband_n"] == 0.25
|
||||||
assert parts["charts"].window_sec == [20.0]
|
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
|
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():
|
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)
|
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
|
||||||
assert controller.device_sample_rate() == 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_studio import replay_controller
|
from orisys_studio import replay_controller
|
||||||
from orisys_studio.replay_controller import ReplayController
|
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