migrate pyqt5 to pyside6
This commit is contained in:
@@ -4,14 +4,21 @@ Desktop applications that consume the Orisys SDK.
|
||||
|
||||
| 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/internal` | Stub, superseded by `apps/autocal` | — |
|
||||
|
||||
The two shipped apps use different Qt bindings on purpose. PyQt5's free option
|
||||
is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal
|
||||
closed-source tool uses PySide6 and why `shared/` must import neither binding.
|
||||
Migrating `apps/studio` to PySide6 is a separate, build-validated change.
|
||||
Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free option is
|
||||
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
|
||||
product would otherwise need a licence purchase. The rule is enforced, not just
|
||||
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
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
|
||||
## Run it
|
||||
|
||||
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
|
||||
# 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
|
||||
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
||||
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
|
||||
all, and the controller interface must stay importable headless.
|
||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
|
||||
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
|
||||
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
"validation_title": "Checks",
|
||||
"points_title": "Calibration points",
|
||||
"field_mode": "Surface mode",
|
||||
"field_name": "Sequence name",
|
||||
"field_note": "Note",
|
||||
"field_pitch": "Pitch (mm)",
|
||||
"field_margin": "Margin (mm)",
|
||||
"field_radius": "Radius (mm)",
|
||||
@@ -221,8 +223,11 @@
|
||||
"btn_generate": "Generate points",
|
||||
"btn_simulate": "Simulate",
|
||||
"btn_dry_run": "Dry run (no contact)",
|
||||
"btn_save": "Save sequence…",
|
||||
"btn_load": "Load sequence…",
|
||||
"btn_export": "Export 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.",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
@@ -234,6 +239,16 @@
|
||||
"col_actions": "Actions",
|
||||
"not_generated": "—",
|
||||
"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."
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
"validation_title": "校验",
|
||||
"points_title": "标定点列表",
|
||||
"field_mode": "曲面模式",
|
||||
"field_name": "序列名称",
|
||||
"field_note": "备注",
|
||||
"field_pitch": "点距 (mm)",
|
||||
"field_margin": "边界余量 (mm)",
|
||||
"field_radius": "曲面半径 (mm)",
|
||||
@@ -221,8 +223,11 @@
|
||||
"btn_generate": "生成标定点",
|
||||
"btn_simulate": "仿真",
|
||||
"btn_dry_run": "干跑(不接触)",
|
||||
"btn_save": "保存序列…",
|
||||
"btn_load": "载入序列…",
|
||||
"btn_export": "导出序列…",
|
||||
"btn_import": "导入序列…",
|
||||
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
|
||||
"export_failed": "导出失败: {error}",
|
||||
"import_failed": "导入失败: {error}",
|
||||
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
@@ -234,6 +239,16 @@
|
||||
"col_actions": "操作",
|
||||
"not_generated": "—",
|
||||
"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": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
||||
},
|
||||
"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 ..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)``.
|
||||
#: ``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
|
||||
|
||||
#: 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], ...] = (
|
||||
("index", "sequence.col_index"),
|
||||
("x", "sequence.col_x"),
|
||||
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("status", "sequence.col_status"),
|
||||
("sensitivity", "sequence.col_sensitivity"),
|
||||
("samples", "sequence.col_samples"),
|
||||
("actions", "sequence.col_actions"),
|
||||
)
|
||||
|
||||
#: 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, ...] = ()
|
||||
|
||||
#: 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.
|
||||
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).
|
||||
PENDING_PHASES: dict[str, str] = {
|
||||
"generate": "P5",
|
||||
"simulate": "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.
|
||||
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
||||
("on_surface", "sequence.check_surface"),
|
||||
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
|
||||
generateRequested = Signal()
|
||||
simulateRequested = Signal()
|
||||
dryRunRequested = Signal()
|
||||
saveRequested = Signal()
|
||||
loadRequested = Signal()
|
||||
exportRequested = Signal()
|
||||
importRequested = Signal()
|
||||
|
||||
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
|
||||
self._choices: dict[str, QComboBox] = {}
|
||||
self._pending_buttons: dict[str, QPushButton] = {}
|
||||
self._check_rows: dict[str, QLabel] = {}
|
||||
self._summary: dict[str, Any] = {}
|
||||
self._dirty = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
|
||||
|
||||
self.set_profile(profile or {})
|
||||
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
|
||||
|
||||
def _build_profile(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
grid = QGridLayout(box)
|
||||
grid.setContentsMargins(10, 8, 10, 8)
|
||||
outer = QVBoxLayout(box)
|
||||
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.setVerticalSpacing(4)
|
||||
outer.addLayout(grid)
|
||||
|
||||
# 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.
|
||||
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
|
||||
column.addWidget(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["simulate"].clicked.connect(self.simulateRequested)
|
||||
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
|
||||
self._pending_buttons["save"].clicked.connect(self.saveRequested)
|
||||
self._pending_buttons["load"].clicked.connect(self.loadRequested)
|
||||
# None of these can run: generation, simulation, the runner and the
|
||||
# dataset writer belong to later phases (see docs/workflow.md).
|
||||
self._live_buttons["export"].clicked.connect(self.exportRequested)
|
||||
self._live_buttons["import"].clicked.connect(self.importRequested)
|
||||
# Generation, simulation and the runner belong to later phases (P5-P7).
|
||||
for button in self._pending_buttons.values():
|
||||
button.setEnabled(False)
|
||||
|
||||
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
|
||||
|
||||
# -------------------------------------------------------------- point table
|
||||
|
||||
def _mark_dirty(self, *_args: Any) -> None:
|
||||
self._dirty = True
|
||||
self.refresh_summary()
|
||||
|
||||
def _build_point_table(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
|
||||
4: t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column_index, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
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.summary_label)
|
||||
|
||||
self.point_hint = QLabel(box)
|
||||
self.point_hint.setObjectName("hintLabel")
|
||||
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
|
||||
4: status or t("sequence.status_pending"),
|
||||
5: "—",
|
||||
6: "—",
|
||||
7: "",
|
||||
}
|
||||
for column, text in values.items():
|
||||
item = QTableWidgetItem(text)
|
||||
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
|
||||
self.table.setItem(row, column, item)
|
||||
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]:
|
||||
"""The steps the table describes, for the future runner.
|
||||
|
||||
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
|
||||
for key, combo in self._choices.items():
|
||||
_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_hint.setText(t("sequence.preview_hint"))
|
||||
for key, _locale_key in VALIDATION_CHECKS:
|
||||
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
|
||||
self._pending_buttons[key].setToolTip(
|
||||
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])
|
||||
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")):
|
||||
item.setText(t("sequence.status_pending"))
|
||||
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:
|
||||
|
||||
@@ -314,6 +314,11 @@ class RunBar(QFrame):
|
||||
self.step_label.setMinimumWidth(140)
|
||||
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.setTextVisible(False)
|
||||
self.progress.setFixedHeight(12)
|
||||
@@ -395,6 +400,14 @@ class RunBar(QFrame):
|
||||
self.abort_button.setEnabled(self._has_run)
|
||||
self.pause_button.setEnabled(self._has_run and 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
|
||||
def phase(self) -> str:
|
||||
@@ -415,6 +428,7 @@ class RunBar(QFrame):
|
||||
else:
|
||||
self.step_label.setText(t("run.no_steps"))
|
||||
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:
|
||||
|
||||
@@ -38,6 +38,14 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
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_readout import AxisReadoutPanel
|
||||
from .brand_bar import BrandBar
|
||||
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
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.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(text)
|
||||
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:
|
||||
"""Style the status bar from the theme rather than Qt's default.
|
||||
|
||||
|
||||
@@ -66,6 +66,21 @@ def _scratch_dir() -> 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
|
||||
def make_window(qt_app, monkeypatch):
|
||||
"""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
|
||||
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
|
||||
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
||||
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
|
||||
import no Qt binding at all;
|
||||
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
|
||||
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 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
|
||||
closed-source tool needs no licence purchase. See ``README.md``.
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
|
||||
needs no licence purchase. See ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,13 +28,19 @@ from pathlib import Path
|
||||
APP_SRC = Path(__file__).resolve().parents[1] / "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"}
|
||||
|
||||
#: The one binding allowed, and where it may be named.
|
||||
ALLOWED_BINDING = "PySide6"
|
||||
|
||||
#: 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 = {
|
||||
"Qt": (
|
||||
"AlignmentFlag",
|
||||
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
|
||||
"ToolButtonStyle",
|
||||
"FocusPolicy",
|
||||
"ScrollBarPolicy",
|
||||
"CursorShape",
|
||||
"MouseButton",
|
||||
"Key",
|
||||
"PenStyle",
|
||||
"GlobalColor",
|
||||
"ItemFlag",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
"QDialog": ("DialogCode",),
|
||||
"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.
|
||||
|
||||
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
|
||||
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
|
||||
but is a static 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.
|
||||
in a comment or docstring (which is exactly where the *unscoped* spelling gets
|
||||
explained, so a rewrite would destroy the explanation — this file quotes it
|
||||
three times, which is why the migration scripts skip it), and
|
||||
``QMessageBox.information(...)`` looks like an enumerator but is a static
|
||||
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"))
|
||||
called = {
|
||||
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
def test_shared_imports_no_qt_binding():
|
||||
"""Shared code serves both products, so it must not pick a side."""
|
||||
def test_shared_imports_no_forbidden_binding():
|
||||
"""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 = []
|
||||
for path in _python_files(SHARED_SRC):
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
||||
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
|
||||
from conftest import pump
|
||||
|
||||
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
|
||||
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."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.pending_controls() == [
|
||||
"generate",
|
||||
"simulate",
|
||||
"dry_run",
|
||||
"save",
|
||||
"load",
|
||||
]
|
||||
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
|
||||
for name, phase in panel.pending_phases().items():
|
||||
button = panel._pending_buttons[name]
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
"""A runner must not be able to start from points nothing has validated."""
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
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": "全部停止",
|
||||
"run_title": "运行",
|
||||
"run_phase": "空闲",
|
||||
"run_step": "第 0/0 点",
|
||||
"run_time": "已用 0:00 · 预计剩余 —",
|
||||
"run_idle_hint": "尚未生成标定点",
|
||||
|
||||
"run_run": "运行",
|
||||
"run_pause": "暂停",
|
||||
"run_abort": "中止",
|
||||
@@ -129,6 +129,9 @@ STRINGS = {
|
||||
"seq_preview": "预览",
|
||||
"seq_validation": "校验",
|
||||
"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": [
|
||||
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
|
||||
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
|
||||
@@ -140,12 +143,12 @@ STRINGS = {
|
||||
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
|
||||
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
|
||||
"seq_checks_pending": "尚未生成标定点。",
|
||||
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"],
|
||||
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
|
||||
"point_rows": [
|
||||
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
],
|
||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
||||
"sensor_note": "设备与信号源、实时图像、力曲线",
|
||||
@@ -169,8 +172,8 @@ STRINGS = {
|
||||
"stop_all": "Stop all",
|
||||
"run_title": "Run",
|
||||
"run_phase": "Idle",
|
||||
"run_step": "Point 0/0",
|
||||
"run_time": "Elapsed 0:00 · about — left",
|
||||
"run_idle_hint": "No points generated",
|
||||
|
||||
"run_run": "Run",
|
||||
"run_pause": "Pause",
|
||||
"run_abort": "Abort",
|
||||
@@ -221,6 +224,9 @@ STRINGS = {
|
||||
"seq_preview": "Preview",
|
||||
"seq_validation": "Checks",
|
||||
"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": [
|
||||
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.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_checks": ["On the surface", "Within travel", "Contact confirmable"],
|
||||
"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)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"],
|
||||
"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"],
|
||||
"point_rows": [
|
||||
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||
["2", "2.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.",
|
||||
"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)
|
||||
p_inner = _panel(painter, 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
|
||||
for index, (label, value) in enumerate(s["seq_fields"]):
|
||||
row, side = divmod(index, 2)
|
||||
x = p_inner.x() + side * column_w
|
||||
y = p_inner.y() + 20 + row * 26
|
||||
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True)
|
||||
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette)
|
||||
row_y = y + row * 26
|
||||
_text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
|
||||
_field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
|
||||
|
||||
# --- preview
|
||||
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)
|
||||
t_inner = _panel(painter, 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),)
|
||||
header_h = 28
|
||||
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):
|
||||
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
|
||||
x += 96
|
||||
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10)
|
||||
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)
|
||||
_text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=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)
|
||||
|
||||
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
|
||||
python apps/autocal/tools/panel_check.py
|
||||
|
||||
No controller, no DLL and no display are needed. Mirrors
|
||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
|
||||
PyQt5, and AutoCal's environment has none.
|
||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
|
||||
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -218,8 +218,8 @@ def main() -> int:
|
||||
"the sequence point list is empty",
|
||||
)
|
||||
checker.that(
|
||||
sequence.table.columnCount() == 8,
|
||||
f"the point list has {sequence.table.columnCount()} columns, expected 8",
|
||||
sequence.table.columnCount() == len(sequence.column_keys()),
|
||||
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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Orisys-Tech" }]
|
||||
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>=0.5",
|
||||
"scipy>=1.10.1",
|
||||
|
||||
@@ -9,11 +9,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QIcon, QImage
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction,
|
||||
QActionGroup,
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
|
||||
root.addWidget(self.toolbar)
|
||||
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.arrow_panel = ArrowPanel(theme=self._theme)
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
|
||||
# convention everyone's hands already know.
|
||||
self.act_play = QAction(self)
|
||||
self.act_play.setShortcut("Space")
|
||||
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
|
||||
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
||||
self.addAction(self.act_play)
|
||||
|
||||
self.view_menu = menu_bar.addMenu("")
|
||||
@@ -1402,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._persist_prefs()
|
||||
|
||||
def _show_about(self) -> None:
|
||||
AboutDialog(self).exec_()
|
||||
AboutDialog(self).exec()
|
||||
|
||||
def _on_camera_id_changed(self, value: int) -> None:
|
||||
self.prefs["camera_id"] = str(int(value))
|
||||
@@ -1507,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
|
||||
filters_locked=bool(self._running or self.recorder.is_recording),
|
||||
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
||||
)
|
||||
if dialog.exec_() != PlotSettingsDialog.Accepted:
|
||||
if dialog.exec() != PlotSettingsDialog.Accepted:
|
||||
return
|
||||
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
||||
self.display_ctl.apply_plot_settings(
|
||||
@@ -1589,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
|
||||
sensor_type=sensor_type,
|
||||
config=config_ref,
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.recorder.log_event(
|
||||
"calibration_apply_blocked", "; ".join(reasons)
|
||||
)
|
||||
@@ -1602,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self,
|
||||
t("cal.apply_title"),
|
||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.statusBar().showMessage(t("cal.apply_skipped"))
|
||||
return
|
||||
worker = self._worker_for(sensor_type)
|
||||
@@ -1720,7 +1718,18 @@ class StudioMainWindow(QMainWindow):
|
||||
if getattr(self, "act_run", None) is None:
|
||||
return
|
||||
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_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
|
||||
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.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()
|
||||
return
|
||||
if not self._session_active:
|
||||
@@ -2348,7 +2363,7 @@ class StudioMainWindow(QMainWindow):
|
||||
geo = self.prefs.get("window_geometry")
|
||||
if isinstance(geo, str) and geo:
|
||||
try:
|
||||
from PyQt5.QtCore import QByteArray
|
||||
from PySide6.QtCore import QByteArray
|
||||
|
||||
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
||||
except Exception:
|
||||
|
||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from PyQt5.QtCore import QObject
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import logging
|
||||
import os
|
||||
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.compat import check_shared_compat
|
||||
@@ -85,7 +90,7 @@ def main() -> int:
|
||||
language,
|
||||
gl_profile,
|
||||
)
|
||||
code = int(app.exec_())
|
||||
code = int(app.exec())
|
||||
|
||||
# Leave the process immediately.
|
||||
#
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
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.record import RecordingReader, VideoOnlySource
|
||||
@@ -47,13 +47,13 @@ class ReplayEngine(QObject):
|
||||
"""Open one recording and answer "what is at t?" for every view."""
|
||||
|
||||
#: 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.
|
||||
frames_changed = pyqtSignal()
|
||||
frames_changed = Signal()
|
||||
#: 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).
|
||||
recording_changed = pyqtSignal(bool)
|
||||
recording_changed = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -28,8 +28,8 @@ from pathlib import Path
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
from PyQt5.QtWidgets import QFileDialog
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
@@ -33,7 +33,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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.scene3d.contracts import (
|
||||
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
|
||||
selection the operator has already moved away from.
|
||||
"""
|
||||
|
||||
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
|
||||
failed = pyqtSignal(object, int) # Diagnostic, request id
|
||||
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||
failed = Signal(object, int) # Diagnostic, request id
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
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
|
||||
capacity-one mailbox), and the design document defers anti-aliasing work.
|
||||
"""
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
fmt = QSurfaceFormat()
|
||||
if profile == "gles30":
|
||||
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
|
||||
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
|
||||
fmt.setVersion(3, 0)
|
||||
elif profile == "desktop33":
|
||||
fmt.setRenderableType(QSurfaceFormat.OpenGL)
|
||||
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||||
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
|
||||
raise ValueError(f"unknown GL profile: {profile!r}")
|
||||
fmt.setDepthBufferSize(24)
|
||||
fmt.setStencilBufferSize(8)
|
||||
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
|
||||
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||
fmt.setSamples(0)
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
|
||||
@@ -442,7 +442,10 @@ class GLDispatch:
|
||||
if context is None or not context.isValid():
|
||||
raise error("gl_unsupported", "no current OpenGL context")
|
||||
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:
|
||||
vendor = _gl_string(GL, GL.GL_VENDOR)
|
||||
renderer = _gl_string(GL, GL.GL_RENDERER)
|
||||
@@ -454,7 +457,7 @@ class GLDispatch:
|
||||
"gl_unsupported",
|
||||
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
from PyQt5.QtGui import QGuiApplication
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
platform_name = ""
|
||||
try:
|
||||
|
||||
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
|
||||
self.setObjectName("colorScaleBar")
|
||||
self.setFixedHeight(12)
|
||||
self.setMinimumWidth(120)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.setVisible(False)
|
||||
self._lut_id = "sequential"
|
||||
|
||||
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
|
||||
self.update()
|
||||
|
||||
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
|
||||
|
||||
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||
# which is what QImage's borrowed buffer needs.
|
||||
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
|
||||
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.drawImage(self.rect(), image)
|
||||
painter.end()
|
||||
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
|
||||
class ThreeDPanel(QFrame):
|
||||
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
||||
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
settingsChanged = pyqtSignal(object) # DisplaySettings
|
||||
resetRequested = pyqtSignal()
|
||||
presetRequested = pyqtSignal(str)
|
||||
enabledChanged = Signal(bool)
|
||||
settingsChanged = Signal(object) # DisplaySettings
|
||||
resetRequested = Signal()
|
||||
presetRequested = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -299,13 +299,13 @@ class ThreeDPanel(QFrame):
|
||||
scale_row.setSpacing(6)
|
||||
self.scale_lower_label = QLabel()
|
||||
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)
|
||||
self.scale_bar = ColorScaleBar()
|
||||
scale_row.addWidget(self.scale_bar, 1)
|
||||
self.scale_upper_label = QLabel()
|
||||
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)
|
||||
root.addLayout(scale_row)
|
||||
|
||||
@@ -327,7 +327,7 @@ class ThreeDPanel(QFrame):
|
||||
self.source_label = ShrinkableLabel()
|
||||
self.source_label.setObjectName("panelMeta")
|
||||
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)
|
||||
root.addLayout(legend_row)
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
|
||||
root.addWidget(panel.controls_widget)
|
||||
panel.controls_widget.setVisible(True)
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
self.buttons.rejected.connect(self.close)
|
||||
self.buttons.accepted.connect(self.close)
|
||||
root.addWidget(self.buttons)
|
||||
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
|
||||
self.hint.setText(t("scene3d.settings_hint"))
|
||||
# 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.
|
||||
close_button = self.buttons.button(QDialogButtonBox.Close)
|
||||
close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close)
|
||||
if close_button is not None:
|
||||
close_button.setText(t("dialog.close"))
|
||||
|
||||
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
|
||||
|
||||
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
"""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()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
@@ -28,8 +28,8 @@ import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
|
||||
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:
|
||||
import OpenGL # noqa: F401
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
|
||||
class Scene3DView(QWidget):
|
||||
"""One 3D pane: the GL widget on top, its controls underneath."""
|
||||
|
||||
stateChanged = pyqtSignal(object) # SceneState
|
||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
||||
renderStatsChanged = pyqtSignal(object) # RenderStats
|
||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
stateChanged = Signal(object) # SceneState
|
||||
diagnosticRaised = Signal(object) # Diagnostic
|
||||
renderStatsChanged = Signal(object) # RenderStats
|
||||
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -19,9 +19,11 @@ from __future__ import annotations
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QMouseEvent, QWheelEvent
|
||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
|
||||
class Sensor3DWidget(QOpenGLWidget):
|
||||
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
||||
|
||||
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
|
||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
||||
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
|
||||
capabilitiesChanged = Signal(object) # GLCapabilities
|
||||
diagnosticRaised = Signal(object) # Diagnostic
|
||||
renderStatsChanged = Signal(object) # RenderStats, rate-limited
|
||||
#: 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
|
||||
#: cannot inherit the previous frame's range.
|
||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
||||
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
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
|
||||
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()``
|
||||
stays ``None`` — so the first paint asks it and turns "no context" into the
|
||||
state the rest of the application already knows how to show.
|
||||
view that has no data yet. Qt does answer the question, though, so the first
|
||||
paint asks it and turns "no context" into the state the rest of the application
|
||||
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)
|
||||
if self._unavailable or self._capabilities is not None:
|
||||
return
|
||||
if self.context() is None:
|
||||
context = self.context()
|
||||
if context is None or not context.isValid():
|
||||
self._fail(
|
||||
diagnostic(
|
||||
"gl_unsupported",
|
||||
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
|
||||
# --------------------------------------------------------------- interaction
|
||||
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)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
if self._last_press is None:
|
||||
super().mouseMoveEvent(event)
|
||||
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])
|
||||
self._last_press = (x, y)
|
||||
viewport = (max(self.width(), 1), max(self.height(), 1))
|
||||
buttons = event.buttons()
|
||||
panning = bool(buttons & Qt.MiddleButton) or bool(
|
||||
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
|
||||
panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
|
||||
buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
||||
)
|
||||
if panning:
|
||||
self._camera.pan(delta, viewport)
|
||||
elif buttons & Qt.LeftButton:
|
||||
elif buttons & Qt.MouseButton.LeftButton:
|
||||
self._camera.orbit(delta, viewport)
|
||||
self.update()
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import logging
|
||||
import time
|
||||
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_studio import session_metadata
|
||||
@@ -47,11 +47,11 @@ class SessionController(QObject):
|
||||
"""Owns the acquisition session's state and its UI-free mechanics."""
|
||||
|
||||
#: 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,
|
||||
#: and an idempotent repaint is cheaper than a second source of truth.
|
||||
phaseChanged = pyqtSignal(str)
|
||||
runningChanged = pyqtSignal(bool)
|
||||
phaseChanged = Signal(str)
|
||||
runningChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
|
||||
logo = QLabel()
|
||||
pix = QPixmap(str(logo_topbar_path()))
|
||||
if not pix.isNull():
|
||||
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
||||
logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
|
||||
row.addWidget(logo)
|
||||
row.addStretch(1)
|
||||
root.addLayout(row)
|
||||
|
||||
self.body = QLabel()
|
||||
self.body.setWordWrap(True)
|
||||
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
root.addWidget(self.body)
|
||||
|
||||
close_btn = QPushButton("OK")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
||||
root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
|
||||
@@ -12,9 +12,9 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
|
||||
"""
|
||||
rgb = frame[:, :, ::-1].copy()
|
||||
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 = {
|
||||
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._aspect = DEFAULT_FRAME_ASPECT
|
||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
policy.setHeightForWidth(True)
|
||||
self.setSizePolicy(policy)
|
||||
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
||||
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
|
||||
self.setObjectName("colorBar")
|
||||
self.setFixedHeight(_COLORBAR_HEIGHT)
|
||||
self.setMinimumWidth(_COLORBAR_WIDTH)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.setVisible(False)
|
||||
|
||||
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
|
||||
# the whole call, which is what QImage's borrowed buffer needs.
|
||||
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.drawImage(self.rect(), image)
|
||||
painter.end()
|
||||
|
||||
|
||||
class ArrowPanel(QFrame):
|
||||
view_mode_changed = pyqtSignal(str)
|
||||
view_mode_changed = Signal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
|
||||
layout.addLayout(header)
|
||||
|
||||
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.setWordWrap(True)
|
||||
# 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()
|
||||
if deg:
|
||||
# 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(
|
||||
self.image_label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.image_label.setPixmap(scaled)
|
||||
self.image_label.setText("")
|
||||
|
||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
|
||||
if source.isNull():
|
||||
return QPixmap()
|
||||
scaled = source.scaled(
|
||||
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||
size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
out = QPixmap(scaled.size())
|
||||
out.fill(Qt.transparent)
|
||||
out.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(out)
|
||||
painter.drawPixmap(0, 0, scaled)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
|
||||
painter.fillRect(out.rect(), QColor(tint))
|
||||
painter.end()
|
||||
return out
|
||||
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
|
||||
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
||||
|
||||
#: The user asked for the live view.
|
||||
live_requested = pyqtSignal()
|
||||
live_requested = Signal()
|
||||
#: The user asked for the history/replay view.
|
||||
playback_requested = pyqtSignal()
|
||||
playback_requested = Signal()
|
||||
#: The user asked for the calibration view.
|
||||
calibration_requested = pyqtSignal()
|
||||
calibration_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
|
||||
self.logo = QLabel()
|
||||
self.logo.setObjectName("brandLogo")
|
||||
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
||||
self.logo.setAlignment(Qt.AlignCenter)
|
||||
self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
# The lockup keeps its two labels (the product's tagline stays available
|
||||
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
|
||||
|
||||
self.product = QLabel()
|
||||
self.product.setObjectName("brandProduct")
|
||||
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
layout.addWidget(self.product)
|
||||
|
||||
self.set_theme(theme)
|
||||
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
|
||||
def _nav_button(self) -> QPushButton:
|
||||
button = QPushButton()
|
||||
button.setObjectName("navButton")
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setFlat(True)
|
||||
button.setCheckable(True)
|
||||
return button
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
@@ -60,11 +60,11 @@ def _mono_font() -> str:
|
||||
|
||||
|
||||
class CalPanel(QFrame):
|
||||
status_message = pyqtSignal(str)
|
||||
status_message = Signal(str)
|
||||
#: 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.
|
||||
apply_requested = pyqtSignal(str)
|
||||
apply_requested = Signal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
|
||||
outer.setSpacing(0)
|
||||
self.scroll = QScrollArea()
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setFrameShape(QFrame.NoFrame)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
content = QWidget()
|
||||
self.scroll.setWidget(content)
|
||||
outer.addWidget(self.scroll)
|
||||
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self._weight_label = QLabel()
|
||||
self._weight_label.setObjectName("fieldLabel")
|
||||
self.weight_spin = QDoubleSpinBox()
|
||||
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
|
||||
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
||||
# stability measure and the point's state.
|
||||
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setShowGrid(False)
|
||||
header = self.table.horizontalHeader()
|
||||
# Every column takes the width its own header and cells need: eliding a
|
||||
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
||||
# panel scrolls instead.
|
||||
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setMinimumSectionSize(64)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
|
||||
):
|
||||
# The action row must survive a narrow three-panel layout.
|
||||
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_validation.clicked.connect(self._on_toggle_validation)
|
||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
|
||||
)
|
||||
for column, text in enumerate(values):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
if column in (0, 1, 2, 3):
|
||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
||||
item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
|
||||
else:
|
||||
item.setTextAlignment(int(Qt.AlignCenter))
|
||||
item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
|
||||
if point.excluded:
|
||||
item.setForeground(QColor(color(self._theme, "text_muted")))
|
||||
elif point.is_validation:
|
||||
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
|
||||
"""Double click a row to inspect that point's three-axis curve."""
|
||||
dialog = self.point_dialog(self._current_point())
|
||||
if dialog is not None:
|
||||
dialog.exec_()
|
||||
dialog.exec()
|
||||
|
||||
def _on_toggle_excluded(self) -> None:
|
||||
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
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
|
||||
root.addWidget(self.plot_widget, 1)
|
||||
self._build_traces()
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize
|
||||
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
||||
from PySide6.QtCore import QSize
|
||||
from PySide6.QtWidgets import QLabel, QSizePolicy
|
||||
|
||||
|
||||
class ShrinkableLabel(QLabel):
|
||||
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
|
||||
|
||||
def __init__(self, text: str = "", parent=None) -> None:
|
||||
super().__init__(text, parent)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
self.setMinimumWidth(0)
|
||||
|
||||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
||||
|
||||
@@ -5,9 +5,9 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QSize, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -116,18 +116,18 @@ class _Sync:
|
||||
|
||||
class ForcePlotsPanel(QFrame):
|
||||
#: 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.
|
||||
cursor_moved = pyqtSignal(float)
|
||||
cursor_moved = Signal(float)
|
||||
#: "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.
|
||||
unit_toggle_requested = pyqtSignal()
|
||||
unit_toggle_requested = Signal()
|
||||
#: 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
|
||||
#: 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:
|
||||
super().__init__(parent)
|
||||
@@ -217,7 +217,7 @@ class ForcePlotsPanel(QFrame):
|
||||
self.unit_badge = QPushButton()
|
||||
self.unit_badge.setObjectName("unitBadge")
|
||||
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)
|
||||
header.addWidget(self.unit_badge)
|
||||
root.addLayout(header)
|
||||
@@ -329,7 +329,7 @@ class ForcePlotsPanel(QFrame):
|
||||
self.window_label.setObjectName("panelMeta")
|
||||
self.stats_label = QLabel()
|
||||
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_label)
|
||||
browse_row.addStretch(1)
|
||||
@@ -348,7 +348,7 @@ class ForcePlotsPanel(QFrame):
|
||||
button.setProperty("iconOnly", "true")
|
||||
button.setIcon(icons.icon(icon_name, self._theme))
|
||||
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
|
||||
return button
|
||||
|
||||
@@ -389,7 +389,7 @@ class ForcePlotsPanel(QFrame):
|
||||
value_row.setSpacing(5)
|
||||
value_label = QLabel()
|
||||
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))
|
||||
# Never a fixed height: the assertion that a value is not clipped is
|
||||
# derived from the font it is actually drawn with.
|
||||
|
||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from PyQt5.QtCore import QRectF, Qt
|
||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
from PySide6.QtCore import QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
|
||||
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:
|
||||
"""A dashed frame: drag a region to zoom into it."""
|
||||
pen = painter.pen()
|
||||
pen.setStyle(Qt.DashLine)
|
||||
pen.setStyle(Qt.PenStyle.DashLine)
|
||||
pen.setDashPattern([2.2, 1.6])
|
||||
painter.setPen(pen)
|
||||
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:
|
||||
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
||||
canvas = QPixmap(size, size)
|
||||
canvas.fill(Qt.transparent)
|
||||
canvas.fill(Qt.GlobalColor.transparent)
|
||||
paint = _PAINTERS.get(str(name))
|
||||
if paint is None:
|
||||
return canvas
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
pen = QPen()
|
||||
pen.setWidthF(_PEN_WIDTH)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
pen.setColor(QColor(tint))
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
try:
|
||||
paint(painter, size)
|
||||
finally:
|
||||
|
||||
@@ -16,8 +16,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
#: 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.
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
|
||||
self.btn_reset_view = QPushButton()
|
||||
self.btn_reset_view.setProperty("compact", "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)
|
||||
header.addWidget(self.btn_reset_view)
|
||||
|
||||
self.btn_settings = QPushButton()
|
||||
self.btn_settings.setProperty("compact", "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)
|
||||
header.addWidget(self.btn_settings)
|
||||
root.addLayout(header)
|
||||
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
self.message = QLabel()
|
||||
self.message.setObjectName("hintText")
|
||||
self.message.setAlignment(Qt.AlignCenter)
|
||||
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.message.setWordWrap(True)
|
||||
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 PyQt5.QtWidgets import (
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
|
||||
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||
buttons_row.addWidget(self.btn_reset)
|
||||
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.rejected.connect(self.reject)
|
||||
buttons_row.addWidget(self.button_box)
|
||||
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
|
||||
self.rate_label.setText(t("dialog.filter_rate"))
|
||||
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
||||
self._refresh_filter_note()
|
||||
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
||||
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
||||
ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
|
||||
cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
|
||||
if ok_btn is not None:
|
||||
ok_btn.setText(t("dialog.ok"))
|
||||
if cancel_btn is not None:
|
||||
|
||||
@@ -14,7 +14,7 @@ timer without touching the writer thread.
|
||||
|
||||
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.theme import color
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
|
||||
def _section_rule() -> QFrame:
|
||||
rule = QFrame()
|
||||
rule.setObjectName("sectionSeparator")
|
||||
rule.setFrameShape(QFrame.HLine)
|
||||
rule.setFrameShape(QFrame.Shape.HLine)
|
||||
rule.setFixedHeight(1)
|
||||
return rule
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
|
||||
|
||||
|
||||
class SensorPickerWidget(QWidget):
|
||||
sensor_type_changed = pyqtSignal(str)
|
||||
config_changed = pyqtSignal(str)
|
||||
sensor_type_changed = Signal(str)
|
||||
config_changed = Signal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
|
||||
#: attribute: it states which sensor the whole window is configured for.
|
||||
self.thumb_label = QLabel()
|
||||
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".
|
||||
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
||||
self.thumb_label.setProperty("rail", "true")
|
||||
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||
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():
|
||||
self.type_combo.addItem(name, name)
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||
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)
|
||||
|
||||
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 PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
|
||||
def _separator() -> QFrame:
|
||||
line = QFrame()
|
||||
line.setObjectName("sectionSeparator")
|
||||
line.setFrameShape(QFrame.VLine)
|
||||
line.setFrameShape(QFrame.Shape.VLine)
|
||||
line.setFixedWidth(1)
|
||||
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||
return line
|
||||
|
||||
|
||||
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
|
||||
|
||||
|
||||
class ToolbarWidget(QWidget):
|
||||
search_clicked = pyqtSignal()
|
||||
preview_toggled = pyqtSignal(bool)
|
||||
camera_id_changed = pyqtSignal(int)
|
||||
search_clicked = Signal()
|
||||
preview_toggled = Signal(bool)
|
||||
camera_id_changed = Signal(int)
|
||||
#: 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.
|
||||
use_camera_clicked = pyqtSignal()
|
||||
use_camera_clicked = Signal()
|
||||
#: 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).
|
||||
recompute_clicked = pyqtSignal()
|
||||
run_toggled = pyqtSignal(bool)
|
||||
reset_clicked = pyqtSignal()
|
||||
units_changed = pyqtSignal(str)
|
||||
record_toggled = pyqtSignal(bool)
|
||||
recompute_clicked = Signal()
|
||||
run_toggled = Signal(bool)
|
||||
reset_clicked = Signal()
|
||||
units_changed = Signal(str)
|
||||
record_toggled = Signal(bool)
|
||||
#: The settings area was expanded or collapsed by the user.
|
||||
settings_toggled = pyqtSignal(bool)
|
||||
settings_toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
|
||||
# narrow window.
|
||||
self._settings_scroll = QScrollArea()
|
||||
self._settings_scroll.setWidgetResizable(True)
|
||||
self._settings_scroll.setFrameShape(QFrame.NoFrame)
|
||||
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.settings_page = self._build_settings()
|
||||
self._settings_scroll.setWidget(self.settings_page)
|
||||
outer.addWidget(self._settings_scroll)
|
||||
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
|
||||
|
||||
self.status_chip = QLabel()
|
||||
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.setObjectName("panelMeta")
|
||||
self.calibration_label = ShrinkableLabel()
|
||||
|
||||
@@ -24,7 +24,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from PyQt5.QtCore import QObject
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
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
|
||||
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
|
||||
@@ -16,11 +20,30 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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:
|
||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||
if _fonts.is_dir():
|
||||
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
|
||||
# truth between the pytest suite and the dependency-free checker.
|
||||
_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")
|
||||
def qt_app():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
@@ -83,7 +106,7 @@ def visible_descendants(widget):
|
||||
|
||||
def show_window(window, size):
|
||||
"""Show a window at ``size`` and flush pending Qt events."""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
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
|
||||
conversion and the stride trim are both load-bearing.
|
||||
"""
|
||||
from PyQt5.QtGui import QImage
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
widget.repaint()
|
||||
qt_app.processEvents()
|
||||
image = widget.grabFramebuffer()
|
||||
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()
|
||||
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()
|
||||
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
||||
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
|
||||
|
||||
import pytest
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
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.
|
||||
class _View(QWidget):
|
||||
diagnosticRaised = pyqtSignal(object)
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
diagnosticRaised = Signal(object)
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(self) -> None:
|
||||
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):
|
||||
"""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.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):
|
||||
"""视图 ▸ 三维显示设置… 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.show()
|
||||
|
||||
@@ -19,7 +19,7 @@ import pytest
|
||||
@pytest.fixture
|
||||
def restore_surface_format():
|
||||
"""The default surface format is process-wide; put it back afterwards."""
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
previous = QSurfaceFormat.defaultFormat()
|
||||
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):
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
desktop = QSurfaceFormat.defaultFormat()
|
||||
assert desktop.renderableType() == QSurfaceFormat.OpenGL
|
||||
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
|
||||
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
||||
assert desktop.profile() == QSurfaceFormat.CoreProfile
|
||||
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
|
||||
assert desktop.depthBufferSize() == 24
|
||||
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
||||
|
||||
configure_surface_format("gles30")
|
||||
embedded = QSurfaceFormat.defaultFormat()
|
||||
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
|
||||
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
|
||||
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -29,8 +29,8 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
import ui_check
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
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.
|
||||
"""
|
||||
|
||||
diagnosticRaised = pyqtSignal(object)
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
diagnosticRaised = Signal(object)
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -559,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
|
||||
loaded: list = []
|
||||
failed: list = []
|
||||
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:
|
||||
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
|
||||
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")
|
||||
loader = SceneAssetLoader()
|
||||
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:
|
||||
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
|
||||
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))
|
||||
for i in range(60):
|
||||
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()
|
||||
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")
|
||||
|
||||
window.toolbar.btn_settings.setChecked(True)
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
QApplication.processEvents()
|
||||
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
|
||||
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._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):
|
||||
"""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))
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -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):
|
||||
"""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)
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||
|
||||
def test_the_source_picker_follows_the_view(make_window):
|
||||
"""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)
|
||||
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):
|
||||
from PyQt5.QtWidgets import QHeaderView
|
||||
from PySide6.QtWidgets import QHeaderView
|
||||
|
||||
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
|
||||
window.brand_bar.btn_calibration.click()
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
for _ in range(3):
|
||||
QApplication.processEvents()
|
||||
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
||||
cal = window.cal_panel
|
||||
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
||||
assert (
|
||||
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
|
||||
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
|
||||
), "the diagnostic columns can still be elided"
|
||||
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
||||
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.brand_bar.btn_live.click()
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
QApplication.processEvents()
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -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_studio.ui.force_plots import unit_display
|
||||
|
||||
from PyQt5.QtWidgets import QPushButton
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
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
|
||||
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.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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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."""
|
||||
import time
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
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
|
||||
):
|
||||
"""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.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
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
"""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.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):
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
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 numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
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 numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
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 numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
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
|
||||
):
|
||||
"""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
|
||||
|
||||
@@ -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(
|
||||
make_window, replay_recording
|
||||
):
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
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 json
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
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
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
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:
|
||||
"""True when ``widget`` lives inside a QScrollArea's content."""
|
||||
from PyQt5.QtWidgets import QScrollArea
|
||||
from PySide6.QtWidgets import QScrollArea
|
||||
|
||||
node = widget
|
||||
while node is not None:
|
||||
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
|
||||
|
||||
|
||||
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.i18n import set_locale, t
|
||||
@@ -1491,7 +1491,7 @@ def main() -> int:
|
||||
window.brand_bar.btn_calibration.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
|
||||
from PySide6.QtWidgets import QHeaderView as _QHeaderView
|
||||
|
||||
sizes = window.content_splitter.sizes()
|
||||
check(
|
||||
@@ -2000,7 +2000,7 @@ def main() -> int:
|
||||
"import os, sys\n"
|
||||
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\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"
|
||||
"show = studio_main.StudioMainWindow.show\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.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 (
|
||||
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()}
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
+13
-4
@@ -24,7 +24,7 @@ dist/
|
||||
OrisysStudio/
|
||||
OrisysStudio.exe
|
||||
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
|
||||
@@ -137,11 +137,20 @@ loaded" can only be printed if those modules imported.)
|
||||
|
||||
## Dependency notes
|
||||
|
||||
- App shared dep uses **`opencv-python-headless`** (PyQt 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`.
|
||||
- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
|
||||
GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
|
||||
(`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.
|
||||
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
|
||||
Qt `QOpenGLWidget` + PyOpenGL.
|
||||
- `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
|
||||
# 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
|
||||
# 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
|
||||
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
|
||||
@@ -15,9 +19,10 @@
|
||||
PyInstaller==6.22.2
|
||||
pyinstaller-hooks-contrib==2026.7
|
||||
PyOpenGL==3.1.10
|
||||
PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
PyQt5-sip==12.19.0
|
||||
PySide6==6.11.2
|
||||
PySide6-Essentials==6.11.2
|
||||
PySide6-Addons==6.11.2
|
||||
shiboken6==6.11.2
|
||||
numpy==2.2.6
|
||||
scipy==1.15.3
|
||||
numba==0.67.0
|
||||
|
||||
@@ -79,7 +79,12 @@ datas = _branding_datas()
|
||||
binaries: list = _extra_runtime_binaries()
|
||||
hiddenimports = [
|
||||
"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",
|
||||
"scipy.signal",
|
||||
"numba",
|
||||
@@ -126,22 +131,50 @@ a = Analysis(
|
||||
"jupyter",
|
||||
"notebook",
|
||||
"pytest",
|
||||
"PyQt5.QtBluetooth",
|
||||
"PyQt5.QtNfc",
|
||||
"PyQt5.QtPositioning",
|
||||
"PyQt5.QtLocation",
|
||||
"PyQt5.QtMultimedia",
|
||||
"PyQt5.QtMultimediaWidgets",
|
||||
"PyQt5.QtSensors",
|
||||
"PyQt5.QtSerialPort",
|
||||
"PyQt5.QtWebChannel",
|
||||
"PyQt5.QtWebEngine",
|
||||
"PyQt5.QtWebEngineCore",
|
||||
"PyQt5.QtWebEngineWidgets",
|
||||
"PyQt5.QtWebSockets",
|
||||
"PySide2",
|
||||
"PySide6",
|
||||
# The app uses PySide6 only. Qt modules it never imports are excluded to keep the
|
||||
# bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole
|
||||
# binding produces an EXE with no Qt at all (it fails at start-up, not at build
|
||||
# time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately
|
||||
# absent from this list.
|
||||
"PySide6.Qt3DAnimation",
|
||||
"PySide6.Qt3DCore",
|
||||
"PySide6.Qt3DExtras",
|
||||
"PySide6.Qt3DInput",
|
||||
"PySide6.Qt3DLogic",
|
||||
"PySide6.Qt3DRender",
|
||||
"PySide6.QtBluetooth",
|
||||
"PySide6.QtCharts",
|
||||
"PySide6.QtDataVisualization",
|
||||
"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",
|
||||
"PySide2",
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
|
||||
+13
-2
@@ -237,12 +237,19 @@ $install += @($PyInstallerPin, $CvPin)
|
||||
& $VenvPy @install
|
||||
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.
|
||||
& $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
|
||||
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.
|
||||
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
||||
if ($hasOpen3d -eq "True") {
|
||||
@@ -256,8 +263,12 @@ if (-not $NoVerify) {
|
||||
Write-Host "Verifying the provisioned environment ..."
|
||||
# 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".
|
||||
& $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)" }
|
||||
# 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
|
||||
# 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')"
|
||||
|
||||
@@ -13,9 +13,10 @@ from concurrent.futures import Executor
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
||||
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
||||
from PySide6.QtCore import QEvent, SignalInstance
|
||||
from PySide6.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
@@ -343,9 +344,9 @@ class GLRenderer(Protocol):
|
||||
|
||||
|
||||
class Sensor3DWidget(QOpenGLWidget):
|
||||
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
|
||||
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
|
||||
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
|
||||
capabilitiesChanged: SignalInstance # Payload: GLCapabilities.
|
||||
diagnosticRaised: SignalInstance # Payload: Diagnostic.
|
||||
renderStatsChanged: SignalInstance # Payload: RenderStats; rate-limited.
|
||||
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
|
||||
parent: QWidget | None = None) -> None: ...
|
||||
def set_scene(self, assets: SceneAssets) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 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”。
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|---|---|---|
|
||||
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
|
||||
| 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 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
|
||||
|
||||
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 依赖
|
||||
|
||||
- 继续使用项目验证过的 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)
|
||||
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
|
||||
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
|
||||
|
||||
@@ -186,9 +186,9 @@ graph TB
|
||||
| 规则 | 是否成立 | 证据 |
|
||||
|---|---|---|
|
||||
| 模块级无循环依赖 | ✅ | 扫描 **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` 同理 |
|
||||
| 三维调度与相机无 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` 全部"干净" |
|
||||
| 共享层与 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 |
|
||||
@@ -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` 只应有移动与转发 |
|
||||
| 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` |
|
||||
| 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 失败) |
|
||||
|
||||
@@ -9,7 +9,10 @@ description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Orisys-Tech" }]
|
||||
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",
|
||||
"numpy>=1.24.4",
|
||||
"scipy>=1.10.1",
|
||||
|
||||
@@ -33,8 +33,8 @@ def _arrow_icon_cache_dir() -> Path:
|
||||
def _paint_arrow(kind: str, fill: str) -> str:
|
||||
"""Draw a small chevron PNG on demand; return its path ('' on failure)."""
|
||||
try:
|
||||
from PyQt5.QtCore import QPointF, Qt
|
||||
from PyQt5.QtGui import QColor, QImage, QPainter, QPolygonF
|
||||
from PySide6.QtCore import QPointF, Qt
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF
|
||||
except Exception: # pragma: no cover - headless import failure
|
||||
return ""
|
||||
try:
|
||||
@@ -43,12 +43,12 @@ def _paint_arrow(kind: str, fill: str) -> str:
|
||||
path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png"
|
||||
if path.is_file():
|
||||
return str(path)
|
||||
image = QImage(size, size, QImage.Format_ARGB32)
|
||||
image.fill(Qt.transparent)
|
||||
image = QImage(size, size, QImage.Format.Format_ARGB32)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setBrush(QColor(fill))
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
half = size / 2.0
|
||||
top = size * 0.32
|
||||
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
|
||||
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).
|
||||
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 sys
|
||||
@@ -471,7 +473,7 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
|
||||
script = (
|
||||
"import sys;"
|
||||
"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(
|
||||
[sys.executable, "-c", script],
|
||||
|
||||
@@ -310,7 +310,7 @@ def measure(
|
||||
) -> tuple[dict, int]:
|
||||
"""Run the assembled view and report what each stage cost."""
|
||||
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.contracts import DisplaySettings
|
||||
|
||||
@@ -43,8 +43,9 @@ def _bootstrap_paths() -> None:
|
||||
|
||||
def collect(profile: str, *, keep_open_s: float) -> dict:
|
||||
"""Create a real window, let Qt initialise it, and report what it got."""
|
||||
from PyQt5.QtCore import QTimer
|
||||
from PyQt5.QtWidgets import QApplication, QOpenGLWidget
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
GLDispatch,
|
||||
@@ -103,7 +104,7 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
|
||||
self.ready = True
|
||||
|
||||
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()
|
||||
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,
|
||||
"qt_format": {
|
||||
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
|
||||
"renderable_type": int(fmt.renderableType()),
|
||||
"profile": int(fmt.profile()),
|
||||
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises
|
||||
# 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()),
|
||||
"samples": int(fmt.samples()),
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@ def run(
|
||||
view = None
|
||||
widget = None
|
||||
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.view import Scene3DView
|
||||
|
||||
Reference in New Issue
Block a user