migrate pyqt5 to pyside6
This commit is contained in:
@@ -4,14 +4,21 @@ Desktop applications that consume the Orisys SDK.
|
|||||||
|
|
||||||
| App | Status | Qt binding |
|
| App | Status | Qt binding |
|
||||||
|-----|--------|-----------|
|
|-----|--------|-----------|
|
||||||
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | PyQt5 |
|
| `apps/studio` | The customer-facing shell (theme / locale / prefs / logging, acquisition, 3D) | **PySide6** |
|
||||||
| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** |
|
| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** |
|
||||||
| `apps/internal` | Stub, superseded by `apps/autocal` | — |
|
| `apps/internal` | Stub, superseded by `apps/autocal` | — |
|
||||||
|
|
||||||
The two shipped apps use different Qt bindings on purpose. PyQt5's free option
|
Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free option is
|
||||||
is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal
|
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
|
||||||
closed-source tool uses PySide6 and why `shared/` must import neither binding.
|
product would otherwise need a licence purchase. The rule is enforced, not just
|
||||||
Migrating `apps/studio` to PySide6 is a separate, build-validated change.
|
documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
|
||||||
|
import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and
|
||||||
|
`apps/autocal/tests/test_binding_purity.py`.
|
||||||
|
|
||||||
|
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
|
||||||
|
development environment that still has PyQt5 installed can build plots from the wrong
|
||||||
|
binding. `orisys_studio.main` sets `PYQTGRAPH_QT_LIB=PySide6` before anything imports it,
|
||||||
|
and the purity test asserts that importing the app pulls in no other binding.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
|
|||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
||||||
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.)
|
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
|
||||||
|
for the same reason and no longer uses it.)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# with the environment that already has PySide6 (Anaconda py310, for example)
|
# with the environment that already has PySide6 (Anaconda py310, for example)
|
||||||
@@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
|
|||||||
are skipped rather than failed: a broken environment should not look like a
|
are skipped rather than failed: a broken environment should not look like a
|
||||||
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
||||||
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
||||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at
|
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
|
||||||
all, and the controller interface must stay importable headless.
|
other binding, and the controller interface must stay importable headless. The
|
||||||
|
studio app enforces the same rules for itself in
|
||||||
|
`apps/studio/tests/test_binding_purity.py`.
|
||||||
|
|
||||||
## Do not ship this app
|
## Do not ship this app
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,8 @@
|
|||||||
"validation_title": "Checks",
|
"validation_title": "Checks",
|
||||||
"points_title": "Calibration points",
|
"points_title": "Calibration points",
|
||||||
"field_mode": "Surface mode",
|
"field_mode": "Surface mode",
|
||||||
|
"field_name": "Sequence name",
|
||||||
|
"field_note": "Note",
|
||||||
"field_pitch": "Pitch (mm)",
|
"field_pitch": "Pitch (mm)",
|
||||||
"field_margin": "Margin (mm)",
|
"field_margin": "Margin (mm)",
|
||||||
"field_radius": "Radius (mm)",
|
"field_radius": "Radius (mm)",
|
||||||
@@ -221,8 +223,11 @@
|
|||||||
"btn_generate": "Generate points",
|
"btn_generate": "Generate points",
|
||||||
"btn_simulate": "Simulate",
|
"btn_simulate": "Simulate",
|
||||||
"btn_dry_run": "Dry run (no contact)",
|
"btn_dry_run": "Dry run (no contact)",
|
||||||
"btn_save": "Save sequence…",
|
"btn_export": "Export sequence…",
|
||||||
"btn_load": "Load sequence…",
|
"btn_import": "Import sequence…",
|
||||||
|
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
|
||||||
|
"export_failed": "Export failed: {error}",
|
||||||
|
"import_failed": "Import failed: {error}",
|
||||||
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
|
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
|
||||||
"col_index": "#",
|
"col_index": "#",
|
||||||
"col_x": "X (mm)",
|
"col_x": "X (mm)",
|
||||||
@@ -234,6 +239,16 @@
|
|||||||
"col_actions": "Actions",
|
"col_actions": "Actions",
|
||||||
"not_generated": "—",
|
"not_generated": "—",
|
||||||
"status_pending": "Pending",
|
"status_pending": "Pending",
|
||||||
|
"status_skipped": "Skipped",
|
||||||
|
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
|
||||||
|
"summary_empty": "No points yet: generate or import to see the count and travel span.",
|
||||||
|
"dialog_export": "Export sequence",
|
||||||
|
"dialog_import": "Import sequence",
|
||||||
|
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
|
||||||
|
"exported": "Sequence exported: {name}",
|
||||||
|
"imported": "Sequence imported: {name} ({count} points)",
|
||||||
|
"import_warnings": "Import reported {count} warning(s)",
|
||||||
|
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
|
||||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
|
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
@@ -196,6 +196,8 @@
|
|||||||
"validation_title": "校验",
|
"validation_title": "校验",
|
||||||
"points_title": "标定点列表",
|
"points_title": "标定点列表",
|
||||||
"field_mode": "曲面模式",
|
"field_mode": "曲面模式",
|
||||||
|
"field_name": "序列名称",
|
||||||
|
"field_note": "备注",
|
||||||
"field_pitch": "点距 (mm)",
|
"field_pitch": "点距 (mm)",
|
||||||
"field_margin": "边界余量 (mm)",
|
"field_margin": "边界余量 (mm)",
|
||||||
"field_radius": "曲面半径 (mm)",
|
"field_radius": "曲面半径 (mm)",
|
||||||
@@ -221,8 +223,11 @@
|
|||||||
"btn_generate": "生成标定点",
|
"btn_generate": "生成标定点",
|
||||||
"btn_simulate": "仿真",
|
"btn_simulate": "仿真",
|
||||||
"btn_dry_run": "干跑(不接触)",
|
"btn_dry_run": "干跑(不接触)",
|
||||||
"btn_save": "保存序列…",
|
"btn_export": "导出序列…",
|
||||||
"btn_load": "载入序列…",
|
"btn_import": "导入序列…",
|
||||||
|
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
|
||||||
|
"export_failed": "导出失败: {error}",
|
||||||
|
"import_failed": "导入失败: {error}",
|
||||||
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
|
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
|
||||||
"col_index": "#",
|
"col_index": "#",
|
||||||
"col_x": "X (mm)",
|
"col_x": "X (mm)",
|
||||||
@@ -234,6 +239,16 @@
|
|||||||
"col_actions": "操作",
|
"col_actions": "操作",
|
||||||
"not_generated": "—",
|
"not_generated": "—",
|
||||||
"status_pending": "待测",
|
"status_pending": "待测",
|
||||||
|
"status_skipped": "已跳过",
|
||||||
|
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
|
||||||
|
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
|
||||||
|
"dialog_export": "导出序列",
|
||||||
|
"dialog_import": "导入序列",
|
||||||
|
"filter_sequence": "标定序列 (*.oryseq *.json)",
|
||||||
|
"exported": "已导出序列: {name}",
|
||||||
|
"imported": "已导入序列: {name}({count} 点)",
|
||||||
|
"import_warnings": "导入有 {count} 项提示",
|
||||||
|
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
||||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Calibration sequence format (`.oryseq`)
|
||||||
|
|
||||||
|
A calibration sequence is a small JSON document: the **profile** that generated
|
||||||
|
the points, plus the **points** themselves. It is written by
|
||||||
|
`导出序列…` in the Sequence tab and read by `导入序列…`.
|
||||||
|
|
||||||
|
Why JSON and not a binary container: a sequence is at most a few hundred points
|
||||||
|
of text. It is read by a human, diffed in review, and emailed to whoever runs the
|
||||||
|
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
|
||||||
|
is a different problem — per-point time series are large and will need a sidecar;
|
||||||
|
that decision belongs to workflow P8, not here.)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
The format follows this repository's existing documents rather than inventing a
|
||||||
|
convention. Where it matches `session.json` and `force_calibration.json`, that is
|
||||||
|
deliberate:
|
||||||
|
|
||||||
|
| Convention | Value |
|
||||||
|
|---|---|
|
||||||
|
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
|
||||||
|
| Identity | `"kind": "autocal.sequence"` — checked on import |
|
||||||
|
| Extension | `.oryseq` by convention; import accepts any `.json` |
|
||||||
|
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
|
||||||
|
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
|
||||||
|
|
||||||
|
## The document
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format_version": "1.0",
|
||||||
|
"kind": "autocal.sequence",
|
||||||
|
"created_at": "2026-09-20T10:31:00+00:00",
|
||||||
|
"name": "5 点灵敏度扫描",
|
||||||
|
"note": "",
|
||||||
|
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
|
||||||
|
"device": {
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_ref": "…\\config\\sensors\\box\\sensor.json",
|
||||||
|
"geometry_id": "finger-1.0.0",
|
||||||
|
"probe_tip_set": true
|
||||||
|
},
|
||||||
|
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
|
||||||
|
"profile": {
|
||||||
|
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
|
||||||
|
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
|
||||||
|
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
|
||||||
|
"repeats": 3, "order": "serpentine"
|
||||||
|
},
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
|
||||||
|
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
|
||||||
|
"clearance_mm": 5.0,
|
||||||
|
"enabled": true,
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"count": 12, "enabled": 12,
|
||||||
|
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
|
||||||
|
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
|
||||||
|
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
|
||||||
|
"pitch_mm": 2.0,
|
||||||
|
"estimated_duration_s": 380.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### `frame` — required
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
|
||||||
|
| `length_unit` | `"mm"`; anything else is refused |
|
||||||
|
| `units_per_mm` | the pulse equivalent the points were built with |
|
||||||
|
|
||||||
|
A sequence whose numbers do not say whether they are millimetres or user units is
|
||||||
|
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
|
||||||
|
file from a differently-configured axis is detectable rather than silently
|
||||||
|
believed.
|
||||||
|
|
||||||
|
### `profile` — required
|
||||||
|
|
||||||
|
The twelve fields the Sequence tab edits, and nothing else. A field absent from
|
||||||
|
the file is **kept at its current value and reported**, never silently defaulted
|
||||||
|
to another bench's number.
|
||||||
|
|
||||||
|
### `points` — required, may be empty
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 1-based, stable within the file; used for run progress and resume |
|
||||||
|
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
|
||||||
|
| `sensor_mm` | where that point lands on the sensor; optional but written |
|
||||||
|
| `clearance_mm` | the standoff for this point |
|
||||||
|
| `enabled` | false = designed but deliberately skipped |
|
||||||
|
| `note` | free text |
|
||||||
|
|
||||||
|
Both positions are stored on purpose. The platform target is what the run uses;
|
||||||
|
the sensor position is what makes the point checkable against the surface.
|
||||||
|
Deriving one from the other on every load would silently change what the points
|
||||||
|
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
|
||||||
|
stored transform should be *noticed*, not recomputed from.
|
||||||
|
|
||||||
|
### `summary` — derived, not authoritative
|
||||||
|
|
||||||
|
Present so a human reading the file sees the point count and travel span without
|
||||||
|
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
|
||||||
|
is reported as a warning and the recomputed values win. That is how a hand-edited
|
||||||
|
document announces itself instead of quietly disagreeing with its own contents.
|
||||||
|
|
||||||
|
`estimated_duration_s` assumes a constant traverse rate
|
||||||
|
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
|
||||||
|
figure and is labelled an estimate wherever it is shown.
|
||||||
|
|
||||||
|
### `device` — a reference, not a requirement
|
||||||
|
|
||||||
|
Records which sensor the sequence was designed for. Importing a sequence for
|
||||||
|
another sensor is **allowed and warned about**, because re-fixturing
|
||||||
|
deliberately is a normal thing to do.
|
||||||
|
|
||||||
|
## Import rules
|
||||||
|
|
||||||
|
Every rejection names the specific problem — `invalid file` tells an operator
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
| Condition | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| not JSON | refused, with line and column |
|
||||||
|
| top level not an object | refused |
|
||||||
|
| `kind` missing or different | refused, naming what was found |
|
||||||
|
| `format_version` missing or not a string | refused |
|
||||||
|
| `format_version` newer than this build | refused, naming both versions |
|
||||||
|
| `frame` missing, or `length_unit` not `mm` | refused |
|
||||||
|
| `profile` not an object | refused |
|
||||||
|
| a profile key absent | imported; the current value is kept and the key is reported |
|
||||||
|
| `points` absent | imported as profile-only, and reported |
|
||||||
|
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
|
||||||
|
| `summary` disagreeing with `points` | imported; recomputed, and reported |
|
||||||
|
| current design is unsaved | the operator is asked before it is replaced |
|
||||||
|
|
||||||
|
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
|
||||||
|
`"1.9"`, and a string comparison would make a newer file look older and load it
|
||||||
|
as if it were supported.
|
||||||
|
|
||||||
|
## Compatibility rules
|
||||||
|
|
||||||
|
- **Adding an optional key** does not change `format_version`.
|
||||||
|
- **Adding a required key, or changing a field's meaning or unit**, does.
|
||||||
|
- A reader must refuse a document whose version it does not support rather than
|
||||||
|
interpreting the parts it recognises — a sequence half-applied is worse than
|
||||||
|
one rejected.
|
||||||
|
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
|
||||||
|
version and report anything they did not use.
|
||||||
|
|
||||||
|
## Where the pieces live
|
||||||
|
|
||||||
|
| Concern | Module |
|
||||||
|
|---|---|
|
||||||
|
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
|
||||||
|
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
|
||||||
|
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
|
||||||
|
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""Import and export a calibration sequence (workflow P5/P8).
|
||||||
|
|
||||||
|
A sequence is a small, human-readable document — a profile plus the points it
|
||||||
|
generated — so it can be reviewed, diffed, emailed, and reproduced on another
|
||||||
|
bench. The format follows this repository's other documents rather than
|
||||||
|
inventing a convention: a dotted ``format_version`` string, sorted sections,
|
||||||
|
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
|
||||||
|
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
|
||||||
|
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
|
||||||
|
|
||||||
|
Two properties matter more than the field names:
|
||||||
|
|
||||||
|
* **``summary`` is derived.** It exists so a human reading the file sees the
|
||||||
|
point count and the travel span without counting rows. It is *recomputed* on
|
||||||
|
load, and a mismatch against the file is reported — that is how a hand-edited
|
||||||
|
document announces itself instead of quietly disagreeing with its own contents.
|
||||||
|
* **The file states its frame.** A sequence whose numbers do not say whether they
|
||||||
|
are millimetres or user units is a sequence that will eventually be read wrong,
|
||||||
|
so ``frame`` records both the unit and the pulse equivalent it was built with.
|
||||||
|
|
||||||
|
Nothing here imports Qt, so the format can be exercised from a test process and
|
||||||
|
a future batch tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
#: The version this build writes, and the highest it will read.
|
||||||
|
FORMAT_VERSION = "1.0"
|
||||||
|
|
||||||
|
#: What a sequence document says it is. Checked on import: a force-calibration
|
||||||
|
#: file and a sequence file are both JSON, and applying one as the other must
|
||||||
|
#: fail loudly rather than half-work.
|
||||||
|
DOCUMENT_KIND = "autocal.sequence"
|
||||||
|
|
||||||
|
#: File extension by convention. Import accepts any ``.json``.
|
||||||
|
FILE_SUFFIX = ".oryseq"
|
||||||
|
|
||||||
|
#: The frame targets are expressed in.
|
||||||
|
TARGET_FRAME = "platform"
|
||||||
|
LENGTH_UNIT = "mm"
|
||||||
|
|
||||||
|
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
|
||||||
|
#: A first-pass planning figure, not a measurement: the axes' own speed setting
|
||||||
|
#: is in user units and the real rate depends on acceleration and the path.
|
||||||
|
#: Clearly labelled as an estimate wherever it is shown.
|
||||||
|
ASSUMED_TRAVERSE_MM_S = 20.0
|
||||||
|
|
||||||
|
#: The profile keys a document must carry, matching the panel's fields.
|
||||||
|
PROFILE_KEYS: tuple[str, ...] = (
|
||||||
|
"mode",
|
||||||
|
"pitch_mm",
|
||||||
|
"margin_mm",
|
||||||
|
"radius_mm",
|
||||||
|
"clear_height_mm",
|
||||||
|
"approach_speed_mm_s",
|
||||||
|
"press_speed_mm_s",
|
||||||
|
"press_depth_mm",
|
||||||
|
"dwell_s",
|
||||||
|
"settle_window_s",
|
||||||
|
"repeats",
|
||||||
|
"order",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceFormatError(ValueError):
|
||||||
|
"""A sequence document could not be read.
|
||||||
|
|
||||||
|
The message is what the operator sees, so it always names the specific
|
||||||
|
problem (which key, which version, which point) rather than "invalid file".
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SequencePoint:
|
||||||
|
"""One generated calibration point.
|
||||||
|
|
||||||
|
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
|
||||||
|
sensor. Both are stored: the run needs the first, and the second is what makes
|
||||||
|
the point checkable against the surface. Re-deriving one from the other on
|
||||||
|
every load would silently change what the points mean when a sensor is
|
||||||
|
re-fixtured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
target_mm: dict[str, float]
|
||||||
|
sensor_mm: dict[str, float] | None = None
|
||||||
|
clearance_mm: float = 0.0
|
||||||
|
enabled: bool = True
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"id": int(self.id),
|
||||||
|
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
|
||||||
|
"clearance_mm": float(self.clearance_mm),
|
||||||
|
"enabled": bool(self.enabled),
|
||||||
|
"note": str(self.note),
|
||||||
|
}
|
||||||
|
if self.sensor_mm is not None:
|
||||||
|
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SequenceDocument:
|
||||||
|
"""A sequence as it travels between machines."""
|
||||||
|
|
||||||
|
profile: dict[str, Any] = field(default_factory=dict)
|
||||||
|
points: list[SequencePoint] = field(default_factory=list)
|
||||||
|
name: str = ""
|
||||||
|
note: str = ""
|
||||||
|
device: dict[str, Any] = field(default_factory=dict)
|
||||||
|
units_per_mm: float = 0.0
|
||||||
|
created_at: str = ""
|
||||||
|
summary: dict[str, Any] = field(default_factory=dict)
|
||||||
|
#: Problems found while reading that did not stop the import. Shown to the
|
||||||
|
#: operator: "it loaded" and "it loaded as written" are different claims.
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def point_count(self) -> int:
|
||||||
|
return len(self.points)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled_count(self) -> int:
|
||||||
|
return sum(1 for point in self.points if point.enabled)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- derive
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(
|
||||||
|
points: Iterable[SequencePoint], profile: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The derived block: count, travel span, and an estimated duration.
|
||||||
|
|
||||||
|
Recomputed everywhere rather than trusted from a file, so a document cannot
|
||||||
|
claim a range its points do not have.
|
||||||
|
"""
|
||||||
|
ordered = list(points)
|
||||||
|
span: dict[str, float] = {}
|
||||||
|
minimum: dict[str, float] = {}
|
||||||
|
maximum: dict[str, float] = {}
|
||||||
|
for axis in "xyz":
|
||||||
|
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
|
||||||
|
if values:
|
||||||
|
minimum[axis] = min(values)
|
||||||
|
maximum[axis] = max(values)
|
||||||
|
span[axis] = maximum[axis] - minimum[axis]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": len(ordered),
|
||||||
|
"enabled": sum(1 for point in ordered if point.enabled),
|
||||||
|
"min_mm": minimum,
|
||||||
|
"max_mm": maximum,
|
||||||
|
"span_mm": span,
|
||||||
|
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
|
||||||
|
"estimated_duration_s": estimate_duration(ordered, profile),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_duration(
|
||||||
|
points: Iterable[SequencePoint], profile: Mapping[str, Any]
|
||||||
|
) -> float:
|
||||||
|
"""A planning estimate for one full run, in seconds.
|
||||||
|
|
||||||
|
Per point: the descent and press, the dwell, and the settle window, repeated
|
||||||
|
``repeats`` times; plus a traverse between points at
|
||||||
|
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
|
||||||
|
shown — it assumes a constant traverse rate and ignores acceleration.
|
||||||
|
"""
|
||||||
|
ordered = list(points)
|
||||||
|
if not ordered:
|
||||||
|
return 0.0
|
||||||
|
repeats = max(1, int(_number(profile.get("repeats"), 1)))
|
||||||
|
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
|
||||||
|
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
|
||||||
|
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
|
||||||
|
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
|
||||||
|
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
|
||||||
|
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
|
||||||
|
|
||||||
|
descent = clear_height / approach
|
||||||
|
press = press_depth / press_speed
|
||||||
|
per_point = (descent + press + dwell + settle) * repeats
|
||||||
|
|
||||||
|
total = per_point * len(ordered)
|
||||||
|
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
|
||||||
|
if pitch > 0:
|
||||||
|
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
|
||||||
|
return float(total)
|
||||||
|
|
||||||
|
|
||||||
|
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
|
||||||
|
"""``min–max (span)``, or a single number when the travel is nil.
|
||||||
|
|
||||||
|
A zero span means every point shares that coordinate; printing
|
||||||
|
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
|
||||||
|
a glance.
|
||||||
|
"""
|
||||||
|
if low is None or high is None:
|
||||||
|
return "—"
|
||||||
|
if abs(high - low) < 10 ** (-places) / 2:
|
||||||
|
return f"{low:.{places}f}"
|
||||||
|
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float | None) -> str:
|
||||||
|
"""``m:ss``, or a dash when no estimate can be made."""
|
||||||
|
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
|
||||||
|
return "—"
|
||||||
|
total = int(round(float(seconds)))
|
||||||
|
minutes, rest = divmod(total, 60)
|
||||||
|
if minutes >= 60:
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
return f"{hours:d}:{minutes:02d}:{rest:02d}"
|
||||||
|
return f"{minutes:d}:{rest:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- serialize
|
||||||
|
|
||||||
|
|
||||||
|
def to_document(
|
||||||
|
*,
|
||||||
|
profile: Mapping[str, Any],
|
||||||
|
points: Iterable[SequencePoint] = (),
|
||||||
|
name: str = "",
|
||||||
|
note: str = "",
|
||||||
|
device: Mapping[str, Any] | None = None,
|
||||||
|
units_per_mm: float = 0.0,
|
||||||
|
app_version: str = "",
|
||||||
|
shared_version: str = "",
|
||||||
|
shared_api: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the sequence document."""
|
||||||
|
ordered = list(points)
|
||||||
|
return {
|
||||||
|
"format_version": FORMAT_VERSION,
|
||||||
|
"kind": DOCUMENT_KIND,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"name": str(name),
|
||||||
|
"note": str(note),
|
||||||
|
"versions": {
|
||||||
|
"app": str(app_version),
|
||||||
|
"shared": str(shared_version),
|
||||||
|
"shared_api": str(shared_api),
|
||||||
|
},
|
||||||
|
"device": dict(device or {}),
|
||||||
|
"frame": {
|
||||||
|
"targets": TARGET_FRAME,
|
||||||
|
"length_unit": LENGTH_UNIT,
|
||||||
|
"units_per_mm": float(units_per_mm),
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
key: profile.get(key)
|
||||||
|
for key in PROFILE_KEYS
|
||||||
|
},
|
||||||
|
"points": [point.as_dict() for point in ordered],
|
||||||
|
"summary": summarise(ordered, profile),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save(document: Mapping[str, Any], path: str | Path) -> Path:
|
||||||
|
"""Write ``document`` atomically, as UTF-8 JSON.
|
||||||
|
|
||||||
|
Temp-then-replace: a half-written sequence file that still parses is worse
|
||||||
|
than no file, because it looks like a design.
|
||||||
|
"""
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(target.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- parse
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
|
||||||
|
"""Read a sequence document, validating it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the file to read.
|
||||||
|
current_profile: the profile in force now. Missing profile keys are
|
||||||
|
taken from it and reported, rather than silently defaulting to
|
||||||
|
another bench's numbers.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SequenceFormatError: with a message naming the specific problem.
|
||||||
|
"""
|
||||||
|
target = Path(path)
|
||||||
|
try:
|
||||||
|
text = target.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{target.name}: the top level must be an object, found {type(data).__name__}"
|
||||||
|
)
|
||||||
|
return from_document(data, current_profile=current_profile, source=target.name)
|
||||||
|
|
||||||
|
|
||||||
|
def from_document(
|
||||||
|
data: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
current_profile: Mapping[str, Any] | None = None,
|
||||||
|
source: str = "document",
|
||||||
|
) -> SequenceDocument:
|
||||||
|
"""Validate and interpret an already-parsed sequence document."""
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
kind = data.get("kind")
|
||||||
|
if kind != DOCUMENT_KIND:
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{source}: not a sequence document "
|
||||||
|
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_version = data.get("format_version")
|
||||||
|
if not isinstance(raw_version, str) or not raw_version.strip():
|
||||||
|
raise SequenceFormatError(f"{source}: missing or invalid format_version")
|
||||||
|
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{source}: written by a newer AutoCal "
|
||||||
|
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = data.get("frame")
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
raise SequenceFormatError(f"{source}: missing frame block")
|
||||||
|
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_in = data.get("profile")
|
||||||
|
if not isinstance(profile_in, dict):
|
||||||
|
raise SequenceFormatError(f"{source}: missing or invalid profile block")
|
||||||
|
profile: dict[str, Any] = dict(current_profile or {})
|
||||||
|
missing: list[str] = []
|
||||||
|
for key in PROFILE_KEYS:
|
||||||
|
if key in profile_in:
|
||||||
|
profile[key] = profile_in[key]
|
||||||
|
else:
|
||||||
|
missing.append(key)
|
||||||
|
if missing:
|
||||||
|
warnings.append(
|
||||||
|
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
points = _parse_points(data.get("points"), source=source, warnings=warnings)
|
||||||
|
|
||||||
|
stored_summary = data.get("summary")
|
||||||
|
recomputed = summarise(points, profile)
|
||||||
|
if isinstance(stored_summary, dict) and points:
|
||||||
|
differences = _summary_differences(stored_summary, recomputed)
|
||||||
|
if differences:
|
||||||
|
warnings.append(
|
||||||
|
"the stored summary disagrees with the points and was recomputed: "
|
||||||
|
+ "; ".join(differences)
|
||||||
|
)
|
||||||
|
|
||||||
|
device = data.get("device") if isinstance(data.get("device"), dict) else {}
|
||||||
|
return SequenceDocument(
|
||||||
|
profile=profile,
|
||||||
|
points=points,
|
||||||
|
name=str(data.get("name") or ""),
|
||||||
|
note=str(data.get("note") or ""),
|
||||||
|
device=dict(device),
|
||||||
|
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
|
||||||
|
created_at=str(data.get("created_at") or ""),
|
||||||
|
summary=recomputed,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_points(
|
||||||
|
raw: Any, *, source: str, warnings: list[str]
|
||||||
|
) -> list[SequencePoint]:
|
||||||
|
if raw is None:
|
||||||
|
warnings.append("no points in the file; only the profile was imported")
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise SequenceFormatError(
|
||||||
|
f"{source}: points must be a list, found {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
points: list[SequencePoint] = []
|
||||||
|
for index, entry in enumerate(raw, start=1):
|
||||||
|
where = f"{source}: point {index}"
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise SequenceFormatError(f"{where} is not an object")
|
||||||
|
target = entry.get("target_mm")
|
||||||
|
if not isinstance(target, dict):
|
||||||
|
raise SequenceFormatError(f"{where} has no target_mm object")
|
||||||
|
for axis in "xyz":
|
||||||
|
if axis not in target:
|
||||||
|
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
|
||||||
|
sensor = entry.get("sensor_mm")
|
||||||
|
points.append(
|
||||||
|
SequencePoint(
|
||||||
|
id=int(_number(entry.get("id"), index)),
|
||||||
|
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
|
||||||
|
sensor_mm=(
|
||||||
|
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
|
||||||
|
if isinstance(sensor, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
|
||||||
|
enabled=bool(entry.get("enabled", True)),
|
||||||
|
note=str(entry.get("note") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
|
||||||
|
"""Where the file's own summary disagrees with its points."""
|
||||||
|
differences: list[str] = []
|
||||||
|
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
|
||||||
|
differences.append(
|
||||||
|
f"count {stored.get('count')} != {recomputed['count']}"
|
||||||
|
)
|
||||||
|
stored_span = stored.get("span_mm")
|
||||||
|
if isinstance(stored_span, dict):
|
||||||
|
for axis in "xyz":
|
||||||
|
if axis in recomputed["span_mm"]:
|
||||||
|
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
|
||||||
|
differences.append(
|
||||||
|
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
|
||||||
|
)
|
||||||
|
return differences
|
||||||
|
|
||||||
|
|
||||||
|
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
|
||||||
|
return abs(float(first) - float(second)) <= tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, fallback: float) -> float:
|
||||||
|
"""A finite float, or the fallback. Never NaN, never a string."""
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return float(fallback)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
return float(fallback)
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _version_tuple(text: str) -> tuple[int, ...]:
|
||||||
|
"""``"1.10"`` as ``(1, 10)``.
|
||||||
|
|
||||||
|
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
|
||||||
|
would make a newer file look older and load it as if it were supported.
|
||||||
|
"""
|
||||||
|
parts: list[int] = []
|
||||||
|
for chunk in str(text).split("."):
|
||||||
|
digits = "".join(character for character in chunk if character.isdigit())
|
||||||
|
parts.append(int(digits) if digits else 0)
|
||||||
|
return tuple(parts)
|
||||||
@@ -47,6 +47,12 @@ from PySide6.QtWidgets import (
|
|||||||
from orisys_app_shared.i18n import t
|
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 (
|
||||||
|
SequencePoint,
|
||||||
|
format_duration,
|
||||||
|
format_span,
|
||||||
|
summarise,
|
||||||
|
)
|
||||||
|
|
||||||
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
||||||
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
||||||
@@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
|||||||
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
||||||
|
|
||||||
#: Point-table columns: ``(key, locale_key)``.
|
#: Point-table columns: ``(key, locale_key)``.
|
||||||
|
#:
|
||||||
|
#: No actions column. It was rendered empty on every row, and an empty action
|
||||||
|
#: cell reads as a broken control — the per-point actions (re-measure, skip)
|
||||||
|
#: belong to the run, not to the design screen.
|
||||||
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||||
("index", "sequence.col_index"),
|
("index", "sequence.col_index"),
|
||||||
("x", "sequence.col_x"),
|
("x", "sequence.col_x"),
|
||||||
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
|||||||
("status", "sequence.col_status"),
|
("status", "sequence.col_status"),
|
||||||
("sensitivity", "sequence.col_sensitivity"),
|
("sensitivity", "sequence.col_sensitivity"),
|
||||||
("samples", "sequence.col_samples"),
|
("samples", "sequence.col_samples"),
|
||||||
("actions", "sequence.col_actions"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
||||||
@@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
|||||||
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
||||||
|
|
||||||
#: Column widths of the point table, by column index.
|
#: Column widths of the point table, by column index.
|
||||||
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
|
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
|
||||||
|
|
||||||
#: Reserved controls and the phase that will fill each one.
|
#: Reserved controls and the phase that will fill each one.
|
||||||
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
|
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
|
||||||
|
|
||||||
#: Which phase each pending control belongs to (see docs/workflow.md).
|
#: Which phase each pending control belongs to (see docs/workflow.md).
|
||||||
PENDING_PHASES: dict[str, str] = {
|
PENDING_PHASES: dict[str, str] = {
|
||||||
"generate": "P5",
|
"generate": "P5",
|
||||||
"simulate": "P6",
|
"simulate": "P6",
|
||||||
"dry_run": "P6",
|
"dry_run": "P6",
|
||||||
"save": "P8",
|
|
||||||
"load": "P8",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: Controls that work now: a sequence is a document, and P8 says it is a
|
||||||
|
#: versioned artifact. Import and export are the first half of that.
|
||||||
|
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
|
||||||
|
|
||||||
#: The validation checks, with the locale key naming what each one proves.
|
#: The validation checks, with the locale key naming what each one proves.
|
||||||
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
||||||
("on_surface", "sequence.check_surface"),
|
("on_surface", "sequence.check_surface"),
|
||||||
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
|
|||||||
generateRequested = Signal()
|
generateRequested = Signal()
|
||||||
simulateRequested = Signal()
|
simulateRequested = Signal()
|
||||||
dryRunRequested = Signal()
|
dryRunRequested = Signal()
|
||||||
saveRequested = Signal()
|
exportRequested = Signal()
|
||||||
loadRequested = Signal()
|
importRequested = Signal()
|
||||||
|
|
||||||
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
|
|||||||
self._choices: dict[str, QComboBox] = {}
|
self._choices: dict[str, QComboBox] = {}
|
||||||
self._pending_buttons: dict[str, QPushButton] = {}
|
self._pending_buttons: dict[str, QPushButton] = {}
|
||||||
self._check_rows: dict[str, QLabel] = {}
|
self._check_rows: dict[str, QLabel] = {}
|
||||||
|
self._summary: dict[str, Any] = {}
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(10, 8, 10, 10)
|
outer.setContentsMargins(10, 8, 10, 10)
|
||||||
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
self.set_profile(profile or {})
|
self.set_profile(profile or {})
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
|
# A profile edit changes the estimate, so the line follows the form.
|
||||||
|
for widget in self._profile_fields.values():
|
||||||
|
if isinstance(widget, QComboBox):
|
||||||
|
widget.currentIndexChanged.connect(self._mark_dirty)
|
||||||
|
else:
|
||||||
|
widget.textChanged.connect(self._mark_dirty)
|
||||||
|
self.refresh_summary()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ profile
|
# ------------------------------------------------------------------ profile
|
||||||
|
|
||||||
def _build_profile(self) -> QWidget:
|
def _build_profile(self) -> QWidget:
|
||||||
box = QGroupBox(self)
|
box = QGroupBox(self)
|
||||||
box.setObjectName("groupBox")
|
box.setObjectName("groupBox")
|
||||||
grid = QGridLayout(box)
|
outer = QVBoxLayout(box)
|
||||||
grid.setContentsMargins(10, 8, 10, 8)
|
outer.setContentsMargins(10, 8, 10, 8)
|
||||||
|
outer.setSpacing(4)
|
||||||
|
|
||||||
|
# The name is what the document is called on disk and in the export, so
|
||||||
|
# it lives at the top of the block that defines the sequence rather than
|
||||||
|
# in a dialog.
|
||||||
|
name_row = QHBoxLayout()
|
||||||
|
name_row.setSpacing(8)
|
||||||
|
self._name_label = QLabel(box)
|
||||||
|
self._name_label.setObjectName("hintLabel")
|
||||||
|
self.name_edit = QLineEdit(box)
|
||||||
|
self.name_edit.setFixedHeight(28)
|
||||||
|
name_row.addWidget(self._name_label)
|
||||||
|
name_row.addWidget(self.name_edit, 1)
|
||||||
|
outer.addLayout(name_row)
|
||||||
|
|
||||||
|
self._note_label = QLabel(box)
|
||||||
|
self._note_label.setObjectName("hintLabel")
|
||||||
|
self.note_edit = QLineEdit(box)
|
||||||
|
self.note_edit.setFixedHeight(28)
|
||||||
|
outer.addWidget(self._note_label)
|
||||||
|
outer.addWidget(self.note_edit)
|
||||||
|
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setContentsMargins(0, 0, 0, 0)
|
||||||
grid.setHorizontalSpacing(10)
|
grid.setHorizontalSpacing(10)
|
||||||
grid.setVerticalSpacing(4)
|
grid.setVerticalSpacing(4)
|
||||||
|
outer.addLayout(grid)
|
||||||
|
|
||||||
# Two fields per row: label, field, label, field. Seeing the whole profile
|
# Two fields per row: label, field, label, field. Seeing the whole profile
|
||||||
# at once is what lets an operator sanity-check a pitch against a depth.
|
# at once is what lets an operator sanity-check a pitch against a depth.
|
||||||
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
|
|||||||
column.addWidget(button)
|
column.addWidget(button)
|
||||||
self._pending_buttons[key] = button
|
self._pending_buttons[key] = button
|
||||||
|
|
||||||
|
# Import/export work today: a sequence is a portable document (P8).
|
||||||
|
self._live_buttons: dict[str, QPushButton] = {}
|
||||||
|
for key in LIVE_CONTROLS:
|
||||||
|
button = QPushButton(box)
|
||||||
|
button.setProperty("rail", True)
|
||||||
|
button.setFixedHeight(28)
|
||||||
|
column.addWidget(button)
|
||||||
|
self._live_buttons[key] = button
|
||||||
|
|
||||||
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
|
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
|
||||||
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
|
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
|
||||||
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
|
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
|
||||||
self._pending_buttons["save"].clicked.connect(self.saveRequested)
|
self._live_buttons["export"].clicked.connect(self.exportRequested)
|
||||||
self._pending_buttons["load"].clicked.connect(self.loadRequested)
|
self._live_buttons["import"].clicked.connect(self.importRequested)
|
||||||
# None of these can run: generation, simulation, the runner and the
|
# Generation, simulation and the runner belong to later phases (P5-P7).
|
||||||
# dataset writer belong to later phases (see docs/workflow.md).
|
|
||||||
for button in self._pending_buttons.values():
|
for button in self._pending_buttons.values():
|
||||||
button.setEnabled(False)
|
button.setEnabled(False)
|
||||||
|
|
||||||
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
# -------------------------------------------------------------- point table
|
# -------------------------------------------------------------- point table
|
||||||
|
|
||||||
|
def _mark_dirty(self, *_args: Any) -> None:
|
||||||
|
self._dirty = True
|
||||||
|
self.refresh_summary()
|
||||||
|
|
||||||
def _build_point_table(self) -> QWidget:
|
def _build_point_table(self) -> QWidget:
|
||||||
box = QGroupBox(self)
|
box = QGroupBox(self)
|
||||||
box.setObjectName("groupBox")
|
box.setObjectName("groupBox")
|
||||||
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
|
|||||||
4: t("sequence.status_pending"),
|
4: t("sequence.status_pending"),
|
||||||
5: "—",
|
5: "—",
|
||||||
6: "—",
|
6: "—",
|
||||||
7: "",
|
|
||||||
}
|
}
|
||||||
for column_index, text in values.items():
|
for column_index, text in values.items():
|
||||||
item = QTableWidgetItem(text)
|
item = QTableWidgetItem(text)
|
||||||
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
|
|||||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.table.setItem(row, column_index, item)
|
self.table.setItem(row, column_index, item)
|
||||||
|
|
||||||
|
# The count and span line: the two numbers an operator reasons with, and
|
||||||
|
# the only place a wrong pitch or margin shows up before a seven-minute
|
||||||
|
# run does it for them.
|
||||||
|
self.summary_label = QLabel(box)
|
||||||
|
self.summary_label.setObjectName("statusValue")
|
||||||
|
self.summary_label.setWordWrap(True)
|
||||||
|
|
||||||
column.addWidget(self.table, 1)
|
column.addWidget(self.table, 1)
|
||||||
|
column.addWidget(self.summary_label)
|
||||||
|
|
||||||
self.point_hint = QLabel(box)
|
self.point_hint = QLabel(box)
|
||||||
self.point_hint.setObjectName("hintLabel")
|
self.point_hint.setObjectName("hintLabel")
|
||||||
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
|
|||||||
4: status or t("sequence.status_pending"),
|
4: status or t("sequence.status_pending"),
|
||||||
5: "—",
|
5: "—",
|
||||||
6: "—",
|
6: "—",
|
||||||
7: "",
|
|
||||||
}
|
}
|
||||||
for column, text in values.items():
|
for column, text in values.items():
|
||||||
item = QTableWidgetItem(text)
|
item = QTableWidgetItem(text)
|
||||||
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
|
|||||||
self.table.setItem(row, column, item)
|
self.table.setItem(row, column, item)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- points & state
|
||||||
|
|
||||||
|
def points(self) -> list[SequencePoint]:
|
||||||
|
"""The list as it stands, for export and for a future runner."""
|
||||||
|
points: list[SequencePoint] = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
cells = self.point_row(row)
|
||||||
|
try:
|
||||||
|
target = {axis: float(cells[axis]) for axis in "xyz"}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # a placeholder row that was never generated
|
||||||
|
points.append(
|
||||||
|
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
def set_points(self, points: list[SequencePoint]) -> None:
|
||||||
|
"""Replace the list. Used by import and by the future generator."""
|
||||||
|
self.table.setRowCount(0)
|
||||||
|
for point in points:
|
||||||
|
row = self.table.rowCount()
|
||||||
|
self.table.insertRow(row)
|
||||||
|
values = {
|
||||||
|
0: str(point.id),
|
||||||
|
1: f"{point.target_mm.get('x', 0.0):.3f}",
|
||||||
|
2: f"{point.target_mm.get('y', 0.0):.3f}",
|
||||||
|
3: f"{point.target_mm.get('z', 0.0):.3f}",
|
||||||
|
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
|
||||||
|
5: "—",
|
||||||
|
6: "—",
|
||||||
|
}
|
||||||
|
for column, text in values.items():
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
if column >= 4:
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, column, item)
|
||||||
|
self._dirty = True
|
||||||
|
self.refresh_summary()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dirty(self) -> bool:
|
||||||
|
"""Whether the design has changed since it was last saved or loaded.
|
||||||
|
|
||||||
|
Import replaces the design outright, and there is no autosave; discarding
|
||||||
|
a designed sequence without asking would be worse than one extra prompt.
|
||||||
|
"""
|
||||||
|
return self._dirty
|
||||||
|
|
||||||
|
def mark_clean(self) -> None:
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.name_edit.text().strip()
|
||||||
|
|
||||||
|
def set_name(self, value: str) -> None:
|
||||||
|
self.name_edit.setText(str(value or ""))
|
||||||
|
|
||||||
|
def note(self) -> str:
|
||||||
|
return self.note_edit.text().strip()
|
||||||
|
|
||||||
|
def set_note(self, value: str) -> None:
|
||||||
|
self.note_edit.setText(str(value or ""))
|
||||||
|
|
||||||
|
def refresh_summary(self) -> None:
|
||||||
|
"""Recompute the count, span and duration line from what is on screen."""
|
||||||
|
profile = self.profile()
|
||||||
|
points = self.points()
|
||||||
|
summary = summarise(points, profile)
|
||||||
|
self._summary = summary
|
||||||
|
self.summary_label.setText(_summary_text(summary, profile))
|
||||||
|
|
||||||
def planned_steps(self) -> list[SequenceStep]:
|
def planned_steps(self) -> list[SequenceStep]:
|
||||||
"""The steps the table describes, for the future runner.
|
"""The steps the table describes, for the future runner.
|
||||||
|
|
||||||
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
|
|||||||
for key, combo in self._choices.items():
|
for key, combo in self._choices.items():
|
||||||
_fill_choice(combo, key)
|
_fill_choice(combo, key)
|
||||||
|
|
||||||
|
self._name_label.setText(t("sequence.field_name"))
|
||||||
|
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
|
||||||
|
self._note_label.setText(t("sequence.field_note"))
|
||||||
|
|
||||||
self.preview_label.setText(t("sequence.preview_placeholder"))
|
self.preview_label.setText(t("sequence.preview_placeholder"))
|
||||||
self.preview_hint.setText(t("sequence.preview_hint"))
|
self.preview_hint.setText(t("sequence.preview_hint"))
|
||||||
for key, _locale_key in VALIDATION_CHECKS:
|
for key, _locale_key in VALIDATION_CHECKS:
|
||||||
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
|
|||||||
self._pending_buttons[key].setToolTip(
|
self._pending_buttons[key].setToolTip(
|
||||||
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
|
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
|
||||||
)
|
)
|
||||||
|
for key in LIVE_CONTROLS:
|
||||||
|
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
|
||||||
|
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
|
||||||
|
|
||||||
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
||||||
for row in range(self.table.rowCount()):
|
for row in range(self.table.rowCount()):
|
||||||
@@ -449,6 +590,33 @@ class SequencePanel(QGroupBox):
|
|||||||
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
||||||
item.setText(t("sequence.status_pending"))
|
item.setText(t("sequence.status_pending"))
|
||||||
self.point_hint.setText(t("sequence.points_hint"))
|
self.point_hint.setText(t("sequence.points_hint"))
|
||||||
|
self.refresh_summary()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
|
||||||
|
"""The count / span / duration line under the point list.
|
||||||
|
|
||||||
|
Spans are ``min–max (span)`` with a nil span printed once; the duration is
|
||||||
|
labelled an estimate because it is one.
|
||||||
|
"""
|
||||||
|
count = int(summary.get("count") or 0)
|
||||||
|
enabled = int(summary.get("enabled") or count)
|
||||||
|
if not count:
|
||||||
|
return t("sequence.summary_empty")
|
||||||
|
|
||||||
|
spans = " · ".join(
|
||||||
|
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
|
||||||
|
for axis in "xyz"
|
||||||
|
if axis in summary.get("min_mm", {})
|
||||||
|
)
|
||||||
|
duration = format_duration(summary.get("estimated_duration_s"))
|
||||||
|
return t(
|
||||||
|
"sequence.summary",
|
||||||
|
count=count,
|
||||||
|
enabled=enabled,
|
||||||
|
spans=spans,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fill_choice(combo: QComboBox, key: str) -> None:
|
def _fill_choice(combo: QComboBox, key: str) -> None:
|
||||||
|
|||||||
@@ -314,6 +314,11 @@ class RunBar(QFrame):
|
|||||||
self.step_label.setMinimumWidth(140)
|
self.step_label.setMinimumWidth(140)
|
||||||
layout.addWidget(self.step_label)
|
layout.addWidget(self.step_label)
|
||||||
|
|
||||||
|
# Shown only while idle, in place of the step/progress/timing trio.
|
||||||
|
self._idle_hint = QLabel(self)
|
||||||
|
self._idle_hint.setObjectName("hintLabel")
|
||||||
|
layout.addWidget(self._idle_hint)
|
||||||
|
|
||||||
self.progress = QProgressBar(self)
|
self.progress = QProgressBar(self)
|
||||||
self.progress.setTextVisible(False)
|
self.progress.setTextVisible(False)
|
||||||
self.progress.setFixedHeight(12)
|
self.progress.setFixedHeight(12)
|
||||||
@@ -395,6 +400,14 @@ class RunBar(QFrame):
|
|||||||
self.abort_button.setEnabled(self._has_run)
|
self.abort_button.setEnabled(self._has_run)
|
||||||
self.pause_button.setEnabled(self._has_run and busy)
|
self.pause_button.setEnabled(self._has_run and busy)
|
||||||
self.run_button.setEnabled(not busy)
|
self.run_button.setEnabled(not busy)
|
||||||
|
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
|
||||||
|
# job is to be read at a glance. Idle shows the phase and why instead.
|
||||||
|
live = self._has_run or busy
|
||||||
|
self.step_label.setVisible(live)
|
||||||
|
self.progress.setVisible(live)
|
||||||
|
self.time_label.setVisible(live)
|
||||||
|
if not live:
|
||||||
|
self._idle_hint.setText(t("run.no_steps"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def phase(self) -> str:
|
def phase(self) -> str:
|
||||||
@@ -415,6 +428,7 @@ class RunBar(QFrame):
|
|||||||
else:
|
else:
|
||||||
self.step_label.setText(t("run.no_steps"))
|
self.step_label.setText(t("run.no_steps"))
|
||||||
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
|
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
|
||||||
|
self._refresh_buttons(self._phase in ("running", "paused"))
|
||||||
|
|
||||||
|
|
||||||
def _duration(seconds: float) -> str:
|
def _duration(seconds: float) -> str:
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ 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 ..preferences import load_preferences, save_preferences
|
from ..preferences import load_preferences, save_preferences
|
||||||
|
from ..sequence_io import (
|
||||||
|
FILE_SUFFIX,
|
||||||
|
SequenceDocument,
|
||||||
|
SequenceFormatError,
|
||||||
|
load,
|
||||||
|
save,
|
||||||
|
to_document,
|
||||||
|
)
|
||||||
from .axis_panel import AxisPanel
|
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
|
||||||
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
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)
|
||||||
|
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
|
||||||
|
self.sequence_panel.importRequested.connect(self._on_import_sequence)
|
||||||
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)
|
||||||
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage(text)
|
self.statusBar().showMessage(text)
|
||||||
self.status_strip.set_action_result(text, ok)
|
self.status_strip.set_action_result(text, ok)
|
||||||
|
|
||||||
|
# ------------------------------------------------------- sequence import/export
|
||||||
|
|
||||||
|
def _default_sequence_path(self) -> str:
|
||||||
|
"""Start the file dialog somewhere useful and named after the profile."""
|
||||||
|
stem = self.sequence_panel.name() or "sequence"
|
||||||
|
return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}")
|
||||||
|
|
||||||
|
def _sequence_device_ref(self) -> dict:
|
||||||
|
setup = self.prefs.get("sensor_setup") or {}
|
||||||
|
return {
|
||||||
|
"sensor_type": str(setup.get("sensor_type") or ""),
|
||||||
|
"config_ref": str(setup.get("config_ref") or ""),
|
||||||
|
"geometry_id": str(setup.get("geometry_id") or ""),
|
||||||
|
"probe_tip_set": bool(setup.get("probe_tip_set")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def export_sequence(self, path: str) -> str:
|
||||||
|
"""Write the current design. Returns the path written."""
|
||||||
|
document = to_document(
|
||||||
|
profile=self.sequence_panel.profile(),
|
||||||
|
points=self.sequence_panel.points(),
|
||||||
|
name=self.sequence_panel.name(),
|
||||||
|
note=self.sequence_panel.note(),
|
||||||
|
device=self._sequence_device_ref(),
|
||||||
|
units_per_mm=float(self.prefs.get("units_per_mm") or 0.0),
|
||||||
|
app_version=app_version,
|
||||||
|
shared_version=shared_version,
|
||||||
|
shared_api=API_VERSION,
|
||||||
|
)
|
||||||
|
written = save(document, path)
|
||||||
|
self.sequence_panel.mark_clean()
|
||||||
|
return str(written)
|
||||||
|
|
||||||
|
def import_sequence(self, path: str) -> SequenceDocument:
|
||||||
|
"""Read a design and apply it. Raises SequenceFormatError on a bad file."""
|
||||||
|
document = load(path, current_profile=self.sequence_panel.profile())
|
||||||
|
self.sequence_panel.set_profile(document.profile)
|
||||||
|
self.sequence_panel.set_points(document.points)
|
||||||
|
self.sequence_panel.set_name(document.name)
|
||||||
|
self.sequence_panel.set_note(document.note)
|
||||||
|
self.sequence_panel.mark_clean()
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _on_export_sequence(self) -> None:
|
||||||
|
path, _selected = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
t("sequence.dialog_export"),
|
||||||
|
self._default_sequence_path(),
|
||||||
|
t("sequence.filter_sequence"),
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
written = self.export_sequence(path)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False)
|
||||||
|
return
|
||||||
|
self._report_sequence(
|
||||||
|
t("sequence.exported", name=Path(written).name), ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_import_sequence(self) -> None:
|
||||||
|
# Import replaces the design and there is no autosave, so an unsaved
|
||||||
|
# sequence is confirmed before it is discarded.
|
||||||
|
if self.sequence_panel.dirty:
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
t("sequence.dialog_import"),
|
||||||
|
t("sequence.confirm_discard"),
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
path, _selected = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
t("sequence.dialog_import"),
|
||||||
|
str(Path(outputs_dir()) / "sequences"),
|
||||||
|
t("sequence.filter_sequence"),
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
document = self.import_sequence(path)
|
||||||
|
except SequenceFormatError as exc:
|
||||||
|
self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False)
|
||||||
|
return
|
||||||
|
self._report_sequence(
|
||||||
|
t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True
|
||||||
|
)
|
||||||
|
if document.warnings:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("sequence.import_warnings", count=len(document.warnings))
|
||||||
|
)
|
||||||
|
for warning in document.warnings:
|
||||||
|
logger.warning("Sequence import: %s", warning)
|
||||||
|
|
||||||
|
def _report_sequence(self, text: str, *, ok: bool) -> None:
|
||||||
|
self.statusBar().showMessage(text)
|
||||||
|
self.status_strip.set_action_result(text, ok)
|
||||||
|
|
||||||
|
|
||||||
def _apply_chrome_colours(self) -> None:
|
def _apply_chrome_colours(self) -> None:
|
||||||
"""Style the status bar from the theme rather than Qt's default.
|
"""Style the status bar from the theme rather than Qt's default.
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_dir():
|
||||||
|
"""A workspace-local scratch directory, cleared per test.
|
||||||
|
|
||||||
|
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
||||||
|
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
||||||
|
environment may not provide.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
path = _scratch_dir()
|
||||||
|
yield path
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def make_window(qt_app, monkeypatch):
|
def make_window(qt_app, monkeypatch):
|
||||||
"""Build a window whose preference writes stay inside a scratch directory.
|
"""Build a window whose preference writes stay inside a scratch directory.
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
"""Binding policy for AutoCal.
|
"""Binding policy for the Orisys apps.
|
||||||
|
|
||||||
AutoCal is **PySide6 only**, and this file is the guard for that:
|
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
|
||||||
|
|
||||||
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
|
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
|
||||||
PySide6 and nothing else, so such an import is a hard start-up failure;
|
PySide6 and nothing else, so such an import is a hard start-up failure;
|
||||||
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
|
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
|
||||||
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
||||||
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
|
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
|
||||||
import no Qt binding at all;
|
must not pull in a second binding, and the studio's own guard
|
||||||
|
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
|
||||||
|
side. (Until the studio moved to PySide6 this file forbade *any* binding in
|
||||||
|
``shared/``; that rule existed for a two-binding workspace, which no longer
|
||||||
|
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
|
||||||
|
under the old rule those imports were failing silently instead.)
|
||||||
* the controller layer must stay importable from a headless process.
|
* the controller layer must stay importable from a headless process.
|
||||||
|
|
||||||
The reason the binding matters at all is licensing, not taste: PyQt5's free
|
The reason the binding matters at all is licensing, not taste: PyQt5's free
|
||||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
|
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
|
||||||
closed-source tool needs no licence purchase. See ``README.md``.
|
needs no licence purchase. See ``README.md``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,13 +28,19 @@ from pathlib import Path
|
|||||||
APP_SRC = Path(__file__).resolve().parents[1] / "src"
|
APP_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||||
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
|
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
|
||||||
|
|
||||||
#: Bindings the app must never import.
|
#: Bindings no module may import.
|
||||||
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||||
|
|
||||||
#: The one binding allowed, and where it may be named.
|
#: The one binding allowed, and where it may be named.
|
||||||
ALLOWED_BINDING = "PySide6"
|
ALLOWED_BINDING = "PySide6"
|
||||||
|
|
||||||
#: Scoped enum holders we care about, with the value names actually used.
|
#: Scoped enum holders we care about, with the value names actually used.
|
||||||
|
#:
|
||||||
|
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
|
||||||
|
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
|
||||||
|
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
|
||||||
|
#: which is the better rule; this map is kept because AutoCal cannot import the binding
|
||||||
|
#: in a headless test run.
|
||||||
_SCOPED_ENUMS = {
|
_SCOPED_ENUMS = {
|
||||||
"Qt": (
|
"Qt": (
|
||||||
"AlignmentFlag",
|
"AlignmentFlag",
|
||||||
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
|
|||||||
"ToolButtonStyle",
|
"ToolButtonStyle",
|
||||||
"FocusPolicy",
|
"FocusPolicy",
|
||||||
"ScrollBarPolicy",
|
"ScrollBarPolicy",
|
||||||
|
"CursorShape",
|
||||||
|
"MouseButton",
|
||||||
|
"Key",
|
||||||
|
"PenStyle",
|
||||||
|
"GlobalColor",
|
||||||
|
"ItemFlag",
|
||||||
),
|
),
|
||||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||||
"QSizePolicy": ("Policy",),
|
"QSizePolicy": ("Policy",),
|
||||||
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
|
|||||||
"QMessageBox": ("StandardButton", "Icon"),
|
"QMessageBox": ("StandardButton", "Icon"),
|
||||||
"QDialog": ("DialogCode",),
|
"QDialog": ("DialogCode",),
|
||||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||||
|
"QDialogButtonBox": ("StandardButton",),
|
||||||
|
"QPainter": ("RenderHint", "CompositionMode"),
|
||||||
|
"QImage": ("Format",),
|
||||||
|
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
||||||
|
"QFrame": ("Shape",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
|
|||||||
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
|
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
|
||||||
|
|
||||||
Parsed rather than pattern-matched for two reasons: a regex also hits prose
|
Parsed rather than pattern-matched for two reasons: a regex also hits prose
|
||||||
in a comment or docstring (which is exactly where the scoped spelling gets
|
in a comment or docstring (which is exactly where the *unscoped* spelling gets
|
||||||
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
|
explained, so a rewrite would destroy the explanation — this file quotes it
|
||||||
but is a static method. The parent map separates the two, and
|
three times, which is why the migration scripts skip it), and
|
||||||
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
|
``QMessageBox.information(...)`` looks like an enumerator but is a static
|
||||||
class rather than a plain name, so it is never a candidate.
|
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
|
||||||
|
an inner attribute on an allow-listed enum class rather than a plain name, so it
|
||||||
|
is never a candidate.
|
||||||
"""
|
"""
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
called = {
|
called = {
|
||||||
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
|
|||||||
assert not offenders, offenders
|
assert not offenders, offenders
|
||||||
|
|
||||||
|
|
||||||
def test_shared_imports_no_qt_binding():
|
def test_shared_imports_no_forbidden_binding():
|
||||||
"""Shared code serves both products, so it must not pick a side."""
|
"""Shared code serves both products, so it must not pull in a second binding.
|
||||||
|
|
||||||
|
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
|
||||||
|
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
|
||||||
|
import in a PySide6-only environment or — worse — work in one app's environment and not
|
||||||
|
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
|
||||||
|
the PyQt5 import in ``qss.py`` was failing silently as a result.
|
||||||
|
"""
|
||||||
offenders = []
|
offenders = []
|
||||||
for path in _python_files(SHARED_SRC):
|
for path in _python_files(SHARED_SRC):
|
||||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||||
if qt_imports:
|
if qt_imports:
|
||||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||||
assert not offenders, (
|
assert not offenders, (
|
||||||
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
|
"shared code may import PySide6 only; these files import another binding: "
|
||||||
f"{offenders}"
|
f"{offenders}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import pytest
|
|||||||
|
|
||||||
from conftest import pump
|
from conftest import pump
|
||||||
|
|
||||||
|
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
|
||||||
from orisys_autocal.ui.workspace import TABS
|
from orisys_autocal.ui.workspace import TABS
|
||||||
|
|
||||||
|
|
||||||
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
|||||||
"""A disabled control must say why, or it reads as a broken button."""
|
"""A disabled control must say why, or it reads as a broken button."""
|
||||||
window = _show(make_window())
|
window = _show(make_window())
|
||||||
panel = window.sequence_panel
|
panel = window.sequence_panel
|
||||||
assert panel.pending_controls() == [
|
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
|
||||||
"generate",
|
|
||||||
"simulate",
|
|
||||||
"dry_run",
|
|
||||||
"save",
|
|
||||||
"load",
|
|
||||||
]
|
|
||||||
for name, phase in panel.pending_phases().items():
|
for name, phase in panel.pending_phases().items():
|
||||||
button = panel._pending_buttons[name]
|
button = panel._pending_buttons[name]
|
||||||
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
||||||
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
|||||||
assert button.text(), f"{name} has no label"
|
assert button.text(), f"{name} has no label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_and_export_are_live_controls(make_window):
|
||||||
|
"""A sequence is a document, and P8 says it is a versioned artifact."""
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
for name in ("export", "import"):
|
||||||
|
button = panel._live_buttons[name]
|
||||||
|
assert button.isEnabled(), f"{name} is not usable"
|
||||||
|
assert button.text(), f"{name} has no label"
|
||||||
|
assert button.toolTip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_point_list_has_no_empty_action_column(make_window):
|
||||||
|
"""An empty action cell reads as a broken control."""
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
assert "actions" not in panel.column_keys()
|
||||||
|
|
||||||
|
|
||||||
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
||||||
"""A runner must not be able to start from points nothing has validated."""
|
"""A runner must not be able to start from points nothing has validated."""
|
||||||
window = _show(make_window())
|
window = _show(make_window())
|
||||||
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
|
|||||||
assert "QStatusBar" in window.statusBar().styleSheet()
|
assert "QStatusBar" in window.statusBar().styleSheet()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sequence_summary_reports_count_and_span(make_window):
|
||||||
|
"""The two numbers an operator reasons with, before anything runs."""
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.set_points(
|
||||||
|
[
|
||||||
|
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||||
|
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
text = panel.summary_label.text()
|
||||||
|
assert "2" in text
|
||||||
|
# A nil Z span prints once, and the real X span prints both ends.
|
||||||
|
assert "0.000–10.000" in text
|
||||||
|
assert "12.400" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sequence_summary_updates_with_the_profile(make_window):
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
|
||||||
|
before = panel.summary_label.text()
|
||||||
|
panel.field("repeats").setText("10")
|
||||||
|
assert panel.summary_label.text() != before
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.set_points([])
|
||||||
|
assert panel.summary_label.text()
|
||||||
|
assert "0" not in panel.summary_label.text()[:2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
|
||||||
|
"""Import replaces the design, and there is no autosave."""
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.mark_clean()
|
||||||
|
assert not panel.dirty
|
||||||
|
panel.field("pitch_mm").setText("9")
|
||||||
|
assert panel.dirty, "a profile edit did not mark the design dirty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_then_import_restores_the_design(make_window, tmp_dir):
|
||||||
|
"""The round trip through the real panel, not just the format module."""
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.set_name("bench sweep")
|
||||||
|
panel.field("pitch_mm").setText("3.5")
|
||||||
|
panel.set_points(
|
||||||
|
[
|
||||||
|
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||||
|
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
path = tmp_dir / "sweep.oryseq"
|
||||||
|
window.export_sequence(str(path))
|
||||||
|
assert path.is_file()
|
||||||
|
assert not panel.dirty, "export did not clear the dirty flag"
|
||||||
|
|
||||||
|
# Change everything, then import it back.
|
||||||
|
panel.set_name("")
|
||||||
|
panel.field("pitch_mm").setText("1")
|
||||||
|
panel.set_points([])
|
||||||
|
document = window.import_sequence(str(path))
|
||||||
|
|
||||||
|
assert document.point_count == 2
|
||||||
|
assert panel.name() == "bench sweep"
|
||||||
|
assert panel.profile()["pitch_mm"] == 3.5
|
||||||
|
assert panel.point_row_count() == 2
|
||||||
|
assert not panel.dirty
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
|
||||||
|
window = _show(make_window())
|
||||||
|
panel = window.sequence_panel
|
||||||
|
panel.field("pitch_mm").setText("4")
|
||||||
|
path = tmp_dir / "bad.oryseq"
|
||||||
|
path.write_text("not json at all", encoding="utf-8")
|
||||||
|
with pytest.raises(SequenceFormatError) as excinfo:
|
||||||
|
window.import_sequence(str(path))
|
||||||
|
assert "not valid JSON" in str(excinfo.value)
|
||||||
|
# The design is untouched: a refused import is not a partial import.
|
||||||
|
assert panel.profile()["pitch_mm"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
|
||||||
|
import json
|
||||||
|
|
||||||
|
window = _show(make_window())
|
||||||
|
window.prefs["sensor_setup"] = {
|
||||||
|
"sensor_type": "finger",
|
||||||
|
"geometry_id": "finger-1.0.0",
|
||||||
|
"probe_tip_set": True,
|
||||||
|
}
|
||||||
|
path = tmp_dir / "named.oryseq"
|
||||||
|
window.export_sequence(str(path))
|
||||||
|
document = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert document["device"]["sensor_type"] == "finger"
|
||||||
|
assert document["device"]["geometry_id"] == "finger-1.0.0"
|
||||||
|
assert document["versions"]["app"]
|
||||||
|
|
||||||
|
|
||||||
def test_language_switch_relabels_the_new_shell(make_window):
|
def test_language_switch_relabels_the_new_shell(make_window):
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""The calibration sequence document: round trip, refusals, derived summary.
|
||||||
|
|
||||||
|
The format is a contract with another machine's bench, so most of these tests
|
||||||
|
are about what happens when the file is *wrong* — a sequence half-applied is
|
||||||
|
worse than one rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_autocal.sequence_io import (
|
||||||
|
DOCUMENT_KIND,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
SequenceFormatError,
|
||||||
|
SequencePoint,
|
||||||
|
_version_tuple,
|
||||||
|
format_duration,
|
||||||
|
format_span,
|
||||||
|
from_document,
|
||||||
|
load,
|
||||||
|
save,
|
||||||
|
summarise,
|
||||||
|
to_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run
|
||||||
|
#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that
|
||||||
|
#: such an environment may not provide (the same reason ``conftest.make_window``
|
||||||
|
#: avoids it).
|
||||||
|
_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_dir():
|
||||||
|
shutil.rmtree(_SCRATCH, ignore_errors=True)
|
||||||
|
_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield _SCRATCH
|
||||||
|
shutil.rmtree(_SCRATCH, ignore_errors=True)
|
||||||
|
|
||||||
|
PROFILE = {
|
||||||
|
"mode": "flat",
|
||||||
|
"pitch_mm": 2.0,
|
||||||
|
"margin_mm": 1.0,
|
||||||
|
"radius_mm": 0.0,
|
||||||
|
"clear_height_mm": 5.0,
|
||||||
|
"approach_speed_mm_s": 2.0,
|
||||||
|
"press_speed_mm_s": 0.5,
|
||||||
|
"press_depth_mm": 0.2,
|
||||||
|
"dwell_s": 0.5,
|
||||||
|
"settle_window_s": 0.5,
|
||||||
|
"repeats": 3,
|
||||||
|
"order": "serpentine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _points(count: int = 6) -> list[SequencePoint]:
|
||||||
|
return [
|
||||||
|
SequencePoint(
|
||||||
|
id=index + 1,
|
||||||
|
target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4},
|
||||||
|
sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0},
|
||||||
|
clearance_mm=5.0,
|
||||||
|
)
|
||||||
|
for index in range(count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _document(**overrides) -> dict:
|
||||||
|
payload = to_document(
|
||||||
|
profile=PROFILE,
|
||||||
|
points=_points(),
|
||||||
|
name="sweep",
|
||||||
|
units_per_mm=10.0,
|
||||||
|
app_version="0.1.0",
|
||||||
|
shared_version="0.1.0",
|
||||||
|
shared_api="0.1",
|
||||||
|
)
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ round trip
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_document_round_trips_through_a_file(tmp_dir):
|
||||||
|
path = tmp_dir / "sweep.oryseq"
|
||||||
|
save(_document(), path)
|
||||||
|
back = load(path, current_profile=PROFILE)
|
||||||
|
assert back.point_count == 6
|
||||||
|
assert back.name == "sweep"
|
||||||
|
assert back.profile["pitch_mm"] == 2.0
|
||||||
|
assert back.units_per_mm == 10.0
|
||||||
|
assert not back.warnings
|
||||||
|
# Both frames survive: the platform target and the sensor position.
|
||||||
|
assert back.points[0].target_mm["z"] == pytest.approx(12.4)
|
||||||
|
assert back.points[0].sensor_mm is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir):
|
||||||
|
path = tmp_dir / "sweep.oryseq"
|
||||||
|
save(_document(), path)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
assert text.endswith("\n")
|
||||||
|
assert "\n " in text, "the document is not indented"
|
||||||
|
assert "\\u" not in text, "non-ASCII was escaped"
|
||||||
|
payload = json.loads(text)
|
||||||
|
assert payload["kind"] == DOCUMENT_KIND
|
||||||
|
assert payload["format_version"] == FORMAT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_leaves_no_temporary_file_behind(tmp_dir):
|
||||||
|
path = tmp_dir / "sweep.oryseq"
|
||||||
|
save(_document(), path)
|
||||||
|
assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_over_an_existing_file_replaces_it(tmp_dir):
|
||||||
|
path = tmp_dir / "sweep.oryseq"
|
||||||
|
save(_document(), path)
|
||||||
|
save(_document(name="second"), path)
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second"
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- refusals
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("case", "mutate", "expected"),
|
||||||
|
[
|
||||||
|
("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"),
|
||||||
|
("missing version", lambda d: d.pop("format_version"), "format_version"),
|
||||||
|
("version not a string", lambda d: d.update(format_version=1.0), "format_version"),
|
||||||
|
("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"),
|
||||||
|
("missing frame", lambda d: d.pop("frame"), "frame"),
|
||||||
|
("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"),
|
||||||
|
("missing profile", lambda d: d.pop("profile"), "profile"),
|
||||||
|
("points not a list", lambda d: d.update(points={}), "must be a list"),
|
||||||
|
("point not an object", lambda d: d.update(points=["nope"]), "not an object"),
|
||||||
|
("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"),
|
||||||
|
("point without target", lambda d: d.update(points=[{}]), "no target_mm"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected):
|
||||||
|
document = _document()
|
||||||
|
mutate(document)
|
||||||
|
with pytest.raises(SequenceFormatError) as excinfo:
|
||||||
|
from_document(document, current_profile=PROFILE)
|
||||||
|
assert expected in str(excinfo.value), case
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_json_file_is_refused_with_its_position(tmp_dir):
|
||||||
|
path = tmp_dir / "broken.oryseq"
|
||||||
|
path.write_text("this is not json", encoding="utf-8")
|
||||||
|
with pytest.raises(SequenceFormatError) as excinfo:
|
||||||
|
load(path, current_profile=PROFILE)
|
||||||
|
assert "not valid JSON" in str(excinfo.value)
|
||||||
|
assert "line 1" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_file_is_refused_not_crashed(tmp_dir):
|
||||||
|
with pytest.raises(SequenceFormatError):
|
||||||
|
load(tmp_dir / "absent.oryseq", current_profile=PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_json_array_is_refused(tmp_dir):
|
||||||
|
path = tmp_dir / "array.oryseq"
|
||||||
|
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||||
|
with pytest.raises(SequenceFormatError) as excinfo:
|
||||||
|
load(path, current_profile=PROFILE)
|
||||||
|
assert "top level" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_summary_reports_count_and_travel_span():
|
||||||
|
summary = summarise(_points(6), PROFILE)
|
||||||
|
assert summary["count"] == 6
|
||||||
|
assert summary["min_mm"]["x"] == 0.0
|
||||||
|
assert summary["max_mm"]["x"] == 10.0
|
||||||
|
assert summary["span_mm"]["x"] == pytest.approx(10.0)
|
||||||
|
# A constant coordinate has a nil span, and says so.
|
||||||
|
assert summary["span_mm"]["z"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_summary_is_recomputed_and_wins_over_the_file():
|
||||||
|
"""A hand-edited summary must not be believed."""
|
||||||
|
document = _document()
|
||||||
|
document["summary"]["count"] = 999
|
||||||
|
document["summary"]["span_mm"]["x"] = 999.0
|
||||||
|
back = from_document(document, current_profile=PROFILE)
|
||||||
|
assert back.summary["count"] == 6
|
||||||
|
assert back.summary["span_mm"]["x"] == pytest.approx(10.0)
|
||||||
|
assert back.warnings and "disagrees" in back.warnings[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_agreeing_summary_produces_no_warning():
|
||||||
|
back = from_document(_document(), current_profile=PROFILE)
|
||||||
|
assert not any("disagrees" in warning for warning in back.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_points_imports_the_profile_and_says_so():
|
||||||
|
document = _document()
|
||||||
|
document.pop("points")
|
||||||
|
back = from_document(document, current_profile=PROFILE)
|
||||||
|
assert back.point_count == 0
|
||||||
|
assert any("no points" in warning for warning in back.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_profile_key_keeps_the_current_value():
|
||||||
|
document = _document()
|
||||||
|
document["profile"].pop("pitch_mm")
|
||||||
|
back = from_document(document, current_profile=PROFILE)
|
||||||
|
assert back.profile["pitch_mm"] == 2.0
|
||||||
|
assert any("pitch_mm" in warning for warning in back.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_disabled_point_is_counted_separately():
|
||||||
|
points = _points(4)
|
||||||
|
points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False)
|
||||||
|
summary = summarise(points, PROFILE)
|
||||||
|
assert summary["count"] == 4
|
||||||
|
assert summary["enabled"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- duration
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_duration_estimate_scales_with_repeats_and_points():
|
||||||
|
one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"]
|
||||||
|
three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"]
|
||||||
|
assert three > one
|
||||||
|
six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"]
|
||||||
|
assert six > three
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_points_means_no_estimate():
|
||||||
|
summary = summarise([], PROFILE)
|
||||||
|
assert summary["estimated_duration_s"] == 0.0
|
||||||
|
assert format_duration(0.0) == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_duration_is_formatted_as_a_bench_timer():
|
||||||
|
assert format_duration(72.0) == "1:12"
|
||||||
|
assert format_duration(3600.0) == "1:00:00"
|
||||||
|
assert format_duration(None) == "—"
|
||||||
|
assert format_duration(float("nan")) == "—"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- spans
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zero_span_prints_once_not_twice():
|
||||||
|
assert format_span(12.4, 12.4) == "12.400"
|
||||||
|
assert format_span(0.0, 0.0) == "0.000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_span_shows_both_ends_and_the_length():
|
||||||
|
assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_span_is_a_dash():
|
||||||
|
assert format_span(None, None) == "—"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- version
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_compare_as_tuples_not_strings():
|
||||||
|
"""``"1.10" < "1.9"`` as strings would load a newer file as if supported."""
|
||||||
|
assert _version_tuple("1.10") > _version_tuple("1.9")
|
||||||
|
assert _version_tuple("1.0") == (1, 0)
|
||||||
|
assert _version_tuple("1") == (1,)
|
||||||
|
# A same-major newer minor is still refused, but is *recognised* as newer.
|
||||||
|
document = _document()
|
||||||
|
document["format_version"] = "1.10"
|
||||||
|
with pytest.raises(SequenceFormatError):
|
||||||
|
from_document(document, current_profile=PROFILE)
|
||||||
@@ -77,8 +77,8 @@ STRINGS = {
|
|||||||
"stop_all": "全部停止",
|
"stop_all": "全部停止",
|
||||||
"run_title": "运行",
|
"run_title": "运行",
|
||||||
"run_phase": "空闲",
|
"run_phase": "空闲",
|
||||||
"run_step": "第 0/0 点",
|
"run_idle_hint": "尚未生成标定点",
|
||||||
"run_time": "已用 0:00 · 预计剩余 —",
|
|
||||||
"run_run": "运行",
|
"run_run": "运行",
|
||||||
"run_pause": "暂停",
|
"run_pause": "暂停",
|
||||||
"run_abort": "中止",
|
"run_abort": "中止",
|
||||||
@@ -129,6 +129,9 @@ STRINGS = {
|
|||||||
"seq_preview": "预览",
|
"seq_preview": "预览",
|
||||||
"seq_validation": "校验",
|
"seq_validation": "校验",
|
||||||
"seq_points": "标定点列表",
|
"seq_points": "标定点列表",
|
||||||
|
"seq_name": "序列名称",
|
||||||
|
"seq_note": "备注",
|
||||||
|
"seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)",
|
||||||
"seq_fields": [
|
"seq_fields": [
|
||||||
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
|
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
|
||||||
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
|
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
|
||||||
@@ -140,12 +143,12 @@ STRINGS = {
|
|||||||
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
|
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
|
||||||
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
|
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
|
||||||
"seq_checks_pending": "尚未生成标定点。",
|
"seq_checks_pending": "尚未生成标定点。",
|
||||||
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)],
|
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
|
||||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"],
|
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
|
||||||
"point_rows": [
|
"point_rows": [
|
||||||
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""],
|
["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
|
||||||
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""],
|
["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
|
||||||
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""],
|
["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
|
||||||
],
|
],
|
||||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
||||||
"sensor_note": "设备与信号源、实时图像、力曲线",
|
"sensor_note": "设备与信号源、实时图像、力曲线",
|
||||||
@@ -169,8 +172,8 @@ STRINGS = {
|
|||||||
"stop_all": "Stop all",
|
"stop_all": "Stop all",
|
||||||
"run_title": "Run",
|
"run_title": "Run",
|
||||||
"run_phase": "Idle",
|
"run_phase": "Idle",
|
||||||
"run_step": "Point 0/0",
|
"run_idle_hint": "No points generated",
|
||||||
"run_time": "Elapsed 0:00 · about — left",
|
|
||||||
"run_run": "Run",
|
"run_run": "Run",
|
||||||
"run_pause": "Pause",
|
"run_pause": "Pause",
|
||||||
"run_abort": "Abort",
|
"run_abort": "Abort",
|
||||||
@@ -221,6 +224,9 @@ STRINGS = {
|
|||||||
"seq_preview": "Preview",
|
"seq_preview": "Preview",
|
||||||
"seq_validation": "Checks",
|
"seq_validation": "Checks",
|
||||||
"seq_points": "Calibration points",
|
"seq_points": "Calibration points",
|
||||||
|
"seq_name": "Sequence name",
|
||||||
|
"seq_note": "Note",
|
||||||
|
"seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)",
|
||||||
"seq_fields": [
|
"seq_fields": [
|
||||||
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
|
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
|
||||||
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
|
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
|
||||||
@@ -232,12 +238,12 @@ STRINGS = {
|
|||||||
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
|
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
|
||||||
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
|
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
|
||||||
"seq_checks_pending": "No points generated yet.",
|
"seq_checks_pending": "No points generated yet.",
|
||||||
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)],
|
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)],
|
||||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"],
|
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"],
|
||||||
"point_rows": [
|
"point_rows": [
|
||||||
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||||
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
["2", "2.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||||
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
["3", "4.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||||
],
|
],
|
||||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
|
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
|
||||||
"sensor_note": "Device and source, live image, force curves",
|
"sensor_note": "Device and source, live image, force curves",
|
||||||
@@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
|||||||
profile = QRect(inner.x(), inner.y(), profile_w, top_h)
|
profile = QRect(inner.x(), inner.y(), profile_w, top_h)
|
||||||
p_inner = _panel(painter, profile, palette)
|
p_inner = _panel(painter, profile, palette)
|
||||||
_box_title(painter, profile, s["seq_profile"], palette)
|
_box_title(painter, profile, s["seq_profile"], palette)
|
||||||
|
# the name and note the document carries
|
||||||
|
y = p_inner.y() + 4
|
||||||
|
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True)
|
||||||
|
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette)
|
||||||
|
y += 26
|
||||||
|
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True)
|
||||||
|
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette)
|
||||||
|
y += 28
|
||||||
|
|
||||||
column_w = p_inner.width() // 2
|
column_w = p_inner.width() // 2
|
||||||
for index, (label, value) in enumerate(s["seq_fields"]):
|
for index, (label, value) in enumerate(s["seq_fields"]):
|
||||||
row, side = divmod(index, 2)
|
row, side = divmod(index, 2)
|
||||||
x = p_inner.x() + side * column_w
|
x = p_inner.x() + side * column_w
|
||||||
y = p_inner.y() + 20 + row * 26
|
row_y = y + row * 26
|
||||||
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True)
|
_text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
|
||||||
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette)
|
_field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
|
||||||
|
|
||||||
# --- preview
|
# --- preview
|
||||||
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
|
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
|
||||||
@@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
|||||||
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
|
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
|
||||||
t_inner = _panel(painter, points, palette)
|
t_inner = _panel(painter, points, palette)
|
||||||
_box_title(painter, points, s["seq_points"], palette)
|
_box_title(painter, points, s["seq_points"], palette)
|
||||||
widths = (50, 150, 150, 150, 130, 190, 100)
|
# Seven columns: no empty actions column, and the summary line carries the
|
||||||
|
# count and span the operator reasons with.
|
||||||
|
widths = (60, 150, 150, 150, 140, 190, 110)
|
||||||
widths = widths + (t_inner.width() - sum(widths),)
|
widths = widths + (t_inner.width() - sum(widths),)
|
||||||
header_h = 28
|
header_h = 28
|
||||||
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
|
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
|
||||||
@@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
|||||||
for value, width in zip(row, widths):
|
for value, width in zip(row, widths):
|
||||||
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
|
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
|
||||||
x += width
|
x += width
|
||||||
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10
|
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8
|
||||||
|
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10)
|
||||||
|
y += 24
|
||||||
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
|
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None
|
|||||||
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
|
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
|
||||||
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
|
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
|
||||||
x += 96
|
x += 96
|
||||||
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10)
|
_text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True)
|
||||||
x += 158
|
|
||||||
|
|
||||||
bar = QRect(x, top + 14, 300, 12)
|
|
||||||
painter.fillRect(bar, palette.border)
|
|
||||||
painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent)
|
|
||||||
_text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True)
|
|
||||||
|
|
||||||
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
|
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
|
||||||
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
|
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
|
|||||||
python apps/autocal/tools/panel_check.py
|
python apps/autocal/tools/panel_check.py
|
||||||
|
|
||||||
No controller, no DLL and no display are needed. Mirrors
|
No controller, no DLL and no display are needed. Mirrors
|
||||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
|
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
|
||||||
PyQt5, and AutoCal's environment has none.
|
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -218,8 +218,8 @@ def main() -> int:
|
|||||||
"the sequence point list is empty",
|
"the sequence point list is empty",
|
||||||
)
|
)
|
||||||
checker.that(
|
checker.that(
|
||||||
sequence.table.columnCount() == 8,
|
sequence.table.columnCount() == len(sequence.column_keys()),
|
||||||
f"the point list has {sequence.table.columnCount()} columns, expected 8",
|
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The shell: one rail, a run bar, and a health chip.
|
# The shell: one rail, a run bar, and a health chip.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Orisys-Tech" }]
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyQt5>=5.15",
|
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
|
||||||
|
# on PyQt5 (whose free option is GPLv3-or-commercial). See
|
||||||
|
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
|
||||||
|
"PySide6>=6.6",
|
||||||
"orisys-app-shared>=0.1.0",
|
"orisys-app-shared>=0.1.0",
|
||||||
"orisys>=0.5",
|
"orisys>=0.5",
|
||||||
"scipy>=1.10.1",
|
"scipy>=1.10.1",
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PyQt5.QtGui import QIcon, QImage
|
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAction,
|
|
||||||
QActionGroup,
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
root.addWidget(self.toolbar)
|
root.addWidget(self.toolbar)
|
||||||
self.status_chip = self.toolbar.status_chip
|
self.status_chip = self.toolbar.status_chip
|
||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Horizontal)
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
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)
|
||||||
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# convention everyone's hands already know.
|
# convention everyone's hands already know.
|
||||||
self.act_play = QAction(self)
|
self.act_play = QAction(self)
|
||||||
self.act_play.setShortcut("Space")
|
self.act_play.setShortcut("Space")
|
||||||
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
|
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
||||||
self.addAction(self.act_play)
|
self.addAction(self.act_play)
|
||||||
|
|
||||||
self.view_menu = menu_bar.addMenu("")
|
self.view_menu = menu_bar.addMenu("")
|
||||||
@@ -1402,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._persist_prefs()
|
self._persist_prefs()
|
||||||
|
|
||||||
def _show_about(self) -> None:
|
def _show_about(self) -> None:
|
||||||
AboutDialog(self).exec_()
|
AboutDialog(self).exec()
|
||||||
|
|
||||||
def _on_camera_id_changed(self, value: int) -> None:
|
def _on_camera_id_changed(self, value: int) -> None:
|
||||||
self.prefs["camera_id"] = str(int(value))
|
self.prefs["camera_id"] = str(int(value))
|
||||||
@@ -1507,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
filters_locked=bool(self._running or self.recorder.is_recording),
|
filters_locked=bool(self._running or self.recorder.is_recording),
|
||||||
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
||||||
)
|
)
|
||||||
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()
|
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
||||||
self.display_ctl.apply_plot_settings(
|
self.display_ctl.apply_plot_settings(
|
||||||
@@ -1589,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
sensor_type=sensor_type,
|
sensor_type=sensor_type,
|
||||||
config=config_ref,
|
config=config_ref,
|
||||||
),
|
),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.No,
|
QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
self.recorder.log_event(
|
self.recorder.log_event(
|
||||||
"calibration_apply_blocked", "; ".join(reasons)
|
"calibration_apply_blocked", "; ".join(reasons)
|
||||||
)
|
)
|
||||||
@@ -1602,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self,
|
self,
|
||||||
t("cal.apply_title"),
|
t("cal.apply_title"),
|
||||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.No,
|
QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
self.statusBar().showMessage(t("cal.apply_skipped"))
|
self.statusBar().showMessage(t("cal.apply_skipped"))
|
||||||
return
|
return
|
||||||
worker = self._worker_for(sensor_type)
|
worker = self._worker_for(sensor_type)
|
||||||
@@ -1720,7 +1718,18 @@ 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())
|
||||||
self.act_stop.setEnabled(self._session_active)
|
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
|
||||||
|
# session alone: a replay or a recompute pass is something to stop.
|
||||||
|
#
|
||||||
|
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
|
||||||
|
# did not) and never delivers the shortcut of a disabled action, so a Stop that
|
||||||
|
# is disabled while a replay is playing is a keyboard escape hatch that does not
|
||||||
|
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
|
||||||
|
self.act_stop.setEnabled(
|
||||||
|
self._session_active
|
||||||
|
or self.replay_ctl.is_playing()
|
||||||
|
or self._recompute_running
|
||||||
|
)
|
||||||
self.act_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())
|
||||||
|
|
||||||
@@ -1740,10 +1749,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
In the replay view it is the same escape hatch as the rail's 停止 button: a
|
In the replay view it is the same escape hatch as the rail's 停止 button: a
|
||||||
replay or a recompute pass that will not stop by itself has to be stoppable
|
replay or a recompute pass that will not stop by itself has to be stoppable
|
||||||
from the keyboard.
|
from the keyboard. The replay branch accepts the same three states
|
||||||
|
``_sync_shortcut_actions`` enables the action for, so the action can never be
|
||||||
|
enabled with nothing behind it.
|
||||||
"""
|
"""
|
||||||
if self.toolbar.run_role() == "playback":
|
if self.toolbar.run_role() == "playback":
|
||||||
if self.replay_ctl.is_playing() or self._session_active:
|
if (
|
||||||
|
self.replay_ctl.is_playing()
|
||||||
|
or self._session_active
|
||||||
|
or self._recompute_running
|
||||||
|
):
|
||||||
self.replay_ctl.stop_activity()
|
self.replay_ctl.stop_activity()
|
||||||
return
|
return
|
||||||
if not self._session_active:
|
if not self._session_active:
|
||||||
@@ -2348,7 +2363,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
geo = self.prefs.get("window_geometry")
|
geo = self.prefs.get("window_geometry")
|
||||||
if isinstance(geo, str) and geo:
|
if isinstance(geo, str) and geo:
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QByteArray
|
from PySide6.QtCore import QByteArray
|
||||||
|
|
||||||
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
# pyqtgraph picks its toolkit from whichever binding is already imported, and in a
|
||||||
|
# development environment that also has PyQt5 installed (``orisys[gui]``) it can pick the
|
||||||
|
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
|
||||||
|
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||||
from orisys_app_shared.compat import check_shared_compat
|
from orisys_app_shared.compat import check_shared_compat
|
||||||
@@ -85,7 +90,7 @@ def main() -> int:
|
|||||||
language,
|
language,
|
||||||
gl_profile,
|
gl_profile,
|
||||||
)
|
)
|
||||||
code = int(app.exec_())
|
code = int(app.exec())
|
||||||
|
|
||||||
# Leave the process immediately.
|
# Leave the process immediately.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal
|
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
|
||||||
@@ -47,13 +47,13 @@ class ReplayEngine(QObject):
|
|||||||
"""Open one recording and answer "what is at t?" for every view."""
|
"""Open one recording and answer "what is at t?" for every view."""
|
||||||
|
|
||||||
#: The cursor moved; the chart cursor and the 2D panel follow.
|
#: The cursor moved; the chart cursor and the 2D panel follow.
|
||||||
position_changed = pyqtSignal(float)
|
position_changed = Signal(float)
|
||||||
#: The frame under the cursor changed.
|
#: The frame under the cursor changed.
|
||||||
frames_changed = pyqtSignal()
|
frames_changed = Signal()
|
||||||
#: The notes about this recording, or the loaded recording, changed.
|
#: The notes about this recording, or the loaded recording, changed.
|
||||||
status_changed = pyqtSignal()
|
status_changed = Signal()
|
||||||
#: A recording was opened (True) or the previous one was dropped (False).
|
#: A recording was opened (True) or the previous one was dropped (False).
|
||||||
recording_changed = pyqtSignal(bool)
|
recording_changed = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import QObject, QTimer
|
from PySide6.QtCore import QObject, QTimer
|
||||||
from PyQt5.QtWidgets import QFileDialog
|
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
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ 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, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal
|
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 (
|
||||||
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
|
|||||||
selection the operator has already moved away from.
|
selection the operator has already moved away from.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
|
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||||
failed = pyqtSignal(object, int) # Diagnostic, request id
|
failed = Signal(object, int) # Diagnostic, request id
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|||||||
@@ -72,21 +72,21 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
|
|||||||
component must not slow down (the acquisition loop feeds the scene through a
|
component must not slow down (the acquisition loop feeds the scene through a
|
||||||
capacity-one mailbox), and the design document defers anti-aliasing work.
|
capacity-one mailbox), and the design document defers anti-aliasing work.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
fmt = QSurfaceFormat()
|
fmt = QSurfaceFormat()
|
||||||
if profile == "gles30":
|
if profile == "gles30":
|
||||||
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
|
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
|
||||||
fmt.setVersion(3, 0)
|
fmt.setVersion(3, 0)
|
||||||
elif profile == "desktop33":
|
elif profile == "desktop33":
|
||||||
fmt.setRenderableType(QSurfaceFormat.OpenGL)
|
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||||||
fmt.setVersion(3, 3)
|
fmt.setVersion(3, 3)
|
||||||
fmt.setProfile(QSurfaceFormat.CoreProfile)
|
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
|
||||||
else: # pragma: no cover - a typo must not reach a board silently
|
else: # pragma: no cover - a typo must not reach a board silently
|
||||||
raise ValueError(f"unknown GL profile: {profile!r}")
|
raise ValueError(f"unknown GL profile: {profile!r}")
|
||||||
fmt.setDepthBufferSize(24)
|
fmt.setDepthBufferSize(24)
|
||||||
fmt.setStencilBufferSize(8)
|
fmt.setStencilBufferSize(8)
|
||||||
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
|
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||||
fmt.setSamples(0)
|
fmt.setSamples(0)
|
||||||
QSurfaceFormat.setDefaultFormat(fmt)
|
QSurfaceFormat.setDefaultFormat(fmt)
|
||||||
|
|
||||||
@@ -442,7 +442,10 @@ class GLDispatch:
|
|||||||
if context is None or not context.isValid():
|
if context is None or not context.isValid():
|
||||||
raise error("gl_unsupported", "no current OpenGL context")
|
raise error("gl_unsupported", "no current OpenGL context")
|
||||||
fmt = context.format()
|
fmt = context.format()
|
||||||
profile = profile_from_renderable_type(int(fmt.renderableType()))
|
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises TypeError, so read
|
||||||
|
# `.value`. (PyQt5 exposed them as ints. Getting this wrong made every GL probe raise,
|
||||||
|
# the 3D pane report "unsupported", and the GL smoke tests skip instead of running.)
|
||||||
|
profile = profile_from_renderable_type(int(fmt.renderableType().value))
|
||||||
try:
|
try:
|
||||||
vendor = _gl_string(GL, GL.GL_VENDOR)
|
vendor = _gl_string(GL, GL.GL_VENDOR)
|
||||||
renderer = _gl_string(GL, GL.GL_RENDERER)
|
renderer = _gl_string(GL, GL.GL_RENDERER)
|
||||||
@@ -454,7 +457,7 @@ class GLDispatch:
|
|||||||
"gl_unsupported",
|
"gl_unsupported",
|
||||||
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
from PyQt5.QtGui import QGuiApplication
|
from PySide6.QtGui import QGuiApplication
|
||||||
|
|
||||||
platform_name = ""
|
platform_name = ""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QImage
|
from PySide6.QtGui import QImage
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
|
|||||||
self.setObjectName("colorScaleBar")
|
self.setObjectName("colorScaleBar")
|
||||||
self.setFixedHeight(12)
|
self.setFixedHeight(12)
|
||||||
self.setMinimumWidth(120)
|
self.setMinimumWidth(120)
|
||||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
self._lut_id = "sequential"
|
self._lut_id = "sequential"
|
||||||
|
|
||||||
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
from PyQt5.QtGui import QPainter
|
from PySide6.QtGui import QPainter
|
||||||
|
|
||||||
from orisys_studio.scene3d.gl_backend import lut_rgba
|
from orisys_studio.scene3d.gl_backend import lut_rgba
|
||||||
|
|
||||||
# (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)[:, 0, :]).tobytes()
|
||||||
image = QImage(rgba, 256, 1, 256 * 4, QImage.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)
|
||||||
painter.end()
|
painter.end()
|
||||||
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
|
|||||||
class ThreeDPanel(QFrame):
|
class ThreeDPanel(QFrame):
|
||||||
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
||||||
|
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
settingsChanged = pyqtSignal(object) # DisplaySettings
|
settingsChanged = Signal(object) # DisplaySettings
|
||||||
resetRequested = pyqtSignal()
|
resetRequested = Signal()
|
||||||
presetRequested = pyqtSignal(str)
|
presetRequested = Signal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -299,13 +299,13 @@ class ThreeDPanel(QFrame):
|
|||||||
scale_row.setSpacing(6)
|
scale_row.setSpacing(6)
|
||||||
self.scale_lower_label = QLabel()
|
self.scale_lower_label = QLabel()
|
||||||
self.scale_lower_label.setObjectName("panelMeta")
|
self.scale_lower_label.setObjectName("panelMeta")
|
||||||
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
self.scale_lower_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
scale_row.addWidget(self.scale_lower_label)
|
scale_row.addWidget(self.scale_lower_label)
|
||||||
self.scale_bar = ColorScaleBar()
|
self.scale_bar = ColorScaleBar()
|
||||||
scale_row.addWidget(self.scale_bar, 1)
|
scale_row.addWidget(self.scale_bar, 1)
|
||||||
self.scale_upper_label = QLabel()
|
self.scale_upper_label = QLabel()
|
||||||
self.scale_upper_label.setObjectName("panelMeta")
|
self.scale_upper_label.setObjectName("panelMeta")
|
||||||
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
self.scale_upper_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
scale_row.addWidget(self.scale_upper_label)
|
scale_row.addWidget(self.scale_upper_label)
|
||||||
root.addLayout(scale_row)
|
root.addLayout(scale_row)
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ class ThreeDPanel(QFrame):
|
|||||||
self.source_label = ShrinkableLabel()
|
self.source_label = ShrinkableLabel()
|
||||||
self.source_label.setObjectName("panelMeta")
|
self.source_label.setObjectName("panelMeta")
|
||||||
self.source_label.setWordWrap(False)
|
self.source_label.setWordWrap(False)
|
||||||
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
legend_row.addWidget(self.source_label, 2)
|
legend_row.addWidget(self.source_label, 2)
|
||||||
root.addLayout(legend_row)
|
root.addLayout(legend_row)
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
root.addWidget(panel.controls_widget)
|
root.addWidget(panel.controls_widget)
|
||||||
panel.controls_widget.setVisible(True)
|
panel.controls_widget.setVisible(True)
|
||||||
|
|
||||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
self.buttons.rejected.connect(self.close)
|
self.buttons.rejected.connect(self.close)
|
||||||
self.buttons.accepted.connect(self.close)
|
self.buttons.accepted.connect(self.close)
|
||||||
root.addWidget(self.buttons)
|
root.addWidget(self.buttons)
|
||||||
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
self.hint.setText(t("scene3d.settings_hint"))
|
self.hint.setText(t("scene3d.settings_hint"))
|
||||||
# Qt ships its own translations for a button box, and the application installs
|
# Qt ships its own translations for a button box, and the application installs
|
||||||
# none: without this the dialog ends in an English "Close" among Chinese labels.
|
# none: without this the dialog ends in an English "Close" among Chinese labels.
|
||||||
close_button = self.buttons.button(QDialogButtonBox.Close)
|
close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close)
|
||||||
if close_button is not None:
|
if close_button is not None:
|
||||||
close_button.setText(t("dialog.close"))
|
close_button.setText(t("dialog.close"))
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
|
|
||||||
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
"""Esc closes the dialog, as it does everywhere else in this application."""
|
"""Esc closes the dialog, as it does everywhere else in this application."""
|
||||||
if event.key() == Qt.Key_Escape:
|
if event.key() == Qt.Key.Key_Escape:
|
||||||
self.hide()
|
self.hide()
|
||||||
return
|
return
|
||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import logging
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QVBoxLayout, QWidget
|
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 (
|
||||||
@@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]:
|
|||||||
pane on a machine where PyOpenGL is not installed.
|
pane on a machine where PyOpenGL is not installed.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return False, f"PyQt5 OpenGL widgets are unavailable: {exc}"
|
return False, f"PySide6 OpenGL widgets are unavailable: {exc}"
|
||||||
try:
|
try:
|
||||||
import OpenGL # noqa: F401
|
import OpenGL # noqa: F401
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
|
|||||||
class Scene3DView(QWidget):
|
class Scene3DView(QWidget):
|
||||||
"""One 3D pane: the GL widget on top, its controls underneath."""
|
"""One 3D pane: the GL widget on top, its controls underneath."""
|
||||||
|
|
||||||
stateChanged = pyqtSignal(object) # SceneState
|
stateChanged = Signal(object) # SceneState
|
||||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
diagnosticRaised = Signal(object) # Diagnostic
|
||||||
renderStatsChanged = pyqtSignal(object) # RenderStats
|
renderStatsChanged = Signal(object) # RenderStats
|
||||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
|
from PySide6.QtCore import QEvent, Qt, Signal
|
||||||
from PyQt5.QtGui import QMouseEvent, QWheelEvent
|
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
# 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,
|
||||||
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
|
|||||||
class Sensor3DWidget(QOpenGLWidget):
|
class Sensor3DWidget(QOpenGLWidget):
|
||||||
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
||||||
|
|
||||||
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
|
capabilitiesChanged = Signal(object) # GLCapabilities
|
||||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
diagnosticRaised = Signal(object) # Diagnostic
|
||||||
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
|
renderStatsChanged = Signal(object) # RenderStats, rate-limited
|
||||||
#: What the frame on screen claims to be — quantity, unit, quality. The panel
|
#: What the frame on screen claims to be — quantity, unit, quality. The panel
|
||||||
#: labels the colour bar from this and nothing else, so an unmeasured surface
|
#: labels the colour bar from this and nothing else, so an unmeasured surface
|
||||||
#: cannot inherit the previous frame's range.
|
#: cannot inherit the previous frame's range.
|
||||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
When the platform cannot create a GL context (the offscreen plugin, a broken
|
When the platform cannot create a GL context (the offscreen plugin, a broken
|
||||||
driver, a board without EGL) ``initializeGL`` is simply never called: there is
|
driver, a board without EGL) ``initializeGL`` is simply never called: there is
|
||||||
no exception to catch, and the widget would sit there looking like a working
|
no exception to catch, and the widget would sit there looking like a working
|
||||||
view that has no data yet. Qt does answer the question, though — ``context()``
|
view that has no data yet. Qt does answer the question, though, so the first
|
||||||
stays ``None`` — so the first paint asks it and turns "no context" into the
|
paint asks it and turns "no context" into the state the rest of the application
|
||||||
state the rest of the application already knows how to show.
|
already knows how to show.
|
||||||
|
|
||||||
|
Under Qt5 that answer was ``context() is None``. Qt6 returns an *invalid*
|
||||||
|
context object instead, so the check has to be ``isValid()`` as well — comparing
|
||||||
|
against ``None`` alone silently stops reporting a broken GL stack.
|
||||||
"""
|
"""
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
if self._unavailable or self._capabilities is not None:
|
if self._unavailable or self._capabilities is not None:
|
||||||
return
|
return
|
||||||
if self.context() is None:
|
context = self.context()
|
||||||
|
if context is None or not context.isValid():
|
||||||
self._fail(
|
self._fail(
|
||||||
diagnostic(
|
diagnostic(
|
||||||
"gl_unsupported",
|
"gl_unsupported",
|
||||||
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
|
|
||||||
# --------------------------------------------------------------- interaction
|
# --------------------------------------------------------------- interaction
|
||||||
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
self._last_press = (float(event.x()), float(event.y()))
|
self._last_press = (float(event.position().x()), float(event.position().y()))
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
if self._last_press is None:
|
if self._last_press is None:
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
return
|
return
|
||||||
x, y = float(event.x()), float(event.y())
|
x, y = float(event.position().x()), float(event.position().y())
|
||||||
delta = (x - self._last_press[0], y - self._last_press[1])
|
delta = (x - self._last_press[0], y - self._last_press[1])
|
||||||
self._last_press = (x, y)
|
self._last_press = (x, y)
|
||||||
viewport = (max(self.width(), 1), max(self.height(), 1))
|
viewport = (max(self.width(), 1), max(self.height(), 1))
|
||||||
buttons = event.buttons()
|
buttons = event.buttons()
|
||||||
panning = bool(buttons & Qt.MiddleButton) or bool(
|
panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
|
||||||
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
|
buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
||||||
)
|
)
|
||||||
if panning:
|
if panning:
|
||||||
self._camera.pan(delta, viewport)
|
self._camera.pan(delta, viewport)
|
||||||
elif buttons & Qt.LeftButton:
|
elif buttons & Qt.MouseButton.LeftButton:
|
||||||
self._camera.orbit(delta, viewport)
|
self._camera.orbit(delta, viewport)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
|
from PySide6.QtCore import QObject, QTimer, Signal
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_studio import session_metadata
|
from orisys_studio import session_metadata
|
||||||
@@ -47,11 +47,11 @@ class SessionController(QObject):
|
|||||||
"""Owns the acquisition session's state and its UI-free mechanics."""
|
"""Owns the acquisition session's state and its UI-free mechanics."""
|
||||||
|
|
||||||
#: The run timer fired: the window's presenter slot does the painting.
|
#: The run timer fired: the window's presenter slot does the painting.
|
||||||
tick = pyqtSignal()
|
tick = Signal()
|
||||||
#: The phase was (re)applied, not only changed: the status chip repaints from it,
|
#: The phase was (re)applied, not only changed: the status chip repaints from it,
|
||||||
#: and an idempotent repaint is cheaper than a second source of truth.
|
#: and an idempotent repaint is cheaper than a second source of truth.
|
||||||
phaseChanged = pyqtSignal(str)
|
phaseChanged = Signal(str)
|
||||||
runningChanged = pyqtSignal(bool)
|
runningChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
from PyQt5.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.i18n import t
|
||||||
from orisys_app_shared.paths import logo_topbar_path
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
|
|||||||
logo = QLabel()
|
logo = QLabel()
|
||||||
pix = QPixmap(str(logo_topbar_path()))
|
pix = QPixmap(str(logo_topbar_path()))
|
||||||
if not pix.isNull():
|
if not pix.isNull():
|
||||||
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
|
||||||
row.addWidget(logo)
|
row.addWidget(logo)
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
root.addLayout(row)
|
root.addLayout(row)
|
||||||
|
|
||||||
self.body = QLabel()
|
self.body = QLabel()
|
||||||
self.body.setWordWrap(True)
|
self.body.setWordWrap(True)
|
||||||
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
root.addWidget(self.body)
|
root.addWidget(self.body)
|
||||||
|
|
||||||
close_btn = QPushButton("OK")
|
close_btn = QPushButton("OK")
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
from PySide6.QtCore import QSize, Qt, Signal
|
||||||
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
|
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
|
|||||||
"""
|
"""
|
||||||
rgb = frame[:, :, ::-1].copy()
|
rgb = frame[:, :, ::-1].copy()
|
||||||
height, width, channels = rgb.shape
|
height, width, channels = rgb.shape
|
||||||
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
|
return QImage(rgb.data, width, height, channels * width, QImage.Format.Format_RGB888).copy()
|
||||||
|
|
||||||
|
|
||||||
_VIEW_LABELS = {
|
_VIEW_LABELS = {
|
||||||
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
|
|||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._aspect = DEFAULT_FRAME_ASPECT
|
self._aspect = DEFAULT_FRAME_ASPECT
|
||||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
policy.setHeightForWidth(True)
|
policy.setHeightForWidth(True)
|
||||||
self.setSizePolicy(policy)
|
self.setSizePolicy(policy)
|
||||||
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
||||||
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
|
|||||||
self.setObjectName("colorBar")
|
self.setObjectName("colorBar")
|
||||||
self.setFixedHeight(_COLORBAR_HEIGHT)
|
self.setFixedHeight(_COLORBAR_HEIGHT)
|
||||||
self.setMinimumWidth(_COLORBAR_WIDTH)
|
self.setMinimumWidth(_COLORBAR_WIDTH)
|
||||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
def set_theme(self, theme: str) -> None:
|
||||||
@@ -145,14 +145,14 @@ class ColorBarLabel(QLabel):
|
|||||||
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
|
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
|
||||||
# the whole call, which is what QImage's borrowed buffer needs.
|
# the whole call, which is what QImage's borrowed buffer needs.
|
||||||
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
|
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
|
||||||
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888)
|
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format.Format_RGB888)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.drawImage(self.rect(), image)
|
painter.drawImage(self.rect(), image)
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
class ArrowPanel(QFrame):
|
class ArrowPanel(QFrame):
|
||||||
view_mode_changed = pyqtSignal(str)
|
view_mode_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
|
|||||||
layout.addLayout(header)
|
layout.addLayout(header)
|
||||||
|
|
||||||
self.image_label = AspectImageLabel()
|
self.image_label = AspectImageLabel()
|
||||||
self.image_label.setAlignment(Qt.AlignCenter)
|
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.image_label.setScaledContents(False)
|
self.image_label.setScaledContents(False)
|
||||||
self.image_label.setWordWrap(True)
|
self.image_label.setWordWrap(True)
|
||||||
# The image keeps the frame's shape and is centred in whatever height is
|
# The image keeps the frame's shape and is centred in whatever height is
|
||||||
@@ -515,11 +515,11 @@ class ArrowPanel(QFrame):
|
|||||||
deg = self._effective_rotation_deg()
|
deg = self._effective_rotation_deg()
|
||||||
if deg:
|
if deg:
|
||||||
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
|
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
|
||||||
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
|
pix = pix.transformed(QTransform().rotate(-deg), Qt.TransformationMode.FastTransformation)
|
||||||
scaled = pix.scaled(
|
scaled = pix.scaled(
|
||||||
self.image_label.size(),
|
self.image_label.size(),
|
||||||
Qt.KeepAspectRatio,
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
Qt.SmoothTransformation,
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
)
|
)
|
||||||
self.image_label.setPixmap(scaled)
|
self.image_label.setPixmap(scaled)
|
||||||
self.image_label.setText("")
|
self.image_label.setText("")
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
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
|
||||||
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
|
|||||||
if source.isNull():
|
if source.isNull():
|
||||||
return QPixmap()
|
return QPixmap()
|
||||||
scaled = source.scaled(
|
scaled = source.scaled(
|
||||||
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
|
||||||
)
|
)
|
||||||
out = QPixmap(scaled.size())
|
out = QPixmap(scaled.size())
|
||||||
out.fill(Qt.transparent)
|
out.fill(Qt.GlobalColor.transparent)
|
||||||
painter = QPainter(out)
|
painter = QPainter(out)
|
||||||
painter.drawPixmap(0, 0, scaled)
|
painter.drawPixmap(0, 0, scaled)
|
||||||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
|
||||||
painter.fillRect(out.rect(), QColor(tint))
|
painter.fillRect(out.rect(), QColor(tint))
|
||||||
painter.end()
|
painter.end()
|
||||||
return out
|
return out
|
||||||
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
|
|||||||
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
||||||
|
|
||||||
#: The user asked for the live view.
|
#: The user asked for the live view.
|
||||||
live_requested = pyqtSignal()
|
live_requested = Signal()
|
||||||
#: The user asked for the history/replay view.
|
#: The user asked for the history/replay view.
|
||||||
playback_requested = pyqtSignal()
|
playback_requested = Signal()
|
||||||
#: The user asked for the calibration view.
|
#: The user asked for the calibration view.
|
||||||
calibration_requested = pyqtSignal()
|
calibration_requested = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
|
|||||||
self.logo = QLabel()
|
self.logo = QLabel()
|
||||||
self.logo.setObjectName("brandLogo")
|
self.logo.setObjectName("brandLogo")
|
||||||
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
||||||
self.logo.setAlignment(Qt.AlignCenter)
|
self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(self.logo)
|
layout.addWidget(self.logo)
|
||||||
|
|
||||||
# The lockup keeps its two labels (the product's tagline stays available
|
# The lockup keeps its two labels (the product's tagline stays available
|
||||||
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
|
|||||||
|
|
||||||
self.product = QLabel()
|
self.product = QLabel()
|
||||||
self.product.setObjectName("brandProduct")
|
self.product.setObjectName("brandProduct")
|
||||||
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
layout.addWidget(self.product)
|
layout.addWidget(self.product)
|
||||||
|
|
||||||
self.set_theme(theme)
|
self.set_theme(theme)
|
||||||
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
|
|||||||
def _nav_button(self) -> QPushButton:
|
def _nav_button(self) -> QPushButton:
|
||||||
button = QPushButton()
|
button = QPushButton()
|
||||||
button.setObjectName("navButton")
|
button.setObjectName("navButton")
|
||||||
button.setCursor(Qt.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
button.setFlat(True)
|
button.setFlat(True)
|
||||||
button.setCheckable(True)
|
button.setCheckable(True)
|
||||||
return button
|
return button
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
@@ -60,11 +60,11 @@ def _mono_font() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class CalPanel(QFrame):
|
class CalPanel(QFrame):
|
||||||
status_message = pyqtSignal(str)
|
status_message = Signal(str)
|
||||||
#: Coefficients were written to disk. It does **not** mean they were applied.
|
#: Coefficients were written to disk. It does **not** mean they were applied.
|
||||||
calibration_exported = pyqtSignal(str)
|
calibration_exported = Signal(str)
|
||||||
#: The operator asked to push the last export into the running device.
|
#: The operator asked to push the last export into the running device.
|
||||||
apply_requested = pyqtSignal(str)
|
apply_requested = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
|
|||||||
outer.setSpacing(0)
|
outer.setSpacing(0)
|
||||||
self.scroll = QScrollArea()
|
self.scroll = QScrollArea()
|
||||||
self.scroll.setWidgetResizable(True)
|
self.scroll.setWidgetResizable(True)
|
||||||
self.scroll.setFrameShape(QFrame.NoFrame)
|
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.scroll.setWidget(content)
|
self.scroll.setWidget(content)
|
||||||
outer.addWidget(self.scroll)
|
outer.addWidget(self.scroll)
|
||||||
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
|
|||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setHorizontalSpacing(10)
|
form.setHorizontalSpacing(10)
|
||||||
form.setVerticalSpacing(6)
|
form.setVerticalSpacing(6)
|
||||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
self._weight_label = QLabel()
|
self._weight_label = QLabel()
|
||||||
self._weight_label.setObjectName("fieldLabel")
|
self._weight_label.setObjectName("fieldLabel")
|
||||||
self.weight_spin = QDoubleSpinBox()
|
self.weight_spin = QDoubleSpinBox()
|
||||||
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
|
|||||||
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
||||||
# stability measure and the point's state.
|
# stability measure and the point's state.
|
||||||
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
self.table.setAlternatingRowColors(True)
|
self.table.setAlternatingRowColors(True)
|
||||||
self.table.setShowGrid(False)
|
self.table.setShowGrid(False)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
# Every column takes the width its own header and cells need: eliding a
|
# Every column takes the width its own header and cells need: eliding a
|
||||||
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
||||||
# panel scrolls instead.
|
# panel scrolls instead.
|
||||||
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setMinimumSectionSize(64)
|
header.setMinimumSectionSize(64)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||||
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
|
|||||||
):
|
):
|
||||||
# The action row must survive a narrow three-panel layout.
|
# The action row must survive a narrow three-panel layout.
|
||||||
button.setProperty("compact", "true")
|
button.setProperty("compact", "true")
|
||||||
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
|
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
|
||||||
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
||||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||||
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
|
|||||||
)
|
)
|
||||||
for column, text in enumerate(values):
|
for column, text in enumerate(values):
|
||||||
item = QTableWidgetItem(text)
|
item = QTableWidgetItem(text)
|
||||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||||
if column in (0, 1, 2, 3):
|
if column in (0, 1, 2, 3):
|
||||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
|
||||||
else:
|
else:
|
||||||
item.setTextAlignment(int(Qt.AlignCenter))
|
item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
|
||||||
if point.excluded:
|
if point.excluded:
|
||||||
item.setForeground(QColor(color(self._theme, "text_muted")))
|
item.setForeground(QColor(color(self._theme, "text_muted")))
|
||||||
elif point.is_validation:
|
elif point.is_validation:
|
||||||
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
|
|||||||
"""Double click a row to inspect that point's three-axis curve."""
|
"""Double click a row to inspect that point's three-axis curve."""
|
||||||
dialog = self.point_dialog(self._current_point())
|
dialog = self.point_dialog(self._current_point())
|
||||||
if dialog is not None:
|
if dialog is not None:
|
||||||
dialog.exec_()
|
dialog.exec()
|
||||||
|
|
||||||
def _on_toggle_excluded(self) -> None:
|
def _on_toggle_excluded(self) -> None:
|
||||||
point = self._current_point()
|
point = self._current_point()
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ 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 PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtGui import QFont, QFontDatabase
|
from PySide6.QtGui import QFont, QFontDatabase
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
|
|||||||
root.addWidget(self.plot_widget, 1)
|
root.addWidget(self.plot_widget, 1)
|
||||||
self._build_traces()
|
self._build_traces()
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
buttons.accepted.connect(self.accept)
|
buttons.accepted.connect(self.accept)
|
||||||
root.addWidget(buttons)
|
root.addWidget(buttons)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize
|
from PySide6.QtCore import QSize
|
||||||
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
from PySide6.QtWidgets import QLabel, QSizePolicy
|
||||||
|
|
||||||
|
|
||||||
class ShrinkableLabel(QLabel):
|
class ShrinkableLabel(QLabel):
|
||||||
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
|
|||||||
|
|
||||||
def __init__(self, text: str = "", parent=None) -> None:
|
def __init__(self, text: str = "", parent=None) -> None:
|
||||||
super().__init__(text, parent)
|
super().__init__(text, parent)
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
self.setMinimumWidth(0)
|
self.setMinimumWidth(0)
|
||||||
|
|
||||||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, QSize, pyqtSignal
|
from PySide6.QtCore import Qt, QSize, Signal
|
||||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -116,18 +116,18 @@ class _Sync:
|
|||||||
|
|
||||||
class ForcePlotsPanel(QFrame):
|
class ForcePlotsPanel(QFrame):
|
||||||
#: The visible time window changed while browsing history.
|
#: The visible time window changed while browsing history.
|
||||||
window_changed = pyqtSignal(float, float)
|
window_changed = Signal(float, float)
|
||||||
#: The shared cursor moved; other views sync their playback position to it.
|
#: The shared cursor moved; other views sync their playback position to it.
|
||||||
cursor_moved = pyqtSignal(float)
|
cursor_moved = Signal(float)
|
||||||
#: "follow" (live) or "browse" (user-controlled window).
|
#: "follow" (live) or "browse" (user-controlled window).
|
||||||
mode_changed = pyqtSignal(str)
|
mode_changed = Signal(str)
|
||||||
#: The unit chip was clicked: switch between newton and raw.
|
#: The unit chip was clicked: switch between newton and raw.
|
||||||
unit_toggle_requested = pyqtSignal()
|
unit_toggle_requested = Signal()
|
||||||
#: The gear was clicked: open the plot settings (window, range, filters).
|
#: The gear was clicked: open the plot settings (window, range, filters).
|
||||||
plot_settings_requested = pyqtSignal()
|
plot_settings_requested = Signal()
|
||||||
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
|
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
|
||||||
#: or moved the overview marker): playback stops where it is.
|
#: or moved the overview marker): playback stops where it is.
|
||||||
timeline_taken_over = pyqtSignal()
|
timeline_taken_over = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -217,7 +217,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.unit_badge = QPushButton()
|
self.unit_badge = QPushButton()
|
||||||
self.unit_badge.setObjectName("unitBadge")
|
self.unit_badge.setObjectName("unitBadge")
|
||||||
self.unit_badge.setProperty("compact", "true")
|
self.unit_badge.setProperty("compact", "true")
|
||||||
self.unit_badge.setCursor(Qt.PointingHandCursor)
|
self.unit_badge.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
|
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
|
||||||
header.addWidget(self.unit_badge)
|
header.addWidget(self.unit_badge)
|
||||||
root.addLayout(header)
|
root.addLayout(header)
|
||||||
@@ -329,7 +329,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.window_label.setObjectName("panelMeta")
|
self.window_label.setObjectName("panelMeta")
|
||||||
self.stats_label = QLabel()
|
self.stats_label = QLabel()
|
||||||
self.stats_label.setObjectName("panelMeta")
|
self.stats_label.setObjectName("panelMeta")
|
||||||
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
self.stats_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
browse_row.addWidget(self.window_caption)
|
browse_row.addWidget(self.window_caption)
|
||||||
browse_row.addWidget(self.window_label)
|
browse_row.addWidget(self.window_label)
|
||||||
browse_row.addStretch(1)
|
browse_row.addStretch(1)
|
||||||
@@ -348,7 +348,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
button.setProperty("iconOnly", "true")
|
button.setProperty("iconOnly", "true")
|
||||||
button.setIcon(icons.icon(icon_name, self._theme))
|
button.setIcon(icons.icon(icon_name, self._theme))
|
||||||
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
||||||
button.setCursor(Qt.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
|
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
|
||||||
return button
|
return button
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
value_row.setSpacing(5)
|
value_row.setSpacing(5)
|
||||||
value_label = QLabel()
|
value_label = QLabel()
|
||||||
value_label.setObjectName("valueCard")
|
value_label.setObjectName("valueCard")
|
||||||
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
value_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
value_label.setFont(_mono_font(16, bold=True))
|
value_label.setFont(_mono_font(16, bold=True))
|
||||||
# Never a fixed height: the assertion that a value is not clipped is
|
# Never a fixed height: the assertion that a value is not clipped is
|
||||||
# derived from the font it is actually drawn with.
|
# derived from the font it is actually drawn with.
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from PyQt5.QtCore import QRectF, Qt
|
from PySide6.QtCore import QRectF, Qt
|
||||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||||
|
|
||||||
from orisys_app_shared.theme import color
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ def _paint_zoom_fit(painter: QPainter, size: int) -> None:
|
|||||||
def _paint_box_zoom(painter: QPainter, size: int) -> None:
|
def _paint_box_zoom(painter: QPainter, size: int) -> None:
|
||||||
"""A dashed frame: drag a region to zoom into it."""
|
"""A dashed frame: drag a region to zoom into it."""
|
||||||
pen = painter.pen()
|
pen = painter.pen()
|
||||||
pen.setStyle(Qt.DashLine)
|
pen.setStyle(Qt.PenStyle.DashLine)
|
||||||
pen.setDashPattern([2.2, 1.6])
|
pen.setDashPattern([2.2, 1.6])
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
|
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
|
||||||
@@ -143,19 +143,19 @@ _PAINTERS = {
|
|||||||
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
|
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
|
||||||
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
||||||
canvas = QPixmap(size, size)
|
canvas = QPixmap(size, size)
|
||||||
canvas.fill(Qt.transparent)
|
canvas.fill(Qt.GlobalColor.transparent)
|
||||||
paint = _PAINTERS.get(str(name))
|
paint = _PAINTERS.get(str(name))
|
||||||
if paint is None:
|
if paint is None:
|
||||||
return canvas
|
return canvas
|
||||||
painter = QPainter(canvas)
|
painter = QPainter(canvas)
|
||||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
pen = QPen()
|
pen = QPen()
|
||||||
pen.setWidthF(_PEN_WIDTH)
|
pen.setWidthF(_PEN_WIDTH)
|
||||||
pen.setCapStyle(Qt.RoundCap)
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
pen.setJoinStyle(Qt.RoundJoin)
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
pen.setColor(QColor(tint))
|
pen.setColor(QColor(tint))
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.setBrush(Qt.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
try:
|
try:
|
||||||
paint(painter, size)
|
paint(painter, size)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Callable, Optional
|
from typing import TYPE_CHECKING, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
from PySide6.QtCore import QSize, Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
#: Emitted when the operator turns the pane on. The window wires the packet sink
|
#: Emitted when the operator turns the pane on. The window wires the packet sink
|
||||||
#: from this, so no packet is adapted while the pane is off.
|
#: from this, so no packet is adapted while the pane is off.
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||||
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
|
|||||||
self.btn_reset_view = QPushButton()
|
self.btn_reset_view = QPushButton()
|
||||||
self.btn_reset_view.setProperty("compact", "true")
|
self.btn_reset_view.setProperty("compact", "true")
|
||||||
self.btn_reset_view.setProperty("iconOnly", "true")
|
self.btn_reset_view.setProperty("iconOnly", "true")
|
||||||
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
|
self.btn_reset_view.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.btn_reset_view.clicked.connect(self.reset_view)
|
self.btn_reset_view.clicked.connect(self.reset_view)
|
||||||
header.addWidget(self.btn_reset_view)
|
header.addWidget(self.btn_reset_view)
|
||||||
|
|
||||||
self.btn_settings = QPushButton()
|
self.btn_settings = QPushButton()
|
||||||
self.btn_settings.setProperty("compact", "true")
|
self.btn_settings.setProperty("compact", "true")
|
||||||
self.btn_settings.setProperty("iconOnly", "true")
|
self.btn_settings.setProperty("iconOnly", "true")
|
||||||
self.btn_settings.setCursor(Qt.PointingHandCursor)
|
self.btn_settings.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.btn_settings.clicked.connect(self.open_settings)
|
self.btn_settings.clicked.connect(self.open_settings)
|
||||||
header.addWidget(self.btn_settings)
|
header.addWidget(self.btn_settings)
|
||||||
root.addLayout(header)
|
root.addLayout(header)
|
||||||
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
self.message = QLabel()
|
self.message = QLabel()
|
||||||
self.message.setObjectName("hintText")
|
self.message.setObjectName("hintText")
|
||||||
self.message.setAlignment(Qt.AlignCenter)
|
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.message.setWordWrap(True)
|
self.message.setWordWrap(True)
|
||||||
self._stack.addWidget(self.message)
|
self._stack.addWidget(self.message)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ reports as done is what the record will say (see ``force/filters.py``).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.btn_reset.clicked.connect(self._reset_defaults)
|
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||||
buttons_row.addWidget(self.btn_reset)
|
buttons_row.addWidget(self.btn_reset)
|
||||||
buttons_row.addStretch(1)
|
buttons_row.addStretch(1)
|
||||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
self.button_box.accepted.connect(self.accept)
|
self.button_box.accepted.connect(self.accept)
|
||||||
self.button_box.rejected.connect(self.reject)
|
self.button_box.rejected.connect(self.reject)
|
||||||
buttons_row.addWidget(self.button_box)
|
buttons_row.addWidget(self.button_box)
|
||||||
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.rate_label.setText(t("dialog.filter_rate"))
|
self.rate_label.setText(t("dialog.filter_rate"))
|
||||||
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
||||||
self._refresh_filter_note()
|
self._refresh_filter_note()
|
||||||
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
|
||||||
if ok_btn is not None:
|
if ok_btn is not None:
|
||||||
ok_btn.setText(t("dialog.ok"))
|
ok_btn.setText(t("dialog.ok"))
|
||||||
if cancel_btn is not None:
|
if cancel_btn is not None:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ timer without touching the writer thread.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QLabel
|
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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
|
|||||||
def _section_rule() -> QFrame:
|
def _section_rule() -> QFrame:
|
||||||
rule = QFrame()
|
rule = QFrame()
|
||||||
rule.setObjectName("sectionSeparator")
|
rule.setObjectName("sectionSeparator")
|
||||||
rule.setFrameShape(QFrame.HLine)
|
rule.setFrameShape(QFrame.Shape.HLine)
|
||||||
rule.setFixedHeight(1)
|
rule.setFixedHeight(1)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
|
|||||||
|
|
||||||
|
|
||||||
class SensorPickerWidget(QWidget):
|
class SensorPickerWidget(QWidget):
|
||||||
sensor_type_changed = pyqtSignal(str)
|
sensor_type_changed = Signal(str)
|
||||||
config_changed = pyqtSignal(str)
|
config_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
#: attribute: it states which sensor the whole window is configured for.
|
#: attribute: it states which sensor the whole window is configured for.
|
||||||
self.thumb_label = QLabel()
|
self.thumb_label = QLabel()
|
||||||
self.thumb_label.setObjectName("unitBadge")
|
self.thumb_label.setObjectName("unitBadge")
|
||||||
self.thumb_label.setAlignment(Qt.AlignCenter)
|
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
|
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
|
||||||
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
||||||
self.thumb_label.setProperty("rail", "true")
|
self.thumb_label.setProperty("rail", "true")
|
||||||
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
self.type_combo = QComboBox()
|
self.type_combo = QComboBox()
|
||||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||||
self.type_combo.setProperty("rail", "true")
|
self.type_combo.setProperty("rail", "true")
|
||||||
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
for name in list_sensor_types():
|
for name in list_sensor_types():
|
||||||
self.type_combo.addItem(name, name)
|
self.type_combo.addItem(name, name)
|
||||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||||
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
self.config_combo = QComboBox()
|
self.config_combo = QComboBox()
|
||||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||||
self.config_combo.setProperty("rail", "true")
|
self.config_combo.setProperty("rail", "true")
|
||||||
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||||
|
|
||||||
layout.addWidget(self.thumb_label)
|
layout.addWidget(self.thumb_label)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
|
|||||||
def _separator() -> QFrame:
|
def _separator() -> QFrame:
|
||||||
line = QFrame()
|
line = QFrame()
|
||||||
line.setObjectName("sectionSeparator")
|
line.setObjectName("sectionSeparator")
|
||||||
line.setFrameShape(QFrame.VLine)
|
line.setFrameShape(QFrame.Shape.VLine)
|
||||||
line.setFixedWidth(1)
|
line.setFixedWidth(1)
|
||||||
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
|
|||||||
|
|
||||||
|
|
||||||
class ToolbarWidget(QWidget):
|
class ToolbarWidget(QWidget):
|
||||||
search_clicked = pyqtSignal()
|
search_clicked = Signal()
|
||||||
preview_toggled = pyqtSignal(bool)
|
preview_toggled = Signal(bool)
|
||||||
camera_id_changed = pyqtSignal(int)
|
camera_id_changed = Signal(int)
|
||||||
#: The sensor summary button: opens the sensor and calibration dialog.
|
#: The sensor summary button: opens the sensor and calibration dialog.
|
||||||
sensor_clicked = pyqtSignal()
|
sensor_clicked = Signal()
|
||||||
#: "Back to the camera" while a video file is the run source.
|
#: "Back to the camera" while a video file is the run source.
|
||||||
use_camera_clicked = pyqtSignal()
|
use_camera_clicked = Signal()
|
||||||
#: Replay view: pick the recorded video to play.
|
#: Replay view: pick the recorded video to play.
|
||||||
replay_video_clicked = pyqtSignal()
|
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 = pyqtSignal()
|
recompute_clicked = Signal()
|
||||||
run_toggled = pyqtSignal(bool)
|
run_toggled = Signal(bool)
|
||||||
reset_clicked = pyqtSignal()
|
reset_clicked = Signal()
|
||||||
units_changed = pyqtSignal(str)
|
units_changed = Signal(str)
|
||||||
record_toggled = pyqtSignal(bool)
|
record_toggled = Signal(bool)
|
||||||
#: The settings area was expanded or collapsed by the user.
|
#: The settings area was expanded or collapsed by the user.
|
||||||
settings_toggled = pyqtSignal(bool)
|
settings_toggled = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
|
|||||||
# narrow window.
|
# narrow window.
|
||||||
self._settings_scroll = QScrollArea()
|
self._settings_scroll = QScrollArea()
|
||||||
self._settings_scroll.setWidgetResizable(True)
|
self._settings_scroll.setWidgetResizable(True)
|
||||||
self._settings_scroll.setFrameShape(QFrame.NoFrame)
|
self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.settings_page = self._build_settings()
|
self.settings_page = self._build_settings()
|
||||||
self._settings_scroll.setWidget(self.settings_page)
|
self._settings_scroll.setWidget(self.settings_page)
|
||||||
outer.addWidget(self._settings_scroll)
|
outer.addWidget(self._settings_scroll)
|
||||||
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
|
|||||||
|
|
||||||
self.status_chip = QLabel()
|
self.status_chip = QLabel()
|
||||||
self.status_chip.setObjectName("statusChip")
|
self.status_chip.setObjectName("statusChip")
|
||||||
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
self.status_chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||||
self.source_summary = ShrinkableLabel()
|
self.source_summary = ShrinkableLabel()
|
||||||
self.source_summary.setObjectName("panelMeta")
|
self.source_summary.setObjectName("panelMeta")
|
||||||
self.calibration_label = ShrinkableLabel()
|
self.calibration_label = ShrinkableLabel()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
The tests render real Qt widgets with the offscreen platform plugin, so they need
|
The tests render real Qt widgets with the offscreen platform plugin, so they need
|
||||||
no display and no camera hardware. A single ``QApplication`` is shared by the
|
no display and no camera hardware. A single ``QApplication`` is shared by the
|
||||||
whole session (Qt forbids creating more than one).
|
whole session (Qt forbids creating more than one).
|
||||||
|
|
||||||
|
The suite requires PySide6, the app's only Qt binding. Where it is missing the
|
||||||
|
modules are skipped rather than failed, so a broken environment cannot look like a
|
||||||
|
broken application (the same rule ``apps/autocal`` follows).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,11 +20,30 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise
|
||||||
|
# bind to it (it takes whichever toolkit it finds first) and the plots would break.
|
||||||
|
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||||
if _fonts.is_dir():
|
if _fonts.is_dir():
|
||||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||||
|
|
||||||
|
|
||||||
|
def _have_pyside6() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
import PySide6
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on the machine
|
||||||
|
return False, str(exc)
|
||||||
|
return True, getattr(PySide6, "__version__", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
|
||||||
|
|
||||||
|
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
|
||||||
|
# Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without
|
||||||
|
# the binding reports no tests instead of a wall of import errors.
|
||||||
|
collect_ignore_glob = ["test_*.py"]
|
||||||
|
|
||||||
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
|
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
|
||||||
# truth between the pytest suite and the dependency-free checker.
|
# truth between the pytest suite and the dependency-free checker.
|
||||||
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
||||||
@@ -30,7 +53,7 @@ if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def qt_app():
|
def qt_app():
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
yield app
|
yield app
|
||||||
@@ -83,7 +106,7 @@ def visible_descendants(widget):
|
|||||||
|
|
||||||
def show_window(window, size):
|
def show_window(window, size):
|
||||||
"""Show a window at ``size`` and flush pending Qt events."""
|
"""Show a window at ``size`` and flush pending Qt events."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window.resize(*size)
|
window.resize(*size)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Binding policy for the studio.
|
||||||
|
|
||||||
|
The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off
|
||||||
|
PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in
|
||||||
|
``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it:
|
||||||
|
|
||||||
|
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and
|
||||||
|
nothing else, so such an import is a hard start-up failure;
|
||||||
|
* every UI and 3D module must name the binding it uses rather than inheriting one;
|
||||||
|
* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6;
|
||||||
|
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``);
|
||||||
|
* importing the application must not drag another binding into ``sys.modules`` — that is
|
||||||
|
what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first.
|
||||||
|
|
||||||
|
Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6
|
||||||
|
instead of using a hand-written list of enum holders: a curated list missed
|
||||||
|
``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and
|
||||||
|
``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh
|
||||||
|
interpreter**, because in-process the answer depends on what some earlier test imported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import enum
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] # apps/studio
|
||||||
|
SRC = APP / "src"
|
||||||
|
PROJECT_ROOT = APP.parents[1] # orisys-app
|
||||||
|
|
||||||
|
#: Everything that ships or drives the app.
|
||||||
|
SCAN_ROOTS = (SRC, APP / "tools")
|
||||||
|
|
||||||
|
#: Bindings no module may import.
|
||||||
|
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||||
|
|
||||||
|
#: The one binding allowed.
|
||||||
|
ALLOWED_BINDING = "PySide6"
|
||||||
|
|
||||||
|
#: Directories that must name their binding explicitly.
|
||||||
|
#:
|
||||||
|
#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed —
|
||||||
|
#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no
|
||||||
|
#: Qt binding at all so they can be tested headless, and requiring one would forbid that
|
||||||
|
#: design. The modules there that *do* use Qt already name PySide6.
|
||||||
|
BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",)
|
||||||
|
|
||||||
|
#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that
|
||||||
|
#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling
|
||||||
|
#: in code from one named in the policy that forbids it.
|
||||||
|
PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty")
|
||||||
|
|
||||||
|
_SKIP_PARTS = ("__pycache__",)
|
||||||
|
|
||||||
|
|
||||||
|
def _python_files(root: Path) -> list[Path]:
|
||||||
|
return sorted(
|
||||||
|
path
|
||||||
|
for path in root.rglob("*.py")
|
||||||
|
if not any(part in path.parts for part in _SKIP_PARTS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
# One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way.
|
||||||
|
return path.read_text(encoding="utf-8-sig", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _imported_packages(path: Path) -> set[str]:
|
||||||
|
"""Every top-level package name imported by ``path``."""
|
||||||
|
found: set[str] = set()
|
||||||
|
for node in ast.walk(ast.parse(_read(path))):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
found.add(node.module.split(".")[0])
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _qt_namespace() -> dict[str, object]:
|
||||||
|
"""The Qt classes the sources could name, resolved through the binding itself."""
|
||||||
|
from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets
|
||||||
|
|
||||||
|
namespace: dict[str, object] = {"Qt": QtCore.Qt}
|
||||||
|
return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets)
|
||||||
|
|
||||||
|
|
||||||
|
def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]:
|
||||||
|
"""Attribute reads whose value resolves to an enum instance rather than an enum class.
|
||||||
|
|
||||||
|
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer
|
||||||
|
``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither
|
||||||
|
is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft``
|
||||||
|
resolves to an enum instance, which is the spelling this test exists to reject.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(_read(path))
|
||||||
|
called = {
|
||||||
|
id(node.func)
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||||
|
}
|
||||||
|
inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||||
|
found: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
|
||||||
|
continue
|
||||||
|
if id(node) in called or id(node) in inner:
|
||||||
|
continue
|
||||||
|
holder, member = node.value.id, node.attr
|
||||||
|
target_holder = namespace.get(holder)
|
||||||
|
if target_holder is None:
|
||||||
|
for module in modules:
|
||||||
|
if hasattr(module, holder):
|
||||||
|
target_holder = namespace[holder] = getattr(module, holder)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
value = getattr(target_holder, member, None)
|
||||||
|
if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace:
|
||||||
|
found.add(f"{holder}.{member}")
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_studio_module_imports_a_forbidden_binding():
|
||||||
|
offenders = []
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
for path in _python_files(root):
|
||||||
|
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||||
|
if bad:
|
||||||
|
offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}")
|
||||||
|
assert not offenders, (
|
||||||
|
f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: "
|
||||||
|
f"{offenders}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_ui_module_imports_the_allowed_binding():
|
||||||
|
"""A widget module that names no binding would be using one it does not declare."""
|
||||||
|
offenders = []
|
||||||
|
for directory in BINDING_DECLARING_DIRS:
|
||||||
|
for path in _python_files(directory):
|
||||||
|
if path.name == "__init__.py":
|
||||||
|
continue # a re-export barrel, not a widget module
|
||||||
|
if ALLOWED_BINDING not in _imported_packages(path):
|
||||||
|
offenders.append(str(path.relative_to(APP)))
|
||||||
|
assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pyqt_only_spelling_survives():
|
||||||
|
offenders = []
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
for path in _python_files(root):
|
||||||
|
text = _read(path)
|
||||||
|
for name in PYQT_ONLY_NAMES:
|
||||||
|
if name in text:
|
||||||
|
offenders.append(f"{path.relative_to(APP)}: {name}")
|
||||||
|
assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_module_uses_an_unscoped_enum():
|
||||||
|
try:
|
||||||
|
import PySide6 # noqa: F401
|
||||||
|
except ImportError: # pragma: no cover - depends on the machine
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked")
|
||||||
|
namespace, modules = _qt_namespace()
|
||||||
|
offenders = {}
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
for path in _python_files(root):
|
||||||
|
found = _unscoped_enums(path, namespace, modules)
|
||||||
|
if found:
|
||||||
|
offenders[str(path.relative_to(APP))] = found
|
||||||
|
assert not offenders, (
|
||||||
|
"unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped "
|
||||||
|
f"form: {offenders}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_the_application_pulls_in_no_other_binding():
|
||||||
|
"""Fresh interpreter: PySide6 only, whatever this test session imported before."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
script = (
|
||||||
|
"import sys;"
|
||||||
|
"import orisys_studio.app_window;"
|
||||||
|
"import orisys_studio.scene3d.view;"
|
||||||
|
"import orisys_studio.ui.force_plots;"
|
||||||
|
"print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))"
|
||||||
|
)
|
||||||
|
env = dict(os.environ)
|
||||||
|
entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)]
|
||||||
|
if env.get("PYTHONPATH"):
|
||||||
|
entries.append(env["PYTHONPATH"])
|
||||||
|
env["PYTHONPATH"] = os.pathsep.join(entries)
|
||||||
|
# The app's own start-up contract (``main.py``) and a display-free platform: the point
|
||||||
|
# is which *binding* comes in, not whether a window can be shown.
|
||||||
|
env.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
|
env.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
pulled = [name for name in result.stdout.strip().split(",") if name]
|
||||||
|
assert pulled == [], (
|
||||||
|
f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it "
|
||||||
|
"finds first, so a second binding in the environment breaks the plots"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_app_declares_its_binding_dependency():
|
||||||
|
"""The packaging must state PySide6, not leave it to chance."""
|
||||||
|
pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
assert ALLOWED_BINDING in pyproject
|
||||||
|
for forbidden in FORBIDDEN_BINDINGS:
|
||||||
|
assert f'"{forbidden}' not in pyproject, forbidden
|
||||||
@@ -94,15 +94,16 @@ def _render(widget, qt_app) -> np.ndarray:
|
|||||||
on a little-endian machine, with a stride that can exceed the width — so the
|
on a little-endian machine, with a stride that can exceed the width — so the
|
||||||
conversion and the stride trim are both load-bearing.
|
conversion and the stride trim are both load-bearing.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtGui import QImage
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
widget.repaint()
|
widget.repaint()
|
||||||
qt_app.processEvents()
|
qt_app.processEvents()
|
||||||
image = widget.grabFramebuffer()
|
image = widget.grabFramebuffer()
|
||||||
assert not image.isNull(), "Qt returned no framebuffer"
|
assert not image.isNull(), "Qt returned no framebuffer"
|
||||||
image = image.convertToFormat(QImage.Format_RGB888)
|
image = image.convertToFormat(QImage.Format.Format_RGB888)
|
||||||
width, height = image.width(), image.height()
|
width, height = image.width(), image.height()
|
||||||
buffer = image.constBits().asarray(image.byteCount())
|
# PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
|
||||||
|
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
|
||||||
stride = image.bytesPerLine()
|
stride = image.bytesPerLine()
|
||||||
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
||||||
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_studio.ui import icons
|
from orisys_studio.ui import icons
|
||||||
@@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app):
|
|||||||
|
|
||||||
# Reset forwards to the view when there is one.
|
# Reset forwards to the view when there is one.
|
||||||
class _View(QWidget):
|
class _View(QWidget):
|
||||||
diagnosticRaised = pyqtSignal(object)
|
diagnosticRaised = Signal(object)
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -229,7 +229,7 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
|
|||||||
|
|
||||||
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
|
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
|
||||||
"""One line under the canvas: what the picture is, not how it was made."""
|
"""One line under the canvas: what the picture is, not how it was made."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = make_window()
|
window = make_window()
|
||||||
window.show()
|
window.show()
|
||||||
@@ -262,7 +262,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
|
|||||||
|
|
||||||
def test_the_view_menu_opens_the_3d_settings(make_window):
|
def test_the_view_menu_opens_the_3d_settings(make_window):
|
||||||
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
|
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = make_window(sensor_type="finger", show_finger_3d=True)
|
window = make_window(sensor_type="finger", show_finger_3d=True)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import pytest
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def restore_surface_format():
|
def restore_surface_format():
|
||||||
"""The default surface format is process-wide; put it back afterwards."""
|
"""The default surface format is process-wide; put it back afterwards."""
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
previous = QSurfaceFormat.defaultFormat()
|
previous = QSurfaceFormat.defaultFormat()
|
||||||
try:
|
try:
|
||||||
@@ -49,21 +49,21 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
|
|||||||
|
|
||||||
|
|
||||||
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
|
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||||
|
|
||||||
configure_surface_format("desktop33")
|
configure_surface_format("desktop33")
|
||||||
desktop = QSurfaceFormat.defaultFormat()
|
desktop = QSurfaceFormat.defaultFormat()
|
||||||
assert desktop.renderableType() == QSurfaceFormat.OpenGL
|
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
|
||||||
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
||||||
assert desktop.profile() == QSurfaceFormat.CoreProfile
|
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
|
||||||
assert desktop.depthBufferSize() == 24
|
assert desktop.depthBufferSize() == 24
|
||||||
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
||||||
|
|
||||||
configure_surface_format("gles30")
|
configure_surface_format("gles30")
|
||||||
embedded = QSurfaceFormat.defaultFormat()
|
embedded = QSurfaceFormat.defaultFormat()
|
||||||
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
|
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
|
||||||
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import ui_check
|
import ui_check
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.scene3d import synthetic
|
from orisys_app_shared.scene3d import synthetic
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -276,8 +276,8 @@ class _RecordingView(QWidget):
|
|||||||
a real ``QWidget`` because the container puts it in its stack.
|
a real ``QWidget`` because the container puts it in its stack.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
diagnosticRaised = pyqtSignal(object)
|
diagnosticRaised = Signal(object)
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -559,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
|
|||||||
loaded: list = []
|
loaded: list = []
|
||||||
failed: list = []
|
failed: list = []
|
||||||
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
|
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
|
||||||
loader.failed.connect(failed.append)
|
# Same arity caveat as below: this branch should never fire, and under PySide6 a
|
||||||
|
# builtin slot would fail *silently* if it ever did.
|
||||||
|
loader.failed.connect(lambda item, request_id: failed.append(item))
|
||||||
try:
|
try:
|
||||||
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
|
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
|
||||||
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
|
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
|
||||||
@@ -679,7 +681,10 @@ def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
|
|||||||
broken.write_bytes(b"not a zip archive at all")
|
broken.write_bytes(b"not a zip archive at all")
|
||||||
loader = SceneAssetLoader()
|
loader = SceneAssetLoader()
|
||||||
failed: list = []
|
failed: list = []
|
||||||
loader.failed.connect(failed.append)
|
# ``failed`` carries (diagnostic, request id). PySide6 adapts to a *Python* callable's
|
||||||
|
# arity but not to a builtin's, so ``failed.append`` raises TypeError inside the emit,
|
||||||
|
# Qt swallows it, and the failure signal looks like it was never sent.
|
||||||
|
loader.failed.connect(lambda item, request_id: failed.append(item))
|
||||||
try:
|
try:
|
||||||
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
|
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
|
||||||
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
|
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window):
|
|||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
for i in range(60):
|
for i in range(60):
|
||||||
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
|
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.force_plots.history.sample_count > 0
|
assert window.force_plots.history.sample_count > 0
|
||||||
@@ -1009,7 +1009,7 @@ def test_top_area_stays_compact(make_window):
|
|||||||
assert window.toolbar.record_chip.text() == t("record.state_idle")
|
assert window.toolbar.record_chip.text() == t("record.state_idle")
|
||||||
|
|
||||||
window.toolbar.btn_settings.setChecked(True)
|
window.toolbar.btn_settings.setChecked(True)
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.toolbar.settings_expanded()
|
assert window.toolbar.settings_expanded()
|
||||||
@@ -1032,7 +1032,7 @@ def test_settings_area_stays_inside_its_container(make_window, size):
|
|||||||
The settings area is the one part of the top rail that may scroll: its own
|
The settings area is the one part of the top rail that may scroll: its own
|
||||||
content is exempt, everything else must fit.
|
content is exempt, everything else must fit.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
||||||
window._set_settings_expanded(True)
|
window._set_settings_expanded(True)
|
||||||
@@ -1063,7 +1063,7 @@ def test_idle_run_is_the_only_filled_action(make_window):
|
|||||||
|
|
||||||
def test_connecting_cancel_stays_filled(make_window):
|
def test_connecting_cancel_stays_filled(make_window):
|
||||||
"""Cancel is the way out of a hung device open, so it keeps the weight."""
|
"""Cancel is the way out of a hung device open, so it keeps the weight."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
stuck = ui_check._StuckWorker()
|
stuck = ui_check._StuckWorker()
|
||||||
@@ -1197,7 +1197,7 @@ def test_overview_region_is_the_timeline_control(make_window):
|
|||||||
|
|
||||||
def test_the_charts_start_in_follow_mode(make_window):
|
def test_the_charts_start_in_follow_mode(make_window):
|
||||||
"""Every live entry point resumes following; only browsing is a gesture."""
|
"""Every live entry point resumes following; only browsing is a gesture."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1229,7 +1229,7 @@ def test_the_charts_start_in_follow_mode(make_window):
|
|||||||
|
|
||||||
def test_the_charts_zoom_and_pan_in_every_view(make_window):
|
def test_the_charts_zoom_and_pan_in_every_view(make_window):
|
||||||
"""Zoom/pan must be reachable from the live view, not only while browsing."""
|
"""Zoom/pan must be reachable from the live view, not only while browsing."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
panel = window.force_plots
|
panel = window.force_plots
|
||||||
@@ -1306,7 +1306,7 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
|||||||
|
|
||||||
def test_the_sensor_summary_opens_the_dialog(make_window):
|
def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||||
"""The rail states the sensor; the pickers live in the dialog it opens."""
|
"""The rail states the sensor; the pickers live in the dialog it opens."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
|
|||||||
|
|
||||||
def test_the_source_picker_follows_the_view(make_window):
|
def test_the_source_picker_follows_the_view(make_window):
|
||||||
"""Live offers a camera, the replay a recorded video — and no residue."""
|
"""Live offers a camera, the replay a recorded video — and no residue."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
assert window.toolbar.camera_group.isVisible()
|
assert window.toolbar.camera_group.isVisible()
|
||||||
@@ -1425,11 +1425,11 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
|
|||||||
|
|
||||||
|
|
||||||
def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
||||||
from PyQt5.QtWidgets import QHeaderView
|
from PySide6.QtWidgets import QHeaderView
|
||||||
|
|
||||||
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
|
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
|
||||||
window.brand_bar.btn_calibration.click()
|
window.brand_bar.btn_calibration.click()
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
|||||||
cal = window.cal_panel
|
cal = window.cal_panel
|
||||||
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
||||||
assert (
|
assert (
|
||||||
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
|
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
|
||||||
), "the diagnostic columns can still be elided"
|
), "the diagnostic columns can still be elided"
|
||||||
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
||||||
assert button.height() >= button.fontMetrics().height(), button.text()
|
assert button.height() >= button.fontMetrics().height(), button.text()
|
||||||
@@ -1468,7 +1468,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
|||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_live.click()
|
window.brand_bar.btn_live.click()
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
live_sizes = tuple(window.content_splitter.sizes())
|
live_sizes = tuple(window.content_splitter.sizes())
|
||||||
@@ -1496,7 +1496,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
|||||||
|
|
||||||
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
|
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
|
||||||
"""The replay view reads the recording: charts, 2D panel and transport."""
|
"""The replay view reads the recording: charts, 2D panel and transport."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1602,7 +1602,7 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
|
|||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_studio.ui.force_plots import unit_display
|
from orisys_studio.ui.force_plots import unit_display
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QPushButton
|
from PySide6.QtWidgets import QPushButton
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
plots = window.force_plots
|
plots = window.force_plots
|
||||||
@@ -1734,7 +1734,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
|
|||||||
While the replay view is up, its frames own the 2D panel; a live packet used
|
While the replay view is up, its frames own the 2D panel; a live packet used
|
||||||
to paint over the recording. Leaving the view hands the pane back.
|
to paint over the recording. Leaving the view hands the pane back.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
@@ -1786,7 +1786,7 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
|
|||||||
A replay used to state the live acquisition in cards sitting next to a chart
|
A replay used to state the live acquisition in cards sitting next to a chart
|
||||||
of 7.5e5 raw counts, which reads as "the replay never opened".
|
of 7.5e5 raw counts, which reads as "the replay never opened".
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1820,7 +1820,7 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
|
|||||||
for the axes it calibrated. Asking for newton anyway drew three blank charts
|
for the axes it calibrated. Asking for newton anyway drew three blank charts
|
||||||
and a dash in every card, which looks exactly like "the replay did not load".
|
and a dash in every card, which looks exactly like "the replay did not load".
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1847,7 +1847,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
|
|||||||
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
|
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
@@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
|||||||
make_window, replay_recording
|
make_window, replay_recording
|
||||||
):
|
):
|
||||||
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
|
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
@@ -1929,7 +1929,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
|||||||
wrong. So the rail's second button becomes an unconditional stop in this view, and
|
wrong. So the rail's second button becomes an unconditional stop in this view, and
|
||||||
it covers both things that can run there: plain playback and a recompute pass.
|
it covers both things that can run there: plain playback and a recompute pass.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -1989,7 +1989,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
|||||||
|
|
||||||
def test_escape_stops_the_replay_too(make_window, replay_recording):
|
def test_escape_stops_the_replay_too(make_window, replay_recording):
|
||||||
"""Esc is the documented way out; in the replay view it stops the replay."""
|
"""Esc is the documented way out; in the replay view it stops the replay."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
@@ -2007,7 +2007,7 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
|
|||||||
|
|
||||||
|
|
||||||
def test_the_live_view_keeps_the_tracker_reset(make_window):
|
def test_the_live_view_keeps_the_tracker_reset(make_window):
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_live.click()
|
window.brand_bar.btn_live.click()
|
||||||
@@ -2028,7 +2028,7 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -2091,7 +2091,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
|
|||||||
"""
|
"""
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
import orisys_studio.app_window as app_module
|
import orisys_studio.app_window as app_module
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
@@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
|
|||||||
"""
|
"""
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -2239,7 +2239,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
|||||||
make_window, record_root
|
make_window, record_root
|
||||||
):
|
):
|
||||||
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -2272,7 +2272,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
|||||||
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||||
make_window, replay_recording
|
make_window, replay_recording
|
||||||
):
|
):
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -2300,7 +2300,7 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
@@ -2399,7 +2399,7 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
import orisys_studio.app_window as app_module
|
import orisys_studio.app_window as app_module
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None:
|
|||||||
|
|
||||||
def _inside_scroll_area(widget) -> bool:
|
def _inside_scroll_area(widget) -> bool:
|
||||||
"""True when ``widget`` lives inside a QScrollArea's content."""
|
"""True when ``widget`` lives inside a QScrollArea's content."""
|
||||||
from PyQt5.QtWidgets import QScrollArea
|
from PySide6.QtWidgets import QScrollArea
|
||||||
|
|
||||||
node = widget
|
node = widget
|
||||||
while node is not None:
|
while node is not None:
|
||||||
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.force.calibration_io import CalSample
|
from orisys_app_shared.force.calibration_io import CalSample
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
@@ -1491,7 +1491,7 @@ def main() -> int:
|
|||||||
window.brand_bar.btn_calibration.click()
|
window.brand_bar.btn_calibration.click()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
|
from PySide6.QtWidgets import QHeaderView as _QHeaderView
|
||||||
|
|
||||||
sizes = window.content_splitter.sizes()
|
sizes = window.content_splitter.sizes()
|
||||||
check(
|
check(
|
||||||
@@ -2000,7 +2000,7 @@ def main() -> int:
|
|||||||
"import os, sys\n"
|
"import os, sys\n"
|
||||||
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
|
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
|
||||||
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
|
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
|
||||||
"from PyQt5.QtCore import QTimer\n"
|
"from PySide6.QtCore import QTimer\n"
|
||||||
"import orisys_studio.main as studio_main\n"
|
"import orisys_studio.main as studio_main\n"
|
||||||
"show = studio_main.StudioMainWindow.show\n"
|
"show = studio_main.StudioMainWindow.show\n"
|
||||||
"def show_and_close(self):\n"
|
"def show_and_close(self):\n"
|
||||||
@@ -2259,7 +2259,7 @@ def main() -> int:
|
|||||||
|
|
||||||
import orisys_studio.ui.cal_panel as _cal_panel_module
|
import orisys_studio.ui.cal_panel as _cal_panel_module
|
||||||
import orisys_studio.calibration_service as _cal_service_module
|
import orisys_studio.calibration_service as _cal_service_module
|
||||||
from PyQt5.QtWidgets import QMessageBox as _QMessageBox
|
from PySide6.QtWidgets import QMessageBox as _QMessageBox
|
||||||
|
|
||||||
from orisys_app_shared.force.calibration_io import (
|
from orisys_app_shared.force.calibration_io import (
|
||||||
build_force_calibration_v2 as _build_v2b,
|
build_force_calibration_v2 as _build_v2b,
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ def main() -> int:
|
|||||||
|
|
||||||
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
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
|
||||||
|
|||||||
+13
-4
@@ -24,7 +24,7 @@ dist/
|
|||||||
OrisysStudio/
|
OrisysStudio/
|
||||||
OrisysStudio.exe
|
OrisysStudio.exe
|
||||||
config/ # lean locales + sensors (no finger visualization/)
|
config/ # lean locales + sensors (no finger visualization/)
|
||||||
_internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, …
|
_internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, …
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why there are two steps
|
## Why there are two steps
|
||||||
@@ -137,11 +137,20 @@ loaded" can only be printed if those modules imported.)
|
|||||||
|
|
||||||
## Dependency notes
|
## Dependency notes
|
||||||
|
|
||||||
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls
|
- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
|
||||||
any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels
|
GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
|
||||||
without a `cv2` module) and verifies `import cv2`.
|
(`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`).
|
||||||
|
`setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one
|
||||||
|
binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover
|
||||||
|
PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes
|
||||||
|
`PyQt5`/`PyQt6`/`PySide2` outright, and must never exclude `PySide6` itself.
|
||||||
|
- App shared dep uses **`opencv-python-headless`** (PySide6 owns the GUI). `setup.ps1`
|
||||||
|
uninstalls any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped
|
||||||
|
wheels without a `cv2` module) and verifies `import cv2`.
|
||||||
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
|
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
|
||||||
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
|
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
|
||||||
|
- **`QOpenGLWidget` lives in `PySide6.QtOpenGLWidgets`**, not `QtWidgets` (Qt6 moved it): the
|
||||||
|
spec names it in `hiddenimports` so the module and `Qt6OpenGLWidgets.dll` are bundled.
|
||||||
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
|
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
|
||||||
Qt `QOpenGLWidget` + PyOpenGL.
|
Qt `QOpenGLWidget` + PyOpenGL.
|
||||||
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
|
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
|
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
|
||||||
# machine and produces a non-starting EXE on another is usually a version difference that
|
# machine and produces a non-starting EXE on another is usually a version difference that
|
||||||
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
|
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
|
||||||
# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set.
|
# own loose requirements (PySide6>=6.6, scipy>=1.10.1, ...) resolve to exactly this set.
|
||||||
|
#
|
||||||
|
# PySide6 rather than PyQt5: the apps are LGPLv3-bound. PyQt5 must not be present in the
|
||||||
|
# build environment at all — `pyqtgraph` binds to whichever toolkit it finds first, so a
|
||||||
|
# leftover PyQt5 produces plots built from the wrong binding (setup.ps1 uninstalls it).
|
||||||
#
|
#
|
||||||
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
|
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
|
||||||
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
|
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
|
||||||
@@ -15,9 +19,10 @@
|
|||||||
PyInstaller==6.22.2
|
PyInstaller==6.22.2
|
||||||
pyinstaller-hooks-contrib==2026.7
|
pyinstaller-hooks-contrib==2026.7
|
||||||
PyOpenGL==3.1.10
|
PyOpenGL==3.1.10
|
||||||
PyQt5==5.15.11
|
PySide6==6.11.2
|
||||||
PyQt5-Qt5==5.15.2
|
PySide6-Essentials==6.11.2
|
||||||
PyQt5-sip==12.19.0
|
PySide6-Addons==6.11.2
|
||||||
|
shiboken6==6.11.2
|
||||||
numpy==2.2.6
|
numpy==2.2.6
|
||||||
scipy==1.15.3
|
scipy==1.15.3
|
||||||
numba==0.67.0
|
numba==0.67.0
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ datas = _branding_datas()
|
|||||||
binaries: list = _extra_runtime_binaries()
|
binaries: list = _extra_runtime_binaries()
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
"cv2",
|
"cv2",
|
||||||
"PyQt5.sip",
|
# PySide6's runtime is shiboken6; the Qt binding itself is found from the imports, but
|
||||||
|
# QtOpenGLWidgets is unusual enough to name explicitly: it is a separate module and DLL
|
||||||
|
# (Qt6OpenGLWidgets), and without it the 3D pane reports "no OpenGL widgets" while the
|
||||||
|
# rest of the app looks fine.
|
||||||
|
"shiboken6",
|
||||||
|
"PySide6.QtOpenGLWidgets",
|
||||||
"pyqtgraph.graphicsItems",
|
"pyqtgraph.graphicsItems",
|
||||||
"scipy.signal",
|
"scipy.signal",
|
||||||
"numba",
|
"numba",
|
||||||
@@ -126,22 +131,50 @@ a = Analysis(
|
|||||||
"jupyter",
|
"jupyter",
|
||||||
"notebook",
|
"notebook",
|
||||||
"pytest",
|
"pytest",
|
||||||
"PyQt5.QtBluetooth",
|
# The app uses PySide6 only. Qt modules it never imports are excluded to keep the
|
||||||
"PyQt5.QtNfc",
|
# bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole
|
||||||
"PyQt5.QtPositioning",
|
# binding produces an EXE with no Qt at all (it fails at start-up, not at build
|
||||||
"PyQt5.QtLocation",
|
# time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately
|
||||||
"PyQt5.QtMultimedia",
|
# absent from this list.
|
||||||
"PyQt5.QtMultimediaWidgets",
|
"PySide6.Qt3DAnimation",
|
||||||
"PyQt5.QtSensors",
|
"PySide6.Qt3DCore",
|
||||||
"PyQt5.QtSerialPort",
|
"PySide6.Qt3DExtras",
|
||||||
"PyQt5.QtWebChannel",
|
"PySide6.Qt3DInput",
|
||||||
"PyQt5.QtWebEngine",
|
"PySide6.Qt3DLogic",
|
||||||
"PyQt5.QtWebEngineCore",
|
"PySide6.Qt3DRender",
|
||||||
"PyQt5.QtWebEngineWidgets",
|
"PySide6.QtBluetooth",
|
||||||
"PyQt5.QtWebSockets",
|
"PySide6.QtCharts",
|
||||||
"PySide2",
|
"PySide6.QtDataVisualization",
|
||||||
"PySide6",
|
"PySide6.QtGraphs",
|
||||||
|
"PySide6.QtHelp",
|
||||||
|
"PySide6.QtLocation",
|
||||||
|
"PySide6.QtMultimedia",
|
||||||
|
"PySide6.QtMultimediaWidgets",
|
||||||
|
"PySide6.QtNfc",
|
||||||
|
"PySide6.QtPdf",
|
||||||
|
"PySide6.QtPdfWidgets",
|
||||||
|
"PySide6.QtPositioning",
|
||||||
|
"PySide6.QtQml",
|
||||||
|
"PySide6.QtQuick",
|
||||||
|
"PySide6.QtQuick3D",
|
||||||
|
"PySide6.QtQuickControls2",
|
||||||
|
"PySide6.QtQuickWidgets",
|
||||||
|
"PySide6.QtRemoteObjects",
|
||||||
|
"PySide6.QtScxml",
|
||||||
|
"PySide6.QtSensors",
|
||||||
|
"PySide6.QtSerialBus",
|
||||||
|
"PySide6.QtSerialPort",
|
||||||
|
"PySide6.QtSpatialAudio",
|
||||||
|
"PySide6.QtStateMachine",
|
||||||
|
"PySide6.QtTextToSpeech",
|
||||||
|
"PySide6.QtWebChannel",
|
||||||
|
"PySide6.QtWebEngineCore",
|
||||||
|
"PySide6.QtWebEngineQuick",
|
||||||
|
"PySide6.QtWebEngineWidgets",
|
||||||
|
"PySide6.QtWebSockets",
|
||||||
|
"PyQt5",
|
||||||
"PyQt6",
|
"PyQt6",
|
||||||
|
"PySide2",
|
||||||
],
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
|
|||||||
+13
-2
@@ -237,12 +237,19 @@ $install += @($PyInstallerPin, $CvPin)
|
|||||||
& $VenvPy @install
|
& $VenvPy @install
|
||||||
if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
|
if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
|
||||||
|
|
||||||
# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns
|
# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PySide6 owns
|
||||||
# the GUI), and 5.x can arrive without a `cv2` module.
|
# the GUI), and 5.x can arrive without a `cv2` module.
|
||||||
& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null
|
& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null
|
||||||
& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin
|
& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin
|
||||||
if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" }
|
if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" }
|
||||||
|
|
||||||
|
# PyQt5 must not be in the build environment, even though the SDK's `gui` extra asks for it
|
||||||
|
# when someone installs that: `pyqtgraph` binds to whichever toolkit it imports first, and a
|
||||||
|
# frozen app built where PyQt5 is present can end up with plots built from the wrong binding.
|
||||||
|
# The apps are PySide6-only (see apps/studio/tests/test_binding_purity.py).
|
||||||
|
Write-Host "Removing any other Qt binding from the build venv ..."
|
||||||
|
& $VenvPy -m pip uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip PyQt6 PySide2 2>$null | Out-Null
|
||||||
|
|
||||||
# Open3D must not be in the build env: it is an offline tool, and the spec excludes it.
|
# Open3D must not be in the build env: it is an offline tool, and the spec excludes it.
|
||||||
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
||||||
if ($hasOpen3d -eq "True") {
|
if ($hasOpen3d -eq "True") {
|
||||||
@@ -256,8 +263,12 @@ if (-not $NoVerify) {
|
|||||||
Write-Host "Verifying the provisioned environment ..."
|
Write-Host "Verifying the provisioned environment ..."
|
||||||
# These are the imports the frozen app makes before it can draw anything. Checking them
|
# These are the imports the frozen app makes before it can draw anything. Checking them
|
||||||
# here is what turns "the build succeeded" into "the build can start".
|
# here is what turns "the build succeeded" into "the build can start".
|
||||||
& $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')"
|
& $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PySide6.QtWidgets, PySide6.QtOpenGLWidgets, PyInstaller; print('imports OK')"
|
||||||
if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" }
|
if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" }
|
||||||
|
# A second binding in the venv is the failure mode the uninstall above exists for: it
|
||||||
|
# imports fine here and breaks `pyqtgraph` at run time.
|
||||||
|
& $VenvPy -c "import importlib.util as u; bad=[m for m in ('PyQt5','PyQt6','PySide2') if u.find_spec(m)]; assert not bad, 'another Qt binding is installed: '+str(bad); print('binding OK: PySide6 only')"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "the build venv has more than one Qt binding" }
|
||||||
# Theme colours are package data: PyInstaller only bundles them if the package is
|
# Theme colours are package data: PyInstaller only bundles them if the package is
|
||||||
# importable at build time, so a stale editable install loses them silently.
|
# importable at build time, so a stale editable install loses them silently.
|
||||||
& $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
|
& $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ from concurrent.futures import Executor
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
from PySide6.QtCore import QEvent, SignalInstance
|
||||||
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
from PySide6.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
from orisys_app_shared.acquisition.models import SdkPacket
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
from orisys.finger.runtime import FingerRuntime
|
from orisys.finger.runtime import FingerRuntime
|
||||||
|
|
||||||
@@ -343,9 +344,9 @@ class GLRenderer(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class Sensor3DWidget(QOpenGLWidget):
|
class Sensor3DWidget(QOpenGLWidget):
|
||||||
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
|
capabilitiesChanged: SignalInstance # Payload: GLCapabilities.
|
||||||
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
|
diagnosticRaised: SignalInstance # Payload: Diagnostic.
|
||||||
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
|
renderStatsChanged: SignalInstance # Payload: RenderStats; rate-limited.
|
||||||
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
|
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
|
||||||
parent: QWidget | None = None) -> None: ...
|
parent: QWidget | None = None) -> None: ...
|
||||||
def set_scene(self, assets: SceneAssets) -> None:
|
def set_scene(self, assets: SceneAssets) -> None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 1. 决策与实施范围
|
## 1. 决策与实施范围
|
||||||
|
|
||||||
**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
|
**推荐使用现有 Qt 技术栈的 QOpenGLWidget + PyOpenGL(Qt 绑定为 PySide6,LGPLv3),使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
|
||||||
|
|
||||||
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
|
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
|
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
|
||||||
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
|
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
|
||||||
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 |
|
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PySide6 暴露的接口。Qt6 把 `QOpenGLWidget` 移到了 `QtOpenGLWidgets`,打包时该模块与 `Qt6OpenGLWidgets.dll` 都要收进 bundle |
|
||||||
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
|
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
|
||||||
|
|
||||||
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
|
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
|
||||||
@@ -366,7 +366,8 @@ Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形
|
|||||||
|
|
||||||
### 11.1 依赖
|
### 11.1 依赖
|
||||||
|
|
||||||
- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
|
- 继续使用项目验证过的 Python、**PySide6**、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
|
||||||
|
Qt 绑定选 PySide6(LGPLv3)而不是 PyQt5(GPLv3-or-commercial):两个 app 都只允许 PySide6,由 `apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 静态强制;构建环境里出现第二个 binding 会让 `pyqtgraph` 绑错(`setup.ps1` 会卸载 PyQt5/PyQt6/PySide2,`main.py` 在 import pyqtgraph 之前设 `PYQTGRAPH_QT_LIB=PySide6`)。
|
||||||
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
|
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
|
||||||
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
|
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
|
||||||
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
|
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ graph TB
|
|||||||
| 规则 | 是否成立 | 证据 |
|
| 规则 | 是否成立 | 证据 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
|
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
|
||||||
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PyQt5 的 21 个模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PyQt5 **画图标时**才 import(函数内 + try 保护) |
|
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `tests/test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) |
|
||||||
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
|
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
|
||||||
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PyQt5/OpenGL/SDK |
|
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK |
|
||||||
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
|
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
|
||||||
| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 |
|
| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 |
|
||||||
| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO |
|
| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO |
|
||||||
@@ -300,7 +300,7 @@ graph TB
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
|
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
|
||||||
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
|
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
|
||||||
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PyQt5/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
|
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
|
||||||
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
|
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
|
||||||
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
|
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
|
||||||
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
|
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Orisys-Tech" }]
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyQt5>=5.15",
|
# Shared by both apps, which are PySide6-only (LGPLv3). `theme/qss.py` paints the chevron
|
||||||
|
# icons with it; nothing here may import another binding — enforced by
|
||||||
|
# apps/autocal/tests/test_binding_purity.py and apps/studio/tests/test_binding_purity.py.
|
||||||
|
"PySide6>=6.6",
|
||||||
"opencv-python-headless>=4.10.0.84,<5",
|
"opencv-python-headless>=4.10.0.84,<5",
|
||||||
"numpy>=1.24.4",
|
"numpy>=1.24.4",
|
||||||
"scipy>=1.10.1",
|
"scipy>=1.10.1",
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ def _arrow_icon_cache_dir() -> Path:
|
|||||||
def _paint_arrow(kind: str, fill: str) -> str:
|
def _paint_arrow(kind: str, fill: str) -> str:
|
||||||
"""Draw a small chevron PNG on demand; return its path ('' on failure)."""
|
"""Draw a small chevron PNG on demand; return its path ('' on failure)."""
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QPointF, Qt
|
from PySide6.QtCore import QPointF, Qt
|
||||||
from PyQt5.QtGui import QColor, QImage, QPainter, QPolygonF
|
from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF
|
||||||
except Exception: # pragma: no cover - headless import failure
|
except Exception: # pragma: no cover - headless import failure
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -43,12 +43,12 @@ def _paint_arrow(kind: str, fill: str) -> str:
|
|||||||
path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png"
|
path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png"
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return str(path)
|
return str(path)
|
||||||
image = QImage(size, size, QImage.Format_ARGB32)
|
image = QImage(size, size, QImage.Format.Format_ARGB32)
|
||||||
image.fill(Qt.transparent)
|
image.fill(Qt.GlobalColor.transparent)
|
||||||
painter = QPainter(image)
|
painter = QPainter(image)
|
||||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
painter.setBrush(QColor(fill))
|
painter.setBrush(QColor(fill))
|
||||||
painter.setPen(Qt.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
half = size / 2.0
|
half = size / 2.0
|
||||||
top = size * 0.32
|
top = size * 0.32
|
||||||
bottom = size * 0.68
|
bottom = size * 0.68
|
||||||
|
|||||||
@@ -464,6 +464,8 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
|
|||||||
Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only
|
Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only
|
||||||
proves what some earlier test happened to import (it failed once the studio tests
|
proves what some earlier test happened to import (it failed once the studio tests
|
||||||
painted a widget before this file ran, which says nothing about the contract layer).
|
painted a widget before this file ran, which says nothing about the contract layer).
|
||||||
|
The Qt name checked is the binding the app uses (PySide6), which is the only one that
|
||||||
|
could be pulled in — the earlier spelling named PyQt5, before the migration.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -471,7 +473,7 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
|
|||||||
script = (
|
script = (
|
||||||
"import sys;"
|
"import sys;"
|
||||||
"import orisys_app_shared.scene3d.contracts as c;"
|
"import orisys_app_shared.scene3d.contracts as c;"
|
||||||
"print(','.join(m for m in ('OpenGL', 'PyQt5', 'cv2') if m in sys.modules))"
|
"print(','.join(m for m in ('OpenGL', 'PySide6', 'cv2') if m in sys.modules))"
|
||||||
)
|
)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-c", script],
|
[sys.executable, "-c", script],
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ def measure(
|
|||||||
) -> tuple[dict, int]:
|
) -> tuple[dict, int]:
|
||||||
"""Run the assembled view and report what each stage cost."""
|
"""Run the assembled view and report what each stage cost."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.scene3d import synthetic
|
from orisys_app_shared.scene3d import synthetic
|
||||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ def _bootstrap_paths() -> None:
|
|||||||
|
|
||||||
def collect(profile: str, *, keep_open_s: float) -> dict:
|
def collect(profile: str, *, keep_open_s: float) -> dict:
|
||||||
"""Create a real window, let Qt initialise it, and report what it got."""
|
"""Create a real window, let Qt initialise it, and report what it got."""
|
||||||
from PyQt5.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
from PyQt5.QtWidgets import QApplication, QOpenGLWidget
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_studio.scene3d.gl_backend import (
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
GLDispatch,
|
GLDispatch,
|
||||||
@@ -103,7 +104,7 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
|
|||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
def report_caps(self, capabilities) -> None:
|
def report_caps(self, capabilities) -> None:
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
|
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
|
||||||
ratio = float(self.devicePixelRatioF() or 1.0)
|
ratio = float(self.devicePixelRatioF() or 1.0)
|
||||||
@@ -118,8 +119,11 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
|
|||||||
"max_texture_size": capabilities.max_texture_size,
|
"max_texture_size": capabilities.max_texture_size,
|
||||||
"qt_format": {
|
"qt_format": {
|
||||||
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
|
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
|
||||||
"renderable_type": int(fmt.renderableType()),
|
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises
|
||||||
"profile": int(fmt.profile()),
|
# TypeError, so read `.value`. (PyQt5 exposed them as ints, which is
|
||||||
|
# why this worked before the migration.)
|
||||||
|
"renderable_type": int(fmt.renderableType().value),
|
||||||
|
"profile": int(fmt.profile().value),
|
||||||
"depth_buffer": int(fmt.depthBufferSize()),
|
"depth_buffer": int(fmt.depthBufferSize()),
|
||||||
"samples": int(fmt.samples()),
|
"samples": int(fmt.samples()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ def run(
|
|||||||
view = None
|
view = None
|
||||||
widget = None
|
widget = None
|
||||||
if use_gui:
|
if use_gui:
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||||
from orisys_studio.scene3d.view import Scene3DView
|
from orisys_studio.scene3d.view import Scene3DView
|
||||||
|
|||||||
Reference in New Issue
Block a user