migrate pyqt5 to pyside6

This commit is contained in:
ykcheungab
2026-09-21 09:34:56 +08:00
parent e276b5de0f
commit ccedb9ddd9
66 changed files with 2184 additions and 395 deletions
+12 -5
View File
@@ -4,14 +4,21 @@ Desktop applications that consume the Orisys SDK.
| App | Status | Qt binding | | App | Status | Qt binding |
|-----|--------|-----------| |-----|--------|-----------|
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | PyQt5 | | `apps/studio` | The customer-facing shell (theme / locale / prefs / logging, acquisition, 3D) | **PySide6** |
| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** | | `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** |
| `apps/internal` | Stub, superseded by `apps/autocal` | — | | `apps/internal` | Stub, superseded by `apps/autocal` | — |
The two shipped apps use different Qt bindings on purpose. PyQt5's free option Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free option is
is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal **GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
closed-source tool uses PySide6 and why `shared/` must import neither binding. product would otherwise need a licence purchase. The rule is enforced, not just
Migrating `apps/studio` to PySide6 is a separate, build-validated change. documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and
`apps/autocal/tests/test_binding_purity.py`.
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
development environment that still has PyQt5 installed can build plots from the wrong
binding. `orisys_studio.main` sets `PYQTGRAPH_QT_LIB=PySide6` before anything imports it,
and the purity test asserts that importing the app pulls in no other binding.
## Layout ## Layout
+6 -3
View File
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
## Run it ## Run it
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.) licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
for the same reason and no longer uses it.)
```powershell ```powershell
# with the environment that already has PySide6 (Anaconda py310, for example) # with the environment that already has PySide6 (Anaconda py310, for example)
@@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
are skipped rather than failed: a broken environment should not look like a are skipped rather than failed: a broken environment should not look like a
broken application. `tests/test_binding_purity.py` enforces the binding policy broken application. `tests/test_binding_purity.py` enforces the binding policy
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
all, and the controller interface must stay importable headless. other binding, and the controller interface must stay importable headless. The
studio app enforces the same rules for itself in
`apps/studio/tests/test_binding_purity.py`.
## Do not ship this app ## Do not ship this app
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "Checks", "validation_title": "Checks",
"points_title": "Calibration points", "points_title": "Calibration points",
"field_mode": "Surface mode", "field_mode": "Surface mode",
"field_name": "Sequence name",
"field_note": "Note",
"field_pitch": "Pitch (mm)", "field_pitch": "Pitch (mm)",
"field_margin": "Margin (mm)", "field_margin": "Margin (mm)",
"field_radius": "Radius (mm)", "field_radius": "Radius (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "Generate points", "btn_generate": "Generate points",
"btn_simulate": "Simulate", "btn_simulate": "Simulate",
"btn_dry_run": "Dry run (no contact)", "btn_dry_run": "Dry run (no contact)",
"btn_save": "Save sequence…", "btn_export": "Export sequence…",
"btn_load": "Load sequence…", "btn_import": "Import sequence…",
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
"export_failed": "Export failed: {error}",
"import_failed": "Import failed: {error}",
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.", "pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
"col_index": "#", "col_index": "#",
"col_x": "X (mm)", "col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "Actions", "col_actions": "Actions",
"not_generated": "—", "not_generated": "—",
"status_pending": "Pending", "status_pending": "Pending",
"status_skipped": "Skipped",
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
"summary_empty": "No points yet: generate or import to see the count and travel span.",
"dialog_export": "Export sequence",
"dialog_import": "Import sequence",
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
"exported": "Sequence exported: {name}",
"imported": "Sequence imported: {name} ({count} points)",
"import_warnings": "Import reported {count} warning(s)",
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run." "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
}, },
"action": { "action": {
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "校验", "validation_title": "校验",
"points_title": "标定点列表", "points_title": "标定点列表",
"field_mode": "曲面模式", "field_mode": "曲面模式",
"field_name": "序列名称",
"field_note": "备注",
"field_pitch": "点距 (mm)", "field_pitch": "点距 (mm)",
"field_margin": "边界余量 (mm)", "field_margin": "边界余量 (mm)",
"field_radius": "曲面半径 (mm)", "field_radius": "曲面半径 (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "生成标定点", "btn_generate": "生成标定点",
"btn_simulate": "仿真", "btn_simulate": "仿真",
"btn_dry_run": "干跑(不接触)", "btn_dry_run": "干跑(不接触)",
"btn_save": "保存序列…", "btn_export": "导出序列…",
"btn_load": "载入序列…", "btn_import": "导入序列…",
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
"export_failed": "导出失败: {error}",
"import_failed": "导入失败: {error}",
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。", "pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
"col_index": "#", "col_index": "#",
"col_x": "X (mm)", "col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "操作", "col_actions": "操作",
"not_generated": "—", "not_generated": "—",
"status_pending": "待测", "status_pending": "待测",
"status_skipped": "已跳过",
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
"dialog_export": "导出序列",
"dialog_import": "导入序列",
"filter_sequence": "标定序列 (*.oryseq *.json)",
"exported": "已导出序列: {name}",
"imported": "已导入序列: {name}({count} 点)",
"import_warnings": "导入有 {count} 项提示",
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。" "points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
}, },
"action": { "action": {
+167
View File
@@ -0,0 +1,167 @@
# Calibration sequence format (`.oryseq`)
A calibration sequence is a small JSON document: the **profile** that generated
the points, plus the **points** themselves. It is written by
`导出序列…` in the Sequence tab and read by `导入序列…`.
Why JSON and not a binary container: a sequence is at most a few hundred points
of text. It is read by a human, diffed in review, and emailed to whoever runs the
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
is a different problem — per-point time series are large and will need a sidecar;
that decision belongs to workflow P8, not here.)
## Conventions
The format follows this repository's existing documents rather than inventing a
convention. Where it matches `session.json` and `force_calibration.json`, that is
deliberate:
| Convention | Value |
|---|---|
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
| Identity | `"kind": "autocal.sequence"` — checked on import |
| Extension | `.oryseq` by convention; import accepts any `.json` |
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
## The document
```json
{
"format_version": "1.0",
"kind": "autocal.sequence",
"created_at": "2026-09-20T10:31:00+00:00",
"name": "5 点灵敏度扫描",
"note": "",
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
"device": {
"sensor_type": "box",
"config_ref": "…\\config\\sensors\\box\\sensor.json",
"geometry_id": "finger-1.0.0",
"probe_tip_set": true
},
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
"profile": {
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
"repeats": 3, "order": "serpentine"
},
"points": [
{
"id": 1,
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
"clearance_mm": 5.0,
"enabled": true,
"note": ""
}
],
"summary": {
"count": 12, "enabled": 12,
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
"pitch_mm": 2.0,
"estimated_duration_s": 380.0
}
}
```
## Fields
### `frame` — required
| Field | Meaning |
|---|---|
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
| `length_unit` | `"mm"`; anything else is refused |
| `units_per_mm` | the pulse equivalent the points were built with |
A sequence whose numbers do not say whether they are millimetres or user units is
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
file from a differently-configured axis is detectable rather than silently
believed.
### `profile` — required
The twelve fields the Sequence tab edits, and nothing else. A field absent from
the file is **kept at its current value and reported**, never silently defaulted
to another bench's number.
### `points` — required, may be empty
| Field | Meaning |
|---|---|
| `id` | 1-based, stable within the file; used for run progress and resume |
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
| `sensor_mm` | where that point lands on the sensor; optional but written |
| `clearance_mm` | the standoff for this point |
| `enabled` | false = designed but deliberately skipped |
| `note` | free text |
Both positions are stored on purpose. The platform target is what the run uses;
the sensor position is what makes the point checkable against the surface.
Deriving one from the other on every load would silently change what the points
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
stored transform should be *noticed*, not recomputed from.
### `summary` — derived, not authoritative
Present so a human reading the file sees the point count and travel span without
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
is reported as a warning and the recomputed values win. That is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
`estimated_duration_s` assumes a constant traverse rate
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
figure and is labelled an estimate wherever it is shown.
### `device` — a reference, not a requirement
Records which sensor the sequence was designed for. Importing a sequence for
another sensor is **allowed and warned about**, because re-fixturing
deliberately is a normal thing to do.
## Import rules
Every rejection names the specific problem — `invalid file` tells an operator
nothing.
| Condition | Behaviour |
|---|---|
| not JSON | refused, with line and column |
| top level not an object | refused |
| `kind` missing or different | refused, naming what was found |
| `format_version` missing or not a string | refused |
| `format_version` newer than this build | refused, naming both versions |
| `frame` missing, or `length_unit` not `mm` | refused |
| `profile` not an object | refused |
| a profile key absent | imported; the current value is kept and the key is reported |
| `points` absent | imported as profile-only, and reported |
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
| `summary` disagreeing with `points` | imported; recomputed, and reported |
| current design is unsaved | the operator is asked before it is replaced |
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
`"1.9"`, and a string comparison would make a newer file look older and load it
as if it were supported.
## Compatibility rules
- **Adding an optional key** does not change `format_version`.
- **Adding a required key, or changing a field's meaning or unit**, does.
- A reader must refuse a document whose version it does not support rather than
interpreting the parts it recognises — a sequence half-applied is worse than
one rejected.
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
version and report anything they did not use.
## Where the pieces live
| Concern | Module |
|---|---|
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
@@ -0,0 +1,476 @@
"""Import and export a calibration sequence (workflow P5/P8).
A sequence is a small, human-readable document — a profile plus the points it
generated — so it can be reviewed, diffed, emailed, and reproduced on another
bench. The format follows this repository's other documents rather than
inventing a convention: a dotted ``format_version`` string, sorted sections,
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
Two properties matter more than the field names:
* **``summary`` is derived.** It exists so a human reading the file sees the
point count and the travel span without counting rows. It is *recomputed* on
load, and a mismatch against the file is reported — that is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
* **The file states its frame.** A sequence whose numbers do not say whether they
are millimetres or user units is a sequence that will eventually be read wrong,
so ``frame`` records both the unit and the pulse equivalent it was built with.
Nothing here imports Qt, so the format can be exercised from a test process and
a future batch tool.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
#: What a sequence document says it is. Checked on import: a force-calibration
#: file and a sequence file are both JSON, and applying one as the other must
#: fail loudly rather than half-work.
DOCUMENT_KIND = "autocal.sequence"
#: File extension by convention. Import accepts any ``.json``.
FILE_SUFFIX = ".oryseq"
#: The frame targets are expressed in.
TARGET_FRAME = "platform"
LENGTH_UNIT = "mm"
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
#: A first-pass planning figure, not a measurement: the axes' own speed setting
#: is in user units and the real rate depends on acceleration and the path.
#: Clearly labelled as an estimate wherever it is shown.
ASSUMED_TRAVERSE_MM_S = 20.0
#: The profile keys a document must carry, matching the panel's fields.
PROFILE_KEYS: tuple[str, ...] = (
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
)
class SequenceFormatError(ValueError):
"""A sequence document could not be read.
The message is what the operator sees, so it always names the specific
problem (which key, which version, which point) rather than "invalid file".
"""
@dataclass
class SequencePoint:
"""One generated calibration point.
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
sensor. Both are stored: the run needs the first, and the second is what makes
the point checkable against the surface. Re-deriving one from the other on
every load would silently change what the points mean when a sensor is
re-fixtured.
"""
id: int
target_mm: dict[str, float]
sensor_mm: dict[str, float] | None = None
clearance_mm: float = 0.0
enabled: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": int(self.id),
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
"clearance_mm": float(self.clearance_mm),
"enabled": bool(self.enabled),
"note": str(self.note),
}
if self.sensor_mm is not None:
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
return payload
@dataclass
class SequenceDocument:
"""A sequence as it travels between machines."""
profile: dict[str, Any] = field(default_factory=dict)
points: list[SequencePoint] = field(default_factory=list)
name: str = ""
note: str = ""
device: dict[str, Any] = field(default_factory=dict)
units_per_mm: float = 0.0
created_at: str = ""
summary: dict[str, Any] = field(default_factory=dict)
#: Problems found while reading that did not stop the import. Shown to the
#: operator: "it loaded" and "it loaded as written" are different claims.
warnings: list[str] = field(default_factory=list)
@property
def point_count(self) -> int:
return len(self.points)
@property
def enabled_count(self) -> int:
return sum(1 for point in self.points if point.enabled)
# --------------------------------------------------------------------- derive
def summarise(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> dict[str, Any]:
"""The derived block: count, travel span, and an estimated duration.
Recomputed everywhere rather than trusted from a file, so a document cannot
claim a range its points do not have.
"""
ordered = list(points)
span: dict[str, float] = {}
minimum: dict[str, float] = {}
maximum: dict[str, float] = {}
for axis in "xyz":
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
if values:
minimum[axis] = min(values)
maximum[axis] = max(values)
span[axis] = maximum[axis] - minimum[axis]
return {
"count": len(ordered),
"enabled": sum(1 for point in ordered if point.enabled),
"min_mm": minimum,
"max_mm": maximum,
"span_mm": span,
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
"estimated_duration_s": estimate_duration(ordered, profile),
}
def estimate_duration(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> float:
"""A planning estimate for one full run, in seconds.
Per point: the descent and press, the dwell, and the settle window, repeated
``repeats`` times; plus a traverse between points at
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
shown — it assumes a constant traverse rate and ignores acceleration.
"""
ordered = list(points)
if not ordered:
return 0.0
repeats = max(1, int(_number(profile.get("repeats"), 1)))
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
descent = clear_height / approach
press = press_depth / press_speed
per_point = (descent + press + dwell + settle) * repeats
total = per_point * len(ordered)
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
if pitch > 0:
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
return float(total)
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
"""``min–max (span)``, or a single number when the travel is nil.
A zero span means every point shares that coordinate; printing
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
a glance.
"""
if low is None or high is None:
return "—"
if abs(high - low) < 10 ** (-places) / 2:
return f"{low:.{places}f}"
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
def format_duration(seconds: float | None) -> str:
"""``m:ss``, or a dash when no estimate can be made."""
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
return "—"
total = int(round(float(seconds)))
minutes, rest = divmod(total, 60)
if minutes >= 60:
hours, minutes = divmod(minutes, 60)
return f"{hours:d}:{minutes:02d}:{rest:02d}"
return f"{minutes:d}:{rest:02d}"
# ----------------------------------------------------------------- serialize
def to_document(
*,
profile: Mapping[str, Any],
points: Iterable[SequencePoint] = (),
name: str = "",
note: str = "",
device: Mapping[str, Any] | None = None,
units_per_mm: float = 0.0,
app_version: str = "",
shared_version: str = "",
shared_api: str = "",
) -> dict[str, Any]:
"""Build the sequence document."""
ordered = list(points)
return {
"format_version": FORMAT_VERSION,
"kind": DOCUMENT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"name": str(name),
"note": str(note),
"versions": {
"app": str(app_version),
"shared": str(shared_version),
"shared_api": str(shared_api),
},
"device": dict(device or {}),
"frame": {
"targets": TARGET_FRAME,
"length_unit": LENGTH_UNIT,
"units_per_mm": float(units_per_mm),
},
"profile": {
key: profile.get(key)
for key in PROFILE_KEYS
},
"points": [point.as_dict() for point in ordered],
"summary": summarise(ordered, profile),
}
def save(document: Mapping[str, Any], path: str | Path) -> Path:
"""Write ``document`` atomically, as UTF-8 JSON.
Temp-then-replace: a half-written sequence file that still parses is worse
than no file, because it looks like a design.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary.replace(target)
return target
# ------------------------------------------------------------------- parse
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
"""Read a sequence document, validating it.
Args:
path: the file to read.
current_profile: the profile in force now. Missing profile keys are
taken from it and reported, rather than silently defaulting to
another bench's numbers.
Raises:
SequenceFormatError: with a message naming the specific problem.
"""
target = Path(path)
try:
text = target.read_text(encoding="utf-8")
except OSError as exc:
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise SequenceFormatError(
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
) from exc
if not isinstance(data, dict):
raise SequenceFormatError(
f"{target.name}: the top level must be an object, found {type(data).__name__}"
)
return from_document(data, current_profile=current_profile, source=target.name)
def from_document(
data: Mapping[str, Any],
*,
current_profile: Mapping[str, Any] | None = None,
source: str = "document",
) -> SequenceDocument:
"""Validate and interpret an already-parsed sequence document."""
warnings: list[str] = []
kind = data.get("kind")
if kind != DOCUMENT_KIND:
raise SequenceFormatError(
f"{source}: not a sequence document "
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
)
raw_version = data.get("format_version")
if not isinstance(raw_version, str) or not raw_version.strip():
raise SequenceFormatError(f"{source}: missing or invalid format_version")
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
raise SequenceFormatError(
f"{source}: written by a newer AutoCal "
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
)
frame = data.get("frame")
if not isinstance(frame, dict):
raise SequenceFormatError(f"{source}: missing frame block")
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
raise SequenceFormatError(
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
)
profile_in = data.get("profile")
if not isinstance(profile_in, dict):
raise SequenceFormatError(f"{source}: missing or invalid profile block")
profile: dict[str, Any] = dict(current_profile or {})
missing: list[str] = []
for key in PROFILE_KEYS:
if key in profile_in:
profile[key] = profile_in[key]
else:
missing.append(key)
if missing:
warnings.append(
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
)
points = _parse_points(data.get("points"), source=source, warnings=warnings)
stored_summary = data.get("summary")
recomputed = summarise(points, profile)
if isinstance(stored_summary, dict) and points:
differences = _summary_differences(stored_summary, recomputed)
if differences:
warnings.append(
"the stored summary disagrees with the points and was recomputed: "
+ "; ".join(differences)
)
device = data.get("device") if isinstance(data.get("device"), dict) else {}
return SequenceDocument(
profile=profile,
points=points,
name=str(data.get("name") or ""),
note=str(data.get("note") or ""),
device=dict(device),
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
created_at=str(data.get("created_at") or ""),
summary=recomputed,
warnings=warnings,
)
def _parse_points(
raw: Any, *, source: str, warnings: list[str]
) -> list[SequencePoint]:
if raw is None:
warnings.append("no points in the file; only the profile was imported")
return []
if not isinstance(raw, list):
raise SequenceFormatError(
f"{source}: points must be a list, found {type(raw).__name__}"
)
points: list[SequencePoint] = []
for index, entry in enumerate(raw, start=1):
where = f"{source}: point {index}"
if not isinstance(entry, dict):
raise SequenceFormatError(f"{where} is not an object")
target = entry.get("target_mm")
if not isinstance(target, dict):
raise SequenceFormatError(f"{where} has no target_mm object")
for axis in "xyz":
if axis not in target:
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
sensor = entry.get("sensor_mm")
points.append(
SequencePoint(
id=int(_number(entry.get("id"), index)),
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
sensor_mm=(
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
if isinstance(sensor, dict)
else None
),
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
enabled=bool(entry.get("enabled", True)),
note=str(entry.get("note") or ""),
)
)
return points
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
"""Where the file's own summary disagrees with its points."""
differences: list[str] = []
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
differences.append(
f"count {stored.get('count')} != {recomputed['count']}"
)
stored_span = stored.get("span_mm")
if isinstance(stored_span, dict):
for axis in "xyz":
if axis in recomputed["span_mm"]:
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
differences.append(
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
)
return differences
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
return abs(float(first) - float(second)) <= tolerance
def _number(value: Any, fallback: float) -> float:
"""A finite float, or the fallback. Never NaN, never a string."""
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number):
return float(fallback)
return number
def _version_tuple(text: str) -> tuple[int, ...]:
"""``"1.10"`` as ``(1, 10)``.
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
would make a newer file look older and load it as if it were supported.
"""
parts: list[int] = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
@@ -47,6 +47,12 @@ from PySide6.QtWidgets import (
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
format_duration,
format_span,
summarise,
)
#: Profile fields, in form order: ``(key, locale_key, kind)``. #: Profile fields, in form order: ``(key, locale_key, kind)``.
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit). #: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
@@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2 PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
#: Point-table columns: ``(key, locale_key)``. #: Point-table columns: ``(key, locale_key)``.
#:
#: No actions column. It was rendered empty on every row, and an empty action
#: cell reads as a broken control — the per-point actions (re-measure, skip)
#: belong to the run, not to the design screen.
POINT_COLUMNS: tuple[tuple[str, str], ...] = ( POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("index", "sequence.col_index"), ("index", "sequence.col_index"),
("x", "sequence.col_x"), ("x", "sequence.col_x"),
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("status", "sequence.col_status"), ("status", "sequence.col_status"),
("sensitivity", "sequence.col_sensitivity"), ("sensitivity", "sequence.col_sensitivity"),
("samples", "sequence.col_samples"), ("samples", "sequence.col_samples"),
("actions", "sequence.col_actions"),
) )
#: Editability of the point table. Positions are *generated*, not typed: a typed #: Editability of the point table. Positions are *generated*, not typed: a typed
@@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = () _EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
#: Column widths of the point table, by column index. #: Column widths of the point table, by column index.
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200) _POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
#: Reserved controls and the phase that will fill each one. #: Reserved controls and the phase that will fill each one.
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load") PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
#: Which phase each pending control belongs to (see docs/workflow.md). #: Which phase each pending control belongs to (see docs/workflow.md).
PENDING_PHASES: dict[str, str] = { PENDING_PHASES: dict[str, str] = {
"generate": "P5", "generate": "P5",
"simulate": "P6", "simulate": "P6",
"dry_run": "P6", "dry_run": "P6",
"save": "P8",
"load": "P8",
} }
#: Controls that work now: a sequence is a document, and P8 says it is a
#: versioned artifact. Import and export are the first half of that.
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
#: The validation checks, with the locale key naming what each one proves. #: The validation checks, with the locale key naming what each one proves.
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = ( VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("on_surface", "sequence.check_surface"), ("on_surface", "sequence.check_surface"),
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
generateRequested = Signal() generateRequested = Signal()
simulateRequested = Signal() simulateRequested = Signal()
dryRunRequested = Signal() dryRunRequested = Signal()
saveRequested = Signal() exportRequested = Signal()
loadRequested = Signal() importRequested = Signal()
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None: def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
self._choices: dict[str, QComboBox] = {} self._choices: dict[str, QComboBox] = {}
self._pending_buttons: dict[str, QPushButton] = {} self._pending_buttons: dict[str, QPushButton] = {}
self._check_rows: dict[str, QLabel] = {} self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {}
self._dirty = False
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10) outer.setContentsMargins(10, 8, 10, 10)
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
self.set_profile(profile or {}) self.set_profile(profile or {})
self.retranslate() self.retranslate()
# A profile edit changes the estimate, so the line follows the form.
for widget in self._profile_fields.values():
if isinstance(widget, QComboBox):
widget.currentIndexChanged.connect(self._mark_dirty)
else:
widget.textChanged.connect(self._mark_dirty)
self.refresh_summary()
# ------------------------------------------------------------------ profile # ------------------------------------------------------------------ profile
def _build_profile(self) -> QWidget: def _build_profile(self) -> QWidget:
box = QGroupBox(self) box = QGroupBox(self)
box.setObjectName("groupBox") box.setObjectName("groupBox")
grid = QGridLayout(box) outer = QVBoxLayout(box)
grid.setContentsMargins(10, 8, 10, 8) outer.setContentsMargins(10, 8, 10, 8)
outer.setSpacing(4)
# The name is what the document is called on disk and in the export, so
# it lives at the top of the block that defines the sequence rather than
# in a dialog.
name_row = QHBoxLayout()
name_row.setSpacing(8)
self._name_label = QLabel(box)
self._name_label.setObjectName("hintLabel")
self.name_edit = QLineEdit(box)
self.name_edit.setFixedHeight(28)
name_row.addWidget(self._name_label)
name_row.addWidget(self.name_edit, 1)
outer.addLayout(name_row)
self._note_label = QLabel(box)
self._note_label.setObjectName("hintLabel")
self.note_edit = QLineEdit(box)
self.note_edit.setFixedHeight(28)
outer.addWidget(self._note_label)
outer.addWidget(self.note_edit)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(10) grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4) grid.setVerticalSpacing(4)
outer.addLayout(grid)
# Two fields per row: label, field, label, field. Seeing the whole profile # Two fields per row: label, field, label, field. Seeing the whole profile
# at once is what lets an operator sanity-check a pitch against a depth. # at once is what lets an operator sanity-check a pitch against a depth.
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
column.addWidget(button) column.addWidget(button)
self._pending_buttons[key] = button self._pending_buttons[key] = button
# Import/export work today: a sequence is a portable document (P8).
self._live_buttons: dict[str, QPushButton] = {}
for key in LIVE_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._live_buttons[key] = button
self._pending_buttons["generate"].clicked.connect(self.generateRequested) self._pending_buttons["generate"].clicked.connect(self.generateRequested)
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested) self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested) self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
self._pending_buttons["save"].clicked.connect(self.saveRequested) self._live_buttons["export"].clicked.connect(self.exportRequested)
self._pending_buttons["load"].clicked.connect(self.loadRequested) self._live_buttons["import"].clicked.connect(self.importRequested)
# None of these can run: generation, simulation, the runner and the # Generation, simulation and the runner belong to later phases (P5-P7).
# dataset writer belong to later phases (see docs/workflow.md).
for button in self._pending_buttons.values(): for button in self._pending_buttons.values():
button.setEnabled(False) button.setEnabled(False)
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
# -------------------------------------------------------------- point table # -------------------------------------------------------------- point table
def _mark_dirty(self, *_args: Any) -> None:
self._dirty = True
self.refresh_summary()
def _build_point_table(self) -> QWidget: def _build_point_table(self) -> QWidget:
box = QGroupBox(self) box = QGroupBox(self)
box.setObjectName("groupBox") box.setObjectName("groupBox")
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
4: t("sequence.status_pending"), 4: t("sequence.status_pending"),
5: "—", 5: "—",
6: "—", 6: "—",
7: "",
} }
for column_index, text in values.items(): for column_index, text in values.items():
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column_index, item) self.table.setItem(row, column_index, item)
# The count and span line: the two numbers an operator reasons with, and
# the only place a wrong pitch or margin shows up before a seven-minute
# run does it for them.
self.summary_label = QLabel(box)
self.summary_label.setObjectName("statusValue")
self.summary_label.setWordWrap(True)
column.addWidget(self.table, 1) column.addWidget(self.table, 1)
column.addWidget(self.summary_label)
self.point_hint = QLabel(box) self.point_hint = QLabel(box)
self.point_hint.setObjectName("hintLabel") self.point_hint.setObjectName("hintLabel")
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
4: status or t("sequence.status_pending"), 4: status or t("sequence.status_pending"),
5: "—", 5: "—",
6: "—", 6: "—",
7: "",
} }
for column, text in values.items(): for column, text in values.items():
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
self.table.setItem(row, column, item) self.table.setItem(row, column, item)
return row return row
# ----------------------------------------------------------- points & state
def points(self) -> list[SequencePoint]:
"""The list as it stands, for export and for a future runner."""
points: list[SequencePoint] = []
for row in range(self.table.rowCount()):
cells = self.point_row(row)
try:
target = {axis: float(cells[axis]) for axis in "xyz"}
except (TypeError, ValueError):
continue # a placeholder row that was never generated
points.append(
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
)
return points
def set_points(self, points: list[SequencePoint]) -> None:
"""Replace the list. Used by import and by the future generator."""
self.table.setRowCount(0)
for point in points:
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(point.id),
1: f"{point.target_mm.get('x', 0.0):.3f}",
2: f"{point.target_mm.get('y', 0.0):.3f}",
3: f"{point.target_mm.get('z', 0.0):.3f}",
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
self._dirty = True
self.refresh_summary()
@property
def dirty(self) -> bool:
"""Whether the design has changed since it was last saved or loaded.
Import replaces the design outright, and there is no autosave; discarding
a designed sequence without asking would be worse than one extra prompt.
"""
return self._dirty
def mark_clean(self) -> None:
self._dirty = False
def name(self) -> str:
return self.name_edit.text().strip()
def set_name(self, value: str) -> None:
self.name_edit.setText(str(value or ""))
def note(self) -> str:
return self.note_edit.text().strip()
def set_note(self, value: str) -> None:
self.note_edit.setText(str(value or ""))
def refresh_summary(self) -> None:
"""Recompute the count, span and duration line from what is on screen."""
profile = self.profile()
points = self.points()
summary = summarise(points, profile)
self._summary = summary
self.summary_label.setText(_summary_text(summary, profile))
def planned_steps(self) -> list[SequenceStep]: def planned_steps(self) -> list[SequenceStep]:
"""The steps the table describes, for the future runner. """The steps the table describes, for the future runner.
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
for key, combo in self._choices.items(): for key, combo in self._choices.items():
_fill_choice(combo, key) _fill_choice(combo, key)
self._name_label.setText(t("sequence.field_name"))
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
self._note_label.setText(t("sequence.field_note"))
self.preview_label.setText(t("sequence.preview_placeholder")) self.preview_label.setText(t("sequence.preview_placeholder"))
self.preview_hint.setText(t("sequence.preview_hint")) self.preview_hint.setText(t("sequence.preview_hint"))
for key, _locale_key in VALIDATION_CHECKS: for key, _locale_key in VALIDATION_CHECKS:
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
self._pending_buttons[key].setToolTip( self._pending_buttons[key].setToolTip(
t("sequence.pending_tooltip", phase=PENDING_PHASES[key]) t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
) )
for key in LIVE_CONTROLS:
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS]) self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
for row in range(self.table.rowCount()): for row in range(self.table.rowCount()):
@@ -449,6 +590,33 @@ class SequencePanel(QGroupBox):
if item is not None and item.text() in ("", t("sequence.status_pending")): if item is not None and item.text() in ("", t("sequence.status_pending")):
item.setText(t("sequence.status_pending")) item.setText(t("sequence.status_pending"))
self.point_hint.setText(t("sequence.points_hint")) self.point_hint.setText(t("sequence.points_hint"))
self.refresh_summary()
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
"""The count / span / duration line under the point list.
Spans are ``min–max (span)`` with a nil span printed once; the duration is
labelled an estimate because it is one.
"""
count = int(summary.get("count") or 0)
enabled = int(summary.get("enabled") or count)
if not count:
return t("sequence.summary_empty")
spans = " · ".join(
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
for axis in "xyz"
if axis in summary.get("min_mm", {})
)
duration = format_duration(summary.get("estimated_duration_s"))
return t(
"sequence.summary",
count=count,
enabled=enabled,
spans=spans,
duration=duration,
)
def _fill_choice(combo: QComboBox, key: str) -> None: def _fill_choice(combo: QComboBox, key: str) -> None:
@@ -314,6 +314,11 @@ class RunBar(QFrame):
self.step_label.setMinimumWidth(140) self.step_label.setMinimumWidth(140)
layout.addWidget(self.step_label) layout.addWidget(self.step_label)
# Shown only while idle, in place of the step/progress/timing trio.
self._idle_hint = QLabel(self)
self._idle_hint.setObjectName("hintLabel")
layout.addWidget(self._idle_hint)
self.progress = QProgressBar(self) self.progress = QProgressBar(self)
self.progress.setTextVisible(False) self.progress.setTextVisible(False)
self.progress.setFixedHeight(12) self.progress.setFixedHeight(12)
@@ -395,6 +400,14 @@ class RunBar(QFrame):
self.abort_button.setEnabled(self._has_run) self.abort_button.setEnabled(self._has_run)
self.pause_button.setEnabled(self._has_run and busy) self.pause_button.setEnabled(self._has_run and busy)
self.run_button.setEnabled(not busy) self.run_button.setEnabled(not busy)
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
# job is to be read at a glance. Idle shows the phase and why instead.
live = self._has_run or busy
self.step_label.setVisible(live)
self.progress.setVisible(live)
self.time_label.setVisible(live)
if not live:
self._idle_hint.setText(t("run.no_steps"))
@property @property
def phase(self) -> str: def phase(self) -> str:
@@ -415,6 +428,7 @@ class RunBar(QFrame):
else: else:
self.step_label.setText(t("run.no_steps")) self.step_label.setText(t("run.no_steps"))
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0))) self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
self._refresh_buttons(self._phase in ("running", "paused"))
def _duration(seconds: float) -> str: def _duration(seconds: float) -> str:
@@ -38,6 +38,14 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..preferences import load_preferences, save_preferences from ..preferences import load_preferences, save_preferences
from ..sequence_io import (
FILE_SUFFIX,
SequenceDocument,
SequenceFormatError,
load,
save,
to_document,
)
from .axis_panel import AxisPanel from .axis_panel import AxisPanel
from .axis_readout import AxisReadoutPanel from .axis_readout import AxisReadoutPanel
from .brand_bar import BrandBar from .brand_bar import BrandBar
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
self.run_bar.runRequested.connect(self._on_run_requested) self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause) self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort) self.run_bar.abortRequested.connect(self._on_run_abort)
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
self.sequence_panel.importRequested.connect(self._on_import_sequence)
self.axis_readout.stopAllRequested.connect(self._on_stop_all) self.axis_readout.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms) self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all) self.axis_readout.enableAllRequested.connect(self._on_enable_all)
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
self.statusBar().showMessage(text) self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok) self.status_strip.set_action_result(text, ok)
# ------------------------------------------------------- sequence import/export
def _default_sequence_path(self) -> str:
"""Start the file dialog somewhere useful and named after the profile."""
stem = self.sequence_panel.name() or "sequence"
return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}")
def _sequence_device_ref(self) -> dict:
setup = self.prefs.get("sensor_setup") or {}
return {
"sensor_type": str(setup.get("sensor_type") or ""),
"config_ref": str(setup.get("config_ref") or ""),
"geometry_id": str(setup.get("geometry_id") or ""),
"probe_tip_set": bool(setup.get("probe_tip_set")),
}
def export_sequence(self, path: str) -> str:
"""Write the current design. Returns the path written."""
document = to_document(
profile=self.sequence_panel.profile(),
points=self.sequence_panel.points(),
name=self.sequence_panel.name(),
note=self.sequence_panel.note(),
device=self._sequence_device_ref(),
units_per_mm=float(self.prefs.get("units_per_mm") or 0.0),
app_version=app_version,
shared_version=shared_version,
shared_api=API_VERSION,
)
written = save(document, path)
self.sequence_panel.mark_clean()
return str(written)
def import_sequence(self, path: str) -> SequenceDocument:
"""Read a design and apply it. Raises SequenceFormatError on a bad file."""
document = load(path, current_profile=self.sequence_panel.profile())
self.sequence_panel.set_profile(document.profile)
self.sequence_panel.set_points(document.points)
self.sequence_panel.set_name(document.name)
self.sequence_panel.set_note(document.note)
self.sequence_panel.mark_clean()
return document
def _on_export_sequence(self) -> None:
path, _selected = QFileDialog.getSaveFileName(
self,
t("sequence.dialog_export"),
self._default_sequence_path(),
t("sequence.filter_sequence"),
)
if not path:
return
try:
written = self.export_sequence(path)
except (OSError, ValueError) as exc:
self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.exported", name=Path(written).name), ok=True
)
def _on_import_sequence(self) -> None:
# Import replaces the design and there is no autosave, so an unsaved
# sequence is confirmed before it is discarded.
if self.sequence_panel.dirty:
answer = QMessageBox.question(
self,
t("sequence.dialog_import"),
t("sequence.confirm_discard"),
)
if answer != QMessageBox.StandardButton.Yes:
return
path, _selected = QFileDialog.getOpenFileName(
self,
t("sequence.dialog_import"),
str(Path(outputs_dir()) / "sequences"),
t("sequence.filter_sequence"),
)
if not path:
return
try:
document = self.import_sequence(path)
except SequenceFormatError as exc:
self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True
)
if document.warnings:
self.statusBar().showMessage(
t("sequence.import_warnings", count=len(document.warnings))
)
for warning in document.warnings:
logger.warning("Sequence import: %s", warning)
def _report_sequence(self, text: str, *, ok: bool) -> None:
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
def _apply_chrome_colours(self) -> None: def _apply_chrome_colours(self) -> None:
"""Style the status bar from the theme rather than Qt's default. """Style the status bar from the theme rather than Qt's default.
+15
View File
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
return path return path
@pytest.fixture
def tmp_dir():
"""A workspace-local scratch directory, cleared per test.
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
shell, and ``tmp_path`` depends on a writable process temp root that such an
environment may not provide.
"""
import shutil
path = _scratch_dir()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest.fixture @pytest.fixture
def make_window(qt_app, monkeypatch): def make_window(qt_app, monkeypatch):
"""Build a window whose preference writes stay inside a scratch directory. """Build a window whose preference writes stay inside a scratch directory.
+47 -16
View File
@@ -1,18 +1,23 @@
"""Binding policy for AutoCal. """Binding policy for the Orisys apps.
AutoCal is **PySide6 only**, and this file is the guard for that: Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has * no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
PySide6 and nothing else, so such an import is a hard start-up failure; PySide6 and nothing else, so such an import is a hard start-up failure;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6 * no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
removed in favour of ``Qt.AlignmentFlag.AlignLeft``; removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
* ``shared/`` — which serves both this app and the PyQt5-based studio — must * ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
import no Qt binding at all; must not pull in a second binding, and the studio's own guard
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
side. (Until the studio moved to PySide6 this file forbade *any* binding in
``shared/``; that rule existed for a two-binding workspace, which no longer
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
under the old rule those imports were failing silently instead.)
* the controller layer must stay importable from a headless process. * the controller layer must stay importable from a headless process.
The reason the binding matters at all is licensing, not taste: PyQt5's free The reason the binding matters at all is licensing, not taste: PyQt5's free
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
closed-source tool needs no licence purchase. See ``README.md``. needs no licence purchase. See ``README.md``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,13 +28,19 @@ from pathlib import Path
APP_SRC = Path(__file__).resolve().parents[1] / "src" APP_SRC = Path(__file__).resolve().parents[1] / "src"
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src" SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
#: Bindings the app must never import. #: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed, and where it may be named. #: The one binding allowed, and where it may be named.
ALLOWED_BINDING = "PySide6" ALLOWED_BINDING = "PySide6"
#: Scoped enum holders we care about, with the value names actually used. #: Scoped enum holders we care about, with the value names actually used.
#:
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
#: which is the better rule; this map is kept because AutoCal cannot import the binding
#: in a headless test run.
_SCOPED_ENUMS = { _SCOPED_ENUMS = {
"Qt": ( "Qt": (
"AlignmentFlag", "AlignmentFlag",
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
"ToolButtonStyle", "ToolButtonStyle",
"FocusPolicy", "FocusPolicy",
"ScrollBarPolicy", "ScrollBarPolicy",
"CursorShape",
"MouseButton",
"Key",
"PenStyle",
"GlobalColor",
"ItemFlag",
), ),
"QTableWidget": ("SelectionMode", "EditTrigger"), "QTableWidget": ("SelectionMode", "EditTrigger"),
"QSizePolicy": ("Policy",), "QSizePolicy": ("Policy",),
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
"QMessageBox": ("StandardButton", "Icon"), "QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",), "QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"), "QAbstractItemView": ("SelectionMode", "EditTrigger"),
"QDialogButtonBox": ("StandardButton",),
"QPainter": ("RenderHint", "CompositionMode"),
"QImage": ("Format",),
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
"QFrame": ("Shape",),
} }
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects. """Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
Parsed rather than pattern-matched for two reasons: a regex also hits prose Parsed rather than pattern-matched for two reasons: a regex also hits prose
in a comment or docstring (which is exactly where the scoped spelling gets in a comment or docstring (which is exactly where the *unscoped* spelling gets
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator explained, so a rewrite would destroy the explanation — this file quotes it
but is a static method. The parent map separates the two, and three times, which is why the migration scripts skip it), and
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum ``QMessageBox.information(...)`` looks like an enumerator but is a static
class rather than a plain name, so it is never a candidate. method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
an inner attribute on an allow-listed enum class rather than a plain name, so it
is never a candidate.
""" """
tree = ast.parse(path.read_text(encoding="utf-8")) tree = ast.parse(path.read_text(encoding="utf-8"))
called = { called = {
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
assert not offenders, offenders assert not offenders, offenders
def test_shared_imports_no_qt_binding(): def test_shared_imports_no_forbidden_binding():
"""Shared code serves both products, so it must not pick a side.""" """Shared code serves both products, so it must not pull in a second binding.
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
import in a PySide6-only environment or — worse — work in one app's environment and not
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
the PyQt5 import in ``qss.py`` was failing silently as a result.
"""
offenders = [] offenders = []
for path in _python_files(SHARED_SRC): for path in _python_files(SHARED_SRC):
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"} qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
if qt_imports: if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}") offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, ( assert not offenders, (
"shared code must not import PySide2/PySide6 (studio is PyQt5): " "shared code may import PySide6 only; these files import another binding: "
f"{offenders}" f"{offenders}"
) )
+124 -7
View File
@@ -17,6 +17,7 @@ import pytest
from conftest import pump from conftest import pump
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
from orisys_autocal.ui.workspace import TABS from orisys_autocal.ui.workspace import TABS
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
"""A disabled control must say why, or it reads as a broken button.""" """A disabled control must say why, or it reads as a broken button."""
window = _show(make_window()) window = _show(make_window())
panel = window.sequence_panel panel = window.sequence_panel
assert panel.pending_controls() == [ assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
"generate",
"simulate",
"dry_run",
"save",
"load",
]
for name, phase in panel.pending_phases().items(): for name, phase in panel.pending_phases().items():
button = panel._pending_buttons[name] button = panel._pending_buttons[name]
assert not button.isEnabled(), f"{name} is usable but not implemented" assert not button.isEnabled(), f"{name} is usable but not implemented"
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
assert button.text(), f"{name} has no label" assert button.text(), f"{name} has no label"
def test_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window): def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
"""A runner must not be able to start from points nothing has validated.""" """A runner must not be able to start from points nothing has validated."""
window = _show(make_window()) window = _show(make_window())
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
assert "QStatusBar" in window.statusBar().styleSheet() assert "QStatusBar" in window.statusBar().styleSheet()
def test_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
def test_language_switch_relabels_the_new_shell(make_window): def test_language_switch_relabels_the_new_shell(make_window):
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
+283
View File
@@ -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)
+39 -26
View File
@@ -77,8 +77,8 @@ STRINGS = {
"stop_all": "全部停止", "stop_all": "全部停止",
"run_title": "运行", "run_title": "运行",
"run_phase": "空闲", "run_phase": "空闲",
"run_step": "第 0/0 点", "run_idle_hint": "尚未生成标定点",
"run_time": "已用 0:00 · 预计剩余 —",
"run_run": "运行", "run_run": "运行",
"run_pause": "暂停", "run_pause": "暂停",
"run_abort": "中止", "run_abort": "中止",
@@ -129,6 +129,9 @@ STRINGS = {
"seq_preview": "预览", "seq_preview": "预览",
"seq_validation": "校验", "seq_validation": "校验",
"seq_points": "标定点列表", "seq_points": "标定点列表",
"seq_name": "序列名称",
"seq_note": "备注",
"seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)",
"seq_fields": [ "seq_fields": [
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"), ("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"), ("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
@@ -140,12 +143,12 @@ STRINGS = {
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。", "seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"], "seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
"seq_checks_pending": "尚未生成标定点。", "seq_checks_pending": "尚未生成标定点。",
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)], "seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"], "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
"point_rows": [ "point_rows": [
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""], ["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""], ["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""], ["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
], ],
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。", "points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
"sensor_note": "设备与信号源、实时图像、力曲线", "sensor_note": "设备与信号源、实时图像、力曲线",
@@ -169,8 +172,8 @@ STRINGS = {
"stop_all": "Stop all", "stop_all": "Stop all",
"run_title": "Run", "run_title": "Run",
"run_phase": "Idle", "run_phase": "Idle",
"run_step": "Point 0/0", "run_idle_hint": "No points generated",
"run_time": "Elapsed 0:00 · about — left",
"run_run": "Run", "run_run": "Run",
"run_pause": "Pause", "run_pause": "Pause",
"run_abort": "Abort", "run_abort": "Abort",
@@ -221,6 +224,9 @@ STRINGS = {
"seq_preview": "Preview", "seq_preview": "Preview",
"seq_validation": "Checks", "seq_validation": "Checks",
"seq_points": "Calibration points", "seq_points": "Calibration points",
"seq_name": "Sequence name",
"seq_note": "Note",
"seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)",
"seq_fields": [ "seq_fields": [
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"), ("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"), ("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
@@ -232,12 +238,12 @@ STRINGS = {
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.", "seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"], "seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
"seq_checks_pending": "No points generated yet.", "seq_checks_pending": "No points generated yet.",
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)], "seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"], "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"],
"point_rows": [ "point_rows": [
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""], ["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""], ["2", "2.000", "0.000", "12.400", "Pending", "—", "—"],
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""], ["3", "4.000", "0.000", "12.400", "Pending", "—", "—"],
], ],
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.", "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
"sensor_note": "Device and source, live image, force curves", "sensor_note": "Device and source, live image, force curves",
@@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
profile = QRect(inner.x(), inner.y(), profile_w, top_h) profile = QRect(inner.x(), inner.y(), profile_w, top_h)
p_inner = _panel(painter, profile, palette) p_inner = _panel(painter, profile, palette)
_box_title(painter, profile, s["seq_profile"], palette) _box_title(painter, profile, s["seq_profile"], palette)
# the name and note the document carries
y = p_inner.y() + 4
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette)
y += 26
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette)
y += 28
column_w = p_inner.width() // 2 column_w = p_inner.width() // 2
for index, (label, value) in enumerate(s["seq_fields"]): for index, (label, value) in enumerate(s["seq_fields"]):
row, side = divmod(index, 2) row, side = divmod(index, 2)
x = p_inner.x() + side * column_w x = p_inner.x() + side * column_w
y = p_inner.y() + 20 + row * 26 row_y = y + row * 26
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True) _text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette) _field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
# --- preview # --- preview
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h) preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
@@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap) points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
t_inner = _panel(painter, points, palette) t_inner = _panel(painter, points, palette)
_box_title(painter, points, s["seq_points"], palette) _box_title(painter, points, s["seq_points"], palette)
widths = (50, 150, 150, 150, 130, 190, 100) # Seven columns: no empty actions column, and the summary line carries the
# count and span the operator reasons with.
widths = (60, 150, 150, 150, 140, 190, 110)
widths = widths + (t_inner.width() - sum(widths),) widths = widths + (t_inner.width() - sum(widths),)
header_h = 28 header_h = 28
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h) header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
@@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
for value, width in zip(row, widths): for value, width in zip(row, widths):
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter) _text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
x += width x += width
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10 y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10)
y += 24
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True) _text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
@@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20 x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True) _text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
x += 96 x += 96
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10) _text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True)
x += 158
bar = QRect(x, top + 14, 300, 12)
painter.fillRect(bar, palette.border)
painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent)
_text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True)
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True) _button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True) _button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
+4 -4
View File
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports ``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
PyQt5, and AutoCal's environment has none. ``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
""" """
from __future__ import annotations from __future__ import annotations
@@ -218,8 +218,8 @@ def main() -> int:
"the sequence point list is empty", "the sequence point list is empty",
) )
checker.that( checker.that(
sequence.table.columnCount() == 8, sequence.table.columnCount() == len(sequence.column_keys()),
f"the point list has {sequence.table.columnCount()} columns, expected 8", f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
) )
# The shell: one rail, a run bar, and a health chip. # The shell: one rail, a run bar, and a health chip.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+4 -1
View File
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }] authors = [{ name = "Orisys-Tech" }]
dependencies = [ dependencies = [
"PyQt5>=5.15", # PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
# on PyQt5 (whose free option is GPLv3-or-commercial). See
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
"PySide6>=6.6",
"orisys-app-shared>=0.1.0", "orisys-app-shared>=0.1.0",
"orisys>=0.5", "orisys>=0.5",
"scipy>=1.10.1", "scipy>=1.10.1",
+34 -19
View File
@@ -9,11 +9,9 @@ from pathlib import Path
from typing import Any from typing import Any
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QAction,
QActionGroup,
QApplication, QApplication,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
root.addWidget(self.toolbar) root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip self.status_chip = self.toolbar.status_chip
self.content_splitter = QSplitter(Qt.Horizontal) self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme) self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
self.arrow_panel = ArrowPanel(theme=self._theme) self.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme) self.force_plots = ForcePlotsPanel(theme=self._theme)
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
# convention everyone's hands already know. # convention everyone's hands already know.
self.act_play = QAction(self) self.act_play = QAction(self)
self.act_play.setShortcut("Space") self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ApplicationShortcut) self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
self.addAction(self.act_play) self.addAction(self.act_play)
self.view_menu = menu_bar.addMenu("") self.view_menu = menu_bar.addMenu("")
@@ -1402,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs() self._persist_prefs()
def _show_about(self) -> None: def _show_about(self) -> None:
AboutDialog(self).exec_() AboutDialog(self).exec()
def _on_camera_id_changed(self, value: int) -> None: def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value)) self.prefs["camera_id"] = str(int(value))
@@ -1507,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
filters_locked=bool(self._running or self.recorder.is_recording), filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self.display_ctl.device_sample_rate(), sample_rate_hz=self.display_ctl.device_sample_rate(),
) )
if dialog.exec_() != PlotSettingsDialog.Accepted: if dialog.exec() != PlotSettingsDialog.Accepted:
return return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values() window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings( self.display_ctl.apply_plot_settings(
@@ -1589,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
sensor_type=sensor_type, sensor_type=sensor_type,
config=config_ref, config=config_ref,
), ),
QMessageBox.Yes | QMessageBox.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.No, QMessageBox.StandardButton.No,
) )
if answer != QMessageBox.Yes: if answer != QMessageBox.StandardButton.Yes:
self.recorder.log_event( self.recorder.log_event(
"calibration_apply_blocked", "; ".join(reasons) "calibration_apply_blocked", "; ".join(reasons)
) )
@@ -1602,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
self, self,
t("cal.apply_title"), t("cal.apply_title"),
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref), t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
QMessageBox.Yes | QMessageBox.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.No, QMessageBox.StandardButton.No,
) )
if answer != QMessageBox.Yes: if answer != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage(t("cal.apply_skipped")) self.statusBar().showMessage(t("cal.apply_skipped"))
return return
worker = self._worker_for(sensor_type) worker = self._worker_for(sensor_type)
@@ -1720,7 +1718,18 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None: if getattr(self, "act_run", None) is None:
return return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled()) self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
self.act_stop.setEnabled(self._session_active) # The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
@@ -1740,10 +1749,16 @@ class StudioMainWindow(QMainWindow):
In the replay view it is the same escape hatch as the rail's 停止 button: a In the replay view it is the same escape hatch as the rail's 停止 button: a
replay or a recompute pass that will not stop by itself has to be stoppable replay or a recompute pass that will not stop by itself has to be stoppable
from the keyboard. from the keyboard. The replay branch accepts the same three states
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
""" """
if self.toolbar.run_role() == "playback": if self.toolbar.run_role() == "playback":
if self.replay_ctl.is_playing() or self._session_active: if (
self.replay_ctl.is_playing()
or self._session_active
or self._recompute_running
):
self.replay_ctl.stop_activity() self.replay_ctl.stop_activity()
return return
if not self._session_active: if not self._session_active:
@@ -2348,7 +2363,7 @@ class StudioMainWindow(QMainWindow):
geo = self.prefs.get("window_geometry") geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo: if isinstance(geo, str) and geo:
try: try:
from PyQt5.QtCore import QByteArray from PySide6.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii"))) self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception: except Exception:
@@ -21,7 +21,7 @@ from __future__ import annotations
import logging import logging
from typing import Any, Optional, Protocol from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject from PySide6.QtCore import QObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+7 -2
View File
@@ -6,7 +6,12 @@ import logging
import os import os
import sys import sys
from PyQt5.QtWidgets import QApplication # pyqtgraph picks its toolkit from whichever binding is already imported, and in a
# development environment that also has PyQt5 installed (``orisys[gui]``) it can pick the
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from orisys_app_shared import API_VERSION, __version__ as shared_version from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat from orisys_app_shared.compat import check_shared_compat
@@ -85,7 +90,7 @@ def main() -> int:
language, language,
gl_profile, gl_profile,
) )
code = int(app.exec_()) code = int(app.exec())
# Leave the process immediately. # Leave the process immediately.
# #
+5 -5
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import numpy as np import numpy as np
from PyQt5.QtCore import QObject, pyqtSignal from PySide6.QtCore import QObject, Signal
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource from orisys_app_shared.record import RecordingReader, VideoOnlySource
@@ -47,13 +47,13 @@ class ReplayEngine(QObject):
"""Open one recording and answer "what is at t?" for every view.""" """Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the chart cursor and the 2D panel follow. #: The cursor moved; the chart cursor and the 2D panel follow.
position_changed = pyqtSignal(float) position_changed = Signal(float)
#: The frame under the cursor changed. #: The frame under the cursor changed.
frames_changed = pyqtSignal() frames_changed = Signal()
#: The notes about this recording, or the loaded recording, changed. #: The notes about this recording, or the loaded recording, changed.
status_changed = pyqtSignal() status_changed = Signal()
#: A recording was opened (True) or the previous one was dropped (False). #: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool) recording_changed = Signal(bool)
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -28,8 +28,8 @@ from pathlib import Path
from typing import Any, Optional, Protocol from typing import Any, Optional, Protocol
import numpy as np import numpy as np
from PyQt5.QtCore import QObject, QTimer from PySide6.QtCore import QObject, QTimer
from PyQt5.QtWidgets import QFileDialog from PySide6.QtWidgets import QFileDialog
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -33,7 +33,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional from typing import TYPE_CHECKING, Any, Callable, Optional
from PyQt5.QtCore import QObject, pyqtSignal from PySide6.QtCore import QObject, Signal
from orisys_app_shared.paths import config_dir from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
selection the operator has already moved away from. selection the operator has already moved away from.
""" """
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
failed = pyqtSignal(object, int) # Diagnostic, request id failed = Signal(object, int) # Diagnostic, request id
def __init__(self, parent: Optional[QObject] = None) -> None: def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -72,21 +72,21 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
component must not slow down (the acquisition loop feeds the scene through a component must not slow down (the acquisition loop feeds the scene through a
capacity-one mailbox), and the design document defers anti-aliasing work. capacity-one mailbox), and the design document defers anti-aliasing work.
""" """
from PyQt5.QtGui import QSurfaceFormat from PySide6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat() fmt = QSurfaceFormat()
if profile == "gles30": if profile == "gles30":
fmt.setRenderableType(QSurfaceFormat.OpenGLES) fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
fmt.setVersion(3, 0) fmt.setVersion(3, 0)
elif profile == "desktop33": elif profile == "desktop33":
fmt.setRenderableType(QSurfaceFormat.OpenGL) fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
fmt.setVersion(3, 3) fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.CoreProfile) fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
else: # pragma: no cover - a typo must not reach a board silently else: # pragma: no cover - a typo must not reach a board silently
raise ValueError(f"unknown GL profile: {profile!r}") raise ValueError(f"unknown GL profile: {profile!r}")
fmt.setDepthBufferSize(24) fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8) fmt.setStencilBufferSize(8)
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer) fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSamples(0) fmt.setSamples(0)
QSurfaceFormat.setDefaultFormat(fmt) QSurfaceFormat.setDefaultFormat(fmt)
@@ -442,7 +442,10 @@ class GLDispatch:
if context is None or not context.isValid(): if context is None or not context.isValid():
raise error("gl_unsupported", "no current OpenGL context") raise error("gl_unsupported", "no current OpenGL context")
fmt = context.format() fmt = context.format()
profile = profile_from_renderable_type(int(fmt.renderableType())) # PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises TypeError, so read
# `.value`. (PyQt5 exposed them as ints. Getting this wrong made every GL probe raise,
# the 3D pane report "unsupported", and the GL smoke tests skip instead of running.)
profile = profile_from_renderable_type(int(fmt.renderableType().value))
try: try:
vendor = _gl_string(GL, GL.GL_VENDOR) vendor = _gl_string(GL, GL.GL_VENDOR)
renderer = _gl_string(GL, GL.GL_RENDERER) renderer = _gl_string(GL, GL.GL_RENDERER)
@@ -454,7 +457,7 @@ class GLDispatch:
"gl_unsupported", "gl_unsupported",
f"the context answered no queries: {type(exc).__name__}: {exc}", f"the context answered no queries: {type(exc).__name__}: {exc}",
) from exc ) from exc
from PyQt5.QtGui import QGuiApplication from PySide6.QtGui import QGuiApplication
platform_name = "" platform_name = ""
try: try:
+13 -13
View File
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal from PySide6.QtCore import Qt, Signal
from PyQt5.QtGui import QImage from PySide6.QtGui import QImage
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
self.setObjectName("colorScaleBar") self.setObjectName("colorScaleBar")
self.setFixedHeight(12) self.setFixedHeight(12)
self.setMinimumWidth(120) self.setMinimumWidth(120)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False) self.setVisible(False)
self._lut_id = "sequential" self._lut_id = "sequential"
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
self.update() self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from PyQt5.QtGui import QPainter from PySide6.QtGui import QPainter
from orisys_studio.scene3d.gl_backend import lut_rgba from orisys_studio.scene3d.gl_backend import lut_rgba
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call, # (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
# which is what QImage's borrowed buffer needs. # which is what QImage's borrowed buffer needs.
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes() rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format_RGBA8888) image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
painter = QPainter(self) painter = QPainter(self)
painter.drawImage(self.rect(), image) painter.drawImage(self.rect(), image)
painter.end() painter.end()
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
class ThreeDPanel(QFrame): class ThreeDPanel(QFrame):
"""Toggle, presets, reset, housing visibility, gain, colour scale, status.""" """Toggle, presets, reset, housing visibility, gain, colour scale, status."""
enabledChanged = pyqtSignal(bool) enabledChanged = Signal(bool)
settingsChanged = pyqtSignal(object) # DisplaySettings settingsChanged = Signal(object) # DisplaySettings
resetRequested = pyqtSignal() resetRequested = Signal()
presetRequested = pyqtSignal(str) presetRequested = Signal(str)
def __init__( def __init__(
self, self,
@@ -299,13 +299,13 @@ class ThreeDPanel(QFrame):
scale_row.setSpacing(6) scale_row.setSpacing(6)
self.scale_lower_label = QLabel() self.scale_lower_label = QLabel()
self.scale_lower_label.setObjectName("panelMeta") self.scale_lower_label.setObjectName("panelMeta")
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.scale_lower_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_lower_label) scale_row.addWidget(self.scale_lower_label)
self.scale_bar = ColorScaleBar() self.scale_bar = ColorScaleBar()
scale_row.addWidget(self.scale_bar, 1) scale_row.addWidget(self.scale_bar, 1)
self.scale_upper_label = QLabel() self.scale_upper_label = QLabel()
self.scale_upper_label.setObjectName("panelMeta") self.scale_upper_label.setObjectName("panelMeta")
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.scale_upper_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_upper_label) scale_row.addWidget(self.scale_upper_label)
root.addLayout(scale_row) root.addLayout(scale_row)
@@ -327,7 +327,7 @@ class ThreeDPanel(QFrame):
self.source_label = ShrinkableLabel() self.source_label = ShrinkableLabel()
self.source_label.setObjectName("panelMeta") self.source_label.setObjectName("panelMeta")
self.source_label.setWordWrap(False) self.source_label.setWordWrap(False)
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
legend_row.addWidget(self.source_label, 2) legend_row.addWidget(self.source_label, 2)
root.addLayout(legend_row) root.addLayout(legend_row)
@@ -16,8 +16,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from PyQt5.QtCore import Qt from PySide6.QtCore import Qt
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QLabel, QLabel,
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
root.addWidget(panel.controls_widget) root.addWidget(panel.controls_widget)
panel.controls_widget.setVisible(True) panel.controls_widget.setVisible(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Close) self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.buttons.rejected.connect(self.close) self.buttons.rejected.connect(self.close)
self.buttons.accepted.connect(self.close) self.buttons.accepted.connect(self.close)
root.addWidget(self.buttons) root.addWidget(self.buttons)
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
self.hint.setText(t("scene3d.settings_hint")) self.hint.setText(t("scene3d.settings_hint"))
# Qt ships its own translations for a button box, and the application installs # Qt ships its own translations for a button box, and the application installs
# none: without this the dialog ends in an English "Close" among Chinese labels. # none: without this the dialog ends in an English "Close" among Chinese labels.
close_button = self.buttons.button(QDialogButtonBox.Close) close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close)
if close_button is not None: if close_button is not None:
close_button.setText(t("dialog.close")) close_button.setText(t("dialog.close"))
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
"""Esc closes the dialog, as it does everywhere else in this application.""" """Esc closes the dialog, as it does everywhere else in this application."""
if event.key() == Qt.Key_Escape: if event.key() == Qt.Key.Key_Escape:
self.hide() self.hide()
return return
super().keyPressEvent(event) super().keyPressEvent(event)
@@ -28,8 +28,8 @@ import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from PyQt5.QtCore import pyqtSignal from PySide6.QtCore import Signal
from PyQt5.QtWidgets import QVBoxLayout, QWidget from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]:
pane on a machine where PyOpenGL is not installed. pane on a machine where PyOpenGL is not installed.
""" """
try: try:
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401 from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return False, f"PyQt5 OpenGL widgets are unavailable: {exc}" return False, f"PySide6 OpenGL widgets are unavailable: {exc}"
try: try:
import OpenGL # noqa: F401 import OpenGL # noqa: F401
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
class Scene3DView(QWidget): class Scene3DView(QWidget):
"""One 3D pane: the GL widget on top, its controls underneath.""" """One 3D pane: the GL widget on top, its controls underneath."""
stateChanged = pyqtSignal(object) # SceneState stateChanged = Signal(object) # SceneState
diagnosticRaised = pyqtSignal(object) # Diagnostic diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats renderStatsChanged = Signal(object) # RenderStats
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor] descriptorChanged = Signal(object) # Optional[FieldDescriptor]
enabledChanged = pyqtSignal(bool) enabledChanged = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None: def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
+23 -16
View File
@@ -19,9 +19,11 @@ from __future__ import annotations
import time import time
from typing import Optional from typing import Optional
from PyQt5.QtCore import QEvent, Qt, pyqtSignal from PySide6.QtCore import QEvent, Qt, Signal
from PyQt5.QtGui import QMouseEvent, QWheelEvent from PySide6.QtGui import QMouseEvent, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget # Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QWidget
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
class Sensor3DWidget(QOpenGLWidget): class Sensor3DWidget(QOpenGLWidget):
"""Renders one scene. Signals carry state, never per-frame arrays.""" """Renders one scene. Signals carry state, never per-frame arrays."""
capabilitiesChanged = pyqtSignal(object) # GLCapabilities capabilitiesChanged = Signal(object) # GLCapabilities
diagnosticRaised = pyqtSignal(object) # Diagnostic diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited renderStatsChanged = Signal(object) # RenderStats, rate-limited
#: What the frame on screen claims to be — quantity, unit, quality. The panel #: What the frame on screen claims to be — quantity, unit, quality. The panel
#: labels the colour bar from this and nothing else, so an unmeasured surface #: labels the colour bar from this and nothing else, so an unmeasured surface
#: cannot inherit the previous frame's range. #: cannot inherit the previous frame's range.
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor] descriptorChanged = Signal(object) # Optional[FieldDescriptor]
def __init__( def __init__(
self, self,
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
When the platform cannot create a GL context (the offscreen plugin, a broken When the platform cannot create a GL context (the offscreen plugin, a broken
driver, a board without EGL) ``initializeGL`` is simply never called: there is driver, a board without EGL) ``initializeGL`` is simply never called: there is
no exception to catch, and the widget would sit there looking like a working no exception to catch, and the widget would sit there looking like a working
view that has no data yet. Qt does answer the question, though — ``context()`` view that has no data yet. Qt does answer the question, though, so the first
stays ``None`` — so the first paint asks it and turns "no context" into the paint asks it and turns "no context" into the state the rest of the application
state the rest of the application already knows how to show. already knows how to show.
Under Qt5 that answer was ``context() is None``. Qt6 returns an *invalid*
context object instead, so the check has to be ``isValid()`` as well — comparing
against ``None`` alone silently stops reporting a broken GL stack.
""" """
super().paintEvent(event) super().paintEvent(event)
if self._unavailable or self._capabilities is not None: if self._unavailable or self._capabilities is not None:
return return
if self.context() is None: context = self.context()
if context is None or not context.isValid():
self._fail( self._fail(
diagnostic( diagnostic(
"gl_unsupported", "gl_unsupported",
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
# --------------------------------------------------------------- interaction # --------------------------------------------------------------- interaction
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
self._last_press = (float(event.x()), float(event.y())) self._last_press = (float(event.position().x()), float(event.position().y()))
super().mousePressEvent(event) super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
if self._last_press is None: if self._last_press is None:
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
return return
x, y = float(event.x()), float(event.y()) x, y = float(event.position().x()), float(event.position().y())
delta = (x - self._last_press[0], y - self._last_press[1]) delta = (x - self._last_press[0], y - self._last_press[1])
self._last_press = (x, y) self._last_press = (x, y)
viewport = (max(self.width(), 1), max(self.height(), 1)) viewport = (max(self.width(), 1), max(self.height(), 1))
buttons = event.buttons() buttons = event.buttons()
panning = bool(buttons & Qt.MiddleButton) or bool( panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
) )
if panning: if panning:
self._camera.pan(delta, viewport) self._camera.pan(delta, viewport)
elif buttons & Qt.LeftButton: elif buttons & Qt.MouseButton.LeftButton:
self._camera.orbit(delta, viewport) self._camera.orbit(delta, viewport)
self.update() self.update()
@@ -26,7 +26,7 @@ import logging
import time import time
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from PyQt5.QtCore import QObject, QTimer, pyqtSignal from PySide6.QtCore import QObject, QTimer, Signal
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio import session_metadata from orisys_studio import session_metadata
@@ -47,11 +47,11 @@ class SessionController(QObject):
"""Owns the acquisition session's state and its UI-free mechanics.""" """Owns the acquisition session's state and its UI-free mechanics."""
#: The run timer fired: the window's presenter slot does the painting. #: The run timer fired: the window's presenter slot does the painting.
tick = pyqtSignal() tick = Signal()
#: The phase was (re)applied, not only changed: the status chip repaints from it, #: The phase was (re)applied, not only changed: the status chip repaints from it,
#: and an idempotent repaint is cheaper than a second source of truth. #: and an idempotent repaint is cheaper than a second source of truth.
phaseChanged = pyqtSignal(str) phaseChanged = Signal(str)
runningChanged = pyqtSignal(bool) runningChanged = Signal(bool)
def __init__( def __init__(
self, self,
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import Qt from PySide6.QtCore import Qt
from PyQt5.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path from orisys_app_shared.paths import logo_topbar_path
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
logo = QLabel() logo = QLabel()
pix = QPixmap(str(logo_topbar_path())) pix = QPixmap(str(logo_topbar_path()))
if not pix.isNull(): if not pix.isNull():
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation)) logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
row.addWidget(logo) row.addWidget(logo)
row.addStretch(1) row.addStretch(1)
root.addLayout(row) root.addLayout(row)
self.body = QLabel() self.body = QLabel()
self.body.setWordWrap(True) self.body.setWordWrap(True)
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse) self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
root.addWidget(self.body) root.addWidget(self.body)
close_btn = QPushButton("OK") close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
root.addWidget(close_btn, alignment=Qt.AlignRight) root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
self.retranslate() self.retranslate()
def retranslate(self) -> None: def retranslate(self) -> None:
+12 -12
View File
@@ -12,9 +12,9 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal from PySide6.QtCore import QSize, Qt, Signal
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
""" """
rgb = frame[:, :, ::-1].copy() rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy() return QImage(rgb.data, width, height, channels * width, QImage.Format.Format_RGB888).copy()
_VIEW_LABELS = { _VIEW_LABELS = {
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._aspect = DEFAULT_FRAME_ASPECT self._aspect = DEFAULT_FRAME_ASPECT
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
policy.setHeightForWidth(True) policy.setHeightForWidth(True)
self.setSizePolicy(policy) self.setSizePolicy(policy)
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT) self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
self.setObjectName("colorBar") self.setObjectName("colorBar")
self.setFixedHeight(_COLORBAR_HEIGHT) self.setFixedHeight(_COLORBAR_HEIGHT)
self.setMinimumWidth(_COLORBAR_WIDTH) self.setMinimumWidth(_COLORBAR_WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setVisible(False) self.setVisible(False)
def set_theme(self, theme: str) -> None: def set_theme(self, theme: str) -> None:
@@ -145,14 +145,14 @@ class ColorBarLabel(QLabel):
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for # (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
# the whole call, which is what QImage's borrowed buffer needs. # the whole call, which is what QImage's borrowed buffer needs.
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes() rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888) image = QImage(rgb, 256, 1, 256 * 3, QImage.Format.Format_RGB888)
painter = QPainter(self) painter = QPainter(self)
painter.drawImage(self.rect(), image) painter.drawImage(self.rect(), image)
painter.end() painter.end()
class ArrowPanel(QFrame): class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str) view_mode_changed = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
layout.addLayout(header) layout.addLayout(header)
self.image_label = AspectImageLabel() self.image_label = AspectImageLabel()
self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setScaledContents(False) self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True) self.image_label.setWordWrap(True)
# The image keeps the frame's shape and is centred in whatever height is # The image keeps the frame's shape and is centred in whatever height is
@@ -515,11 +515,11 @@ class ArrowPanel(QFrame):
deg = self._effective_rotation_deg() deg = self._effective_rotation_deg()
if deg: if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW. # QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation) pix = pix.transformed(QTransform().rotate(-deg), Qt.TransformationMode.FastTransformation)
scaled = pix.scaled( scaled = pix.scaled(
self.image_label.size(), self.image_label.size(),
Qt.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.SmoothTransformation, Qt.TransformationMode.SmoothTransformation,
) )
self.image_label.setPixmap(scaled) self.image_label.setPixmap(scaled)
self.image_label.setText("") self.image_label.setText("")
+12 -12
View File
@@ -14,9 +14,9 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal from PySide6.QtCore import Qt, Signal
from PyQt5.QtGui import QColor, QPainter, QPixmap from PySide6.QtGui import QColor, QPainter, QPixmap
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path from orisys_app_shared.paths import logo_topbar_path
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
if source.isNull(): if source.isNull():
return QPixmap() return QPixmap()
scaled = source.scaled( scaled = source.scaled(
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
) )
out = QPixmap(scaled.size()) out = QPixmap(scaled.size())
out.fill(Qt.transparent) out.fill(Qt.GlobalColor.transparent)
painter = QPainter(out) painter = QPainter(out)
painter.drawPixmap(0, 0, scaled) painter.drawPixmap(0, 0, scaled)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(out.rect(), QColor(tint)) painter.fillRect(out.rect(), QColor(tint))
painter.end() painter.end()
return out return out
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
"""Thin brand rail: lockup and top-level view switcher, nothing else.""" """Thin brand rail: lockup and top-level view switcher, nothing else."""
#: The user asked for the live view. #: The user asked for the live view.
live_requested = pyqtSignal() live_requested = Signal()
#: The user asked for the history/replay view. #: The user asked for the history/replay view.
playback_requested = pyqtSignal() playback_requested = Signal()
#: The user asked for the calibration view. #: The user asked for the calibration view.
calibration_requested = pyqtSignal() calibration_requested = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
self.logo = QLabel() self.logo = QLabel()
self.logo.setObjectName("brandLogo") self.logo.setObjectName("brandLogo")
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE) self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
self.logo.setAlignment(Qt.AlignCenter) self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.logo) layout.addWidget(self.logo)
# The lockup keeps its two labels (the product's tagline stays available # The lockup keeps its two labels (the product's tagline stays available
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
self.product = QLabel() self.product = QLabel()
self.product.setObjectName("brandProduct") self.product.setObjectName("brandProduct")
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(self.product) layout.addWidget(self.product)
self.set_theme(theme) self.set_theme(theme)
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
def _nav_button(self) -> QPushButton: def _nav_button(self) -> QPushButton:
button = QPushButton() button = QPushButton()
button.setObjectName("navButton") button.setObjectName("navButton")
button.setCursor(Qt.PointingHandCursor) button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFlat(True) button.setFlat(True)
button.setCheckable(True) button.setCheckable(True)
return button return button
+19 -19
View File
@@ -3,9 +3,9 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal from PySide6.QtCore import Qt, Signal
from PyQt5.QtGui import QColor, QFont, QFontDatabase from PySide6.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QButtonGroup, QButtonGroup,
QDoubleSpinBox, QDoubleSpinBox,
@@ -60,11 +60,11 @@ def _mono_font() -> str:
class CalPanel(QFrame): class CalPanel(QFrame):
status_message = pyqtSignal(str) status_message = Signal(str)
#: Coefficients were written to disk. It does **not** mean they were applied. #: Coefficients were written to disk. It does **not** mean they were applied.
calibration_exported = pyqtSignal(str) calibration_exported = Signal(str)
#: The operator asked to push the last export into the running device. #: The operator asked to push the last export into the running device.
apply_requested = pyqtSignal(str) apply_requested = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
outer.setSpacing(0) outer.setSpacing(0)
self.scroll = QScrollArea() self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True) self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame) self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
content = QWidget() content = QWidget()
self.scroll.setWidget(content) self.scroll.setWidget(content)
outer.addWidget(self.scroll) outer.addWidget(self.scroll)
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
form = QFormLayout() form = QFormLayout()
form.setHorizontalSpacing(10) form.setHorizontalSpacing(10)
form.setVerticalSpacing(6) form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._weight_label = QLabel() self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel") self._weight_label.setObjectName("fieldLabel")
self.weight_spin = QDoubleSpinBox() self.weight_spin = QDoubleSpinBox()
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
# Diagnostic table: the loaded axis plus the two crosstalk axes, a # Diagnostic table: the loaded axis plus the two crosstalk axes, a
# stability measure and the point's state. # stability measure and the point's state.
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS)) self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False) self.table.setShowGrid(False)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
# Every column takes the width its own header and cells need: eliding a # Every column takes the width its own header and cells need: eliding a
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow # diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
# panel scrolls instead. # panel scrolls instead.
header.setSectionResizeMode(QHeaderView.ResizeToContents) header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
header.setMinimumSectionSize(64) header.setMinimumSectionSize(64)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26) self.table.verticalHeader().setDefaultSectionSize(26)
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
): ):
# The action row must survive a narrow three-panel layout. # The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true") button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded) self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation) self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
self.btn_delete.clicked.connect(self._on_delete_row) self.btn_delete.clicked.connect(self._on_delete_row)
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
) )
for column, text in enumerate(values): for column, text in enumerate(values):
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if column in (0, 1, 2, 3): if column in (0, 1, 2, 3):
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter)) item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
else: else:
item.setTextAlignment(int(Qt.AlignCenter)) item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
if point.excluded: if point.excluded:
item.setForeground(QColor(color(self._theme, "text_muted"))) item.setForeground(QColor(color(self._theme, "text_muted")))
elif point.is_validation: elif point.is_validation:
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
"""Double click a row to inspect that point's three-axis curve.""" """Double click a row to inspect that point's three-axis curve."""
dialog = self.point_dialog(self._current_point()) dialog = self.point_dialog(self._current_point())
if dialog is not None: if dialog is not None:
dialog.exec_() dialog.exec()
def _on_toggle_excluded(self) -> None: def _on_toggle_excluded(self) -> None:
point = self._current_point() point = self._current_point()
@@ -7,9 +7,9 @@ the three-panel layout, while a single point can still be examined in full.
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from PyQt5.QtCore import Qt from PySide6.QtCore import Qt
from PyQt5.QtGui import QFont, QFontDatabase from PySide6.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QFormLayout, QFormLayout,
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
root.addWidget(self.plot_widget, 1) root.addWidget(self.plot_widget, 1)
self._build_traces() self._build_traces()
buttons = QDialogButtonBox(QDialogButtonBox.Close) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
root.addWidget(buttons) root.addWidget(buttons)
+3 -3
View File
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import QSize from PySide6.QtCore import QSize
from PyQt5.QtWidgets import QLabel, QSizePolicy from PySide6.QtWidgets import QLabel, QSizePolicy
class ShrinkableLabel(QLabel): class ShrinkableLabel(QLabel):
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
def __init__(self, text: str = "", parent=None) -> None: def __init__(self, text: str = "", parent=None) -> None:
super().__init__(text, parent) super().__init__(text, parent)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
self.setMinimumWidth(0) self.setMinimumWidth(0)
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
+13 -13
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
import time import time
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, QSize, pyqtSignal from PySide6.QtCore import Qt, QSize, Signal
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -116,18 +116,18 @@ class _Sync:
class ForcePlotsPanel(QFrame): class ForcePlotsPanel(QFrame):
#: The visible time window changed while browsing history. #: The visible time window changed while browsing history.
window_changed = pyqtSignal(float, float) window_changed = Signal(float, float)
#: The shared cursor moved; other views sync their playback position to it. #: The shared cursor moved; other views sync their playback position to it.
cursor_moved = pyqtSignal(float) cursor_moved = Signal(float)
#: "follow" (live) or "browse" (user-controlled window). #: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str) mode_changed = Signal(str)
#: The unit chip was clicked: switch between newton and raw. #: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal() unit_toggle_requested = Signal()
#: The gear was clicked: open the plot settings (window, range, filters). #: The gear was clicked: open the plot settings (window, range, filters).
plot_settings_requested = pyqtSignal() plot_settings_requested = Signal()
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned #: The operator took the timeline over (dragged the cursor, box-zoomed, panned
#: or moved the overview marker): playback stops where it is. #: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal() timeline_taken_over = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -217,7 +217,7 @@ class ForcePlotsPanel(QFrame):
self.unit_badge = QPushButton() self.unit_badge = QPushButton()
self.unit_badge.setObjectName("unitBadge") self.unit_badge.setObjectName("unitBadge")
self.unit_badge.setProperty("compact", "true") self.unit_badge.setProperty("compact", "true")
self.unit_badge.setCursor(Qt.PointingHandCursor) self.unit_badge.setCursor(Qt.CursorShape.PointingHandCursor)
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit) self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
header.addWidget(self.unit_badge) header.addWidget(self.unit_badge)
root.addLayout(header) root.addLayout(header)
@@ -329,7 +329,7 @@ class ForcePlotsPanel(QFrame):
self.window_label.setObjectName("panelMeta") self.window_label.setObjectName("panelMeta")
self.stats_label = QLabel() self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta") self.stats_label.setObjectName("panelMeta")
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.stats_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
browse_row.addWidget(self.window_caption) browse_row.addWidget(self.window_caption)
browse_row.addWidget(self.window_label) browse_row.addWidget(self.window_label)
browse_row.addStretch(1) browse_row.addStretch(1)
@@ -348,7 +348,7 @@ class ForcePlotsPanel(QFrame):
button.setProperty("iconOnly", "true") button.setProperty("iconOnly", "true")
button.setIcon(icons.icon(icon_name, self._theme)) button.setIcon(icons.icon(icon_name, self._theme))
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
button.setCursor(Qt.PointingHandCursor) button.setCursor(Qt.CursorShape.PointingHandCursor)
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button return button
@@ -389,7 +389,7 @@ class ForcePlotsPanel(QFrame):
value_row.setSpacing(5) value_row.setSpacing(5)
value_label = QLabel() value_label = QLabel()
value_label.setObjectName("valueCard") value_label.setObjectName("valueCard")
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) value_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
value_label.setFont(_mono_font(16, bold=True)) value_label.setFont(_mono_font(16, bold=True))
# Never a fixed height: the assertion that a value is not clipped is # Never a fixed height: the assertion that a value is not clipped is
# derived from the font it is actually drawn with. # derived from the font it is actually drawn with.
+8 -8
View File
@@ -14,8 +14,8 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt from PySide6.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color from orisys_app_shared.theme import color
@@ -50,7 +50,7 @@ def _paint_zoom_fit(painter: QPainter, size: int) -> None:
def _paint_box_zoom(painter: QPainter, size: int) -> None: def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it.""" """A dashed frame: drag a region to zoom into it."""
pen = painter.pen() pen = painter.pen()
pen.setStyle(Qt.DashLine) pen.setStyle(Qt.PenStyle.DashLine)
pen.setDashPattern([2.2, 1.6]) pen.setDashPattern([2.2, 1.6])
painter.setPen(pen) painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0)) painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
@@ -143,19 +143,19 @@ _PAINTERS = {
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap: def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value).""" """One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size) canvas = QPixmap(size, size)
canvas.fill(Qt.transparent) canvas.fill(Qt.GlobalColor.transparent)
paint = _PAINTERS.get(str(name)) paint = _PAINTERS.get(str(name))
if paint is None: if paint is None:
return canvas return canvas
painter = QPainter(canvas) painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen() pen = QPen()
pen.setWidthF(_PEN_WIDTH) pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.RoundJoin) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setColor(QColor(tint)) pen.setColor(QColor(tint))
painter.setPen(pen) painter.setPen(pen)
painter.setBrush(Qt.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
try: try:
paint(painter, size) paint(painter, size)
finally: finally:
@@ -16,8 +16,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional from typing import TYPE_CHECKING, Callable, Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal from PySide6.QtCore import QSize, Qt, Signal
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
#: Emitted when the operator turns the pane on. The window wires the packet sink #: Emitted when the operator turns the pane on. The window wires the packet sink
#: from this, so no packet is adapted while the pane is off. #: from this, so no packet is adapted while the pane is off.
enabledChanged = pyqtSignal(bool) enabledChanged = Signal(bool)
def __init__( def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark" self, parent: Optional[QWidget] = None, *, theme: str = "dark"
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
self.btn_reset_view = QPushButton() self.btn_reset_view = QPushButton()
self.btn_reset_view.setProperty("compact", "true") self.btn_reset_view.setProperty("compact", "true")
self.btn_reset_view.setProperty("iconOnly", "true") self.btn_reset_view.setProperty("iconOnly", "true")
self.btn_reset_view.setCursor(Qt.PointingHandCursor) self.btn_reset_view.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_reset_view.clicked.connect(self.reset_view) self.btn_reset_view.clicked.connect(self.reset_view)
header.addWidget(self.btn_reset_view) header.addWidget(self.btn_reset_view)
self.btn_settings = QPushButton() self.btn_settings = QPushButton()
self.btn_settings.setProperty("compact", "true") self.btn_settings.setProperty("compact", "true")
self.btn_settings.setProperty("iconOnly", "true") self.btn_settings.setProperty("iconOnly", "true")
self.btn_settings.setCursor(Qt.PointingHandCursor) self.btn_settings.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_settings.clicked.connect(self.open_settings) self.btn_settings.clicked.connect(self.open_settings)
header.addWidget(self.btn_settings) header.addWidget(self.btn_settings)
root.addLayout(header) root.addLayout(header)
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
self.message = QLabel() self.message = QLabel()
self.message.setObjectName("hintText") self.message.setObjectName("hintText")
self.message.setAlignment(Qt.AlignCenter) self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.message.setWordWrap(True) self.message.setWordWrap(True)
self._stack.addWidget(self.message) self._stack.addWidget(self.message)
@@ -9,7 +9,7 @@ reports as done is what the record will say (see ``force/filters.py``).
from __future__ import annotations from __future__ import annotations
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
self.btn_reset.clicked.connect(self._reset_defaults) self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset) buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1) buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
self.button_box.accepted.connect(self.accept) self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject) self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box) buttons_row.addWidget(self.button_box)
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
self.rate_label.setText(t("dialog.filter_rate")) self.rate_label.setText(t("dialog.filter_rate"))
self.rate_spin.setToolTip(t("dialog.filter_rate_tip")) self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
self._refresh_filter_note() self._refresh_filter_note()
ok_btn = self.button_box.button(QDialogButtonBox.Ok) ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
if ok_btn is not None: if ok_btn is not None:
ok_btn.setText(t("dialog.ok")) ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None: if cancel_btn is not None:
@@ -14,7 +14,7 @@ timer without touching the writer thread.
from __future__ import annotations from __future__ import annotations
from PyQt5.QtWidgets import QLabel from PySide6.QtWidgets import QLabel
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color from orisys_app_shared.theme import color
@@ -17,7 +17,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
def _section_rule() -> QFrame: def _section_rule() -> QFrame:
rule = QFrame() rule = QFrame()
rule.setObjectName("sectionSeparator") rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine) rule.setFrameShape(QFrame.Shape.HLine)
rule.setFixedHeight(1) rule.setFixedHeight(1)
return rule return rule
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal from PySide6.QtCore import Qt, Signal
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
class SensorPickerWidget(QWidget): class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str) sensor_type_changed = Signal(str)
config_changed = pyqtSignal(str) config_changed = Signal(str)
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
#: attribute: it states which sensor the whole window is configured for. #: attribute: it states which sensor the whole window is configured for.
self.thumb_label = QLabel() self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge") self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter) self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE". # A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
self.thumb_label.setMinimumWidth(BADGE_WIDTH) self.thumb_label.setMinimumWidth(BADGE_WIDTH)
self.thumb_label.setProperty("rail", "true") self.thumb_label.setProperty("rail", "true")
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
self.type_combo = QComboBox() self.type_combo = QComboBox()
self.type_combo.setMinimumWidth(TYPE_WIDTH) self.type_combo.setMinimumWidth(TYPE_WIDTH)
self.type_combo.setProperty("rail", "true") self.type_combo.setProperty("rail", "true")
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
for name in list_sensor_types(): for name in list_sensor_types():
self.type_combo.addItem(name, name) self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
self.config_combo = QComboBox() self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(CONFIG_WIDTH) self.config_combo.setMinimumWidth(CONFIG_WIDTH)
self.config_combo.setProperty("rail", "true") self.config_combo.setProperty("rail", "true")
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed) self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
layout.addWidget(self.thumb_label) layout.addWidget(self.thumb_label)
+20 -20
View File
@@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal from PySide6.QtCore import Qt, Signal
from PyQt5.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
def _separator() -> QFrame: def _separator() -> QFrame:
line = QFrame() line = QFrame()
line.setObjectName("sectionSeparator") line.setObjectName("sectionSeparator")
line.setFrameShape(QFrame.VLine) line.setFrameShape(QFrame.Shape.VLine)
line.setFixedWidth(1) line.setFixedWidth(1)
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
return line return line
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
class ToolbarWidget(QWidget): class ToolbarWidget(QWidget):
search_clicked = pyqtSignal() search_clicked = Signal()
preview_toggled = pyqtSignal(bool) preview_toggled = Signal(bool)
camera_id_changed = pyqtSignal(int) camera_id_changed = Signal(int)
#: The sensor summary button: opens the sensor and calibration dialog. #: The sensor summary button: opens the sensor and calibration dialog.
sensor_clicked = pyqtSignal() sensor_clicked = Signal()
#: "Back to the camera" while a video file is the run source. #: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal() use_camera_clicked = Signal()
#: Replay view: pick the recorded video to play. #: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal() replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture). #: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = pyqtSignal() recompute_clicked = Signal()
run_toggled = pyqtSignal(bool) run_toggled = Signal(bool)
reset_clicked = pyqtSignal() reset_clicked = Signal()
units_changed = pyqtSignal(str) units_changed = Signal(str)
record_toggled = pyqtSignal(bool) record_toggled = Signal(bool)
#: The settings area was expanded or collapsed by the user. #: The settings area was expanded or collapsed by the user.
settings_toggled = pyqtSignal(bool) settings_toggled = Signal(bool)
def __init__( def __init__(
self, self,
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
# narrow window. # narrow window.
self._settings_scroll = QScrollArea() self._settings_scroll = QScrollArea()
self._settings_scroll.setWidgetResizable(True) self._settings_scroll.setWidgetResizable(True)
self._settings_scroll.setFrameShape(QFrame.NoFrame) self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.settings_page = self._build_settings() self.settings_page = self._build_settings()
self._settings_scroll.setWidget(self.settings_page) self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll) outer.addWidget(self._settings_scroll)
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
self.status_chip = QLabel() self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip") self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) self.status_chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.source_summary = ShrinkableLabel() self.source_summary = ShrinkableLabel()
self.source_summary.setObjectName("panelMeta") self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel() self.calibration_label = ShrinkableLabel()
+1 -1
View File
@@ -24,7 +24,7 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Optional, Protocol from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject from PySide6.QtCore import QObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+25 -2
View File
@@ -3,6 +3,10 @@
The tests render real Qt widgets with the offscreen platform plugin, so they need The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one). whole session (Qt forbids creating more than one).
The suite requires PySide6, the app's only Qt binding. Where it is missing the
modules are skipped rather than failed, so a broken environment cannot look like a
broken application (the same rule ``apps/autocal`` follows).
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,11 +20,30 @@ from pathlib import Path
import pytest import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise
# bind to it (it takes whichever toolkit it finds first) and the plots would break.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir(): if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts) os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def _have_pyside6() -> tuple[bool, str]:
try:
import PySide6
except ImportError as exc: # pragma: no cover - depends on the machine
return False, str(exc)
return True, getattr(PySide6, "__version__", "unknown")
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
# Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without
# the binding reports no tests instead of a wall of import errors.
collect_ignore_glob = ["test_*.py"]
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of # ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
# truth between the pytest suite and the dependency-free checker. # truth between the pytest suite and the dependency-free checker.
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" _TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
@@ -30,7 +53,7 @@ if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def qt_app(): def qt_app():
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
yield app yield app
@@ -83,7 +106,7 @@ def visible_descendants(widget):
def show_window(window, size): def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events.""" """Show a window at ``size`` and flush pending Qt events."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window.resize(*size) window.resize(*size)
window.show() window.show()
+224
View File
@@ -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
+4 -3
View File
@@ -94,15 +94,16 @@ def _render(widget, qt_app) -> np.ndarray:
on a little-endian machine, with a stride that can exceed the width — so the on a little-endian machine, with a stride that can exceed the width — so the
conversion and the stride trim are both load-bearing. conversion and the stride trim are both load-bearing.
""" """
from PyQt5.QtGui import QImage from PySide6.QtGui import QImage
widget.repaint() widget.repaint()
qt_app.processEvents() qt_app.processEvents()
image = widget.grabFramebuffer() image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer" assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format_RGB888) image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height() width, height = image.width(), image.height()
buffer = image.constBits().asarray(image.byteCount()) # PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
stride = image.bytesPerLine() stride = image.bytesPerLine()
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride) rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
return rows[:, : width * 3].reshape(height, width, 3).copy() return rows[:, : width * 3].reshape(height, width, 3).copy()
+6 -6
View File
@@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog.
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from PyQt5.QtCore import pyqtSignal from PySide6.QtCore import Signal
from PyQt5.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui import icons from orisys_studio.ui import icons
@@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app):
# Reset forwards to the view when there is one. # Reset forwards to the view when there is one.
class _View(QWidget): class _View(QWidget):
diagnosticRaised = pyqtSignal(object) diagnosticRaised = Signal(object)
enabledChanged = pyqtSignal(bool) enabledChanged = Signal(bool)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -229,7 +229,7 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window): def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
"""One line under the canvas: what the picture is, not how it was made.""" """One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = make_window() window = make_window()
window.show() window.show()
@@ -262,7 +262,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
def test_the_view_menu_opens_the_3d_settings(make_window): def test_the_view_menu_opens_the_3d_settings(make_window):
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header.""" """视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", show_finger_3d=True) window = make_window(sensor_type="finger", show_finger_3d=True)
window.show() window.show()
+5 -5
View File
@@ -19,7 +19,7 @@ import pytest
@pytest.fixture @pytest.fixture
def restore_surface_format(): def restore_surface_format():
"""The default surface format is process-wide; put it back afterwards.""" """The default surface format is process-wide; put it back afterwards."""
from PyQt5.QtGui import QSurfaceFormat from PySide6.QtGui import QSurfaceFormat
previous = QSurfaceFormat.defaultFormat() previous = QSurfaceFormat.defaultFormat()
try: try:
@@ -49,21 +49,21 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format): def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
from PyQt5.QtGui import QSurfaceFormat from PySide6.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33") configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat() desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.OpenGL assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3) assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.CoreProfile assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
assert desktop.depthBufferSize() == 24 assert desktop.depthBufferSize() == 24
assert desktop.samples() == 0, "v1 does not pay for multisampling" assert desktop.samples() == 0, "v1 does not pay for multisampling"
configure_surface_format("gles30") configure_surface_format("gles30")
embedded = QSurfaceFormat.defaultFormat() embedded = QSurfaceFormat.defaultFormat()
assert embedded.renderableType() == QSurfaceFormat.OpenGLES assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0) assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
with pytest.raises(ValueError): with pytest.raises(ValueError):
+11 -6
View File
@@ -29,8 +29,8 @@ import numpy as np
import pytest import pytest
import ui_check import ui_check
from PyQt5.QtCore import pyqtSignal from PySide6.QtCore import Signal
from PyQt5.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -276,8 +276,8 @@ class _RecordingView(QWidget):
a real ``QWidget`` because the container puts it in its stack. a real ``QWidget`` because the container puts it in its stack.
""" """
diagnosticRaised = pyqtSignal(object) diagnosticRaised = Signal(object)
enabledChanged = pyqtSignal(bool) enabledChanged = Signal(bool)
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -559,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
loaded: list = [] loaded: list = []
failed: list = [] failed: list = []
loader.loaded.connect(lambda assets, description: loaded.append((assets, description))) loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
loader.failed.connect(failed.append) # Same arity caveat as below: this branch should never fire, and under PySide6 a
# builtin slot would fail *silently* if it ever did.
loader.failed.connect(lambda item, request_id: failed.append(item))
try: try:
loader.load(SceneSource(path=path, kind="package", directory=path.parent)) loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered" assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
@@ -679,7 +681,10 @@ def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
broken.write_bytes(b"not a zip archive at all") broken.write_bytes(b"not a zip archive at all")
loader = SceneAssetLoader() loader = SceneAssetLoader()
failed: list = [] failed: list = []
loader.failed.connect(failed.append) # ``failed`` carries (diagnostic, request id). PySide6 adapts to a *Python* callable's
# arity but not to a builtin's, so ``failed.append`` raises TypeError inside the emit,
# Qt swallows it, and the failure signal looks like it was never sent.
loader.failed.connect(lambda item, request_id: failed.append(item))
try: try:
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path)) loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer" assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
+29 -29
View File
@@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
for i in range(60): for i in range(60):
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
QApplication.processEvents() QApplication.processEvents()
assert window.force_plots.history.sample_count > 0 assert window.force_plots.history.sample_count > 0
@@ -1009,7 +1009,7 @@ def test_top_area_stays_compact(make_window):
assert window.toolbar.record_chip.text() == t("record.state_idle") assert window.toolbar.record_chip.text() == t("record.state_idle")
window.toolbar.btn_settings.setChecked(True) window.toolbar.btn_settings.setChecked(True)
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
QApplication.processEvents() QApplication.processEvents()
assert window.toolbar.settings_expanded() assert window.toolbar.settings_expanded()
@@ -1032,7 +1032,7 @@ def test_settings_area_stays_inside_its_container(make_window, size):
The settings area is the one part of the top rail that may scroll: its own The settings area is the one part of the top rail that may scroll: its own
content is exempt, everything else must fit. content is exempt, everything else must fit.
""" """
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
window._set_settings_expanded(True) window._set_settings_expanded(True)
@@ -1063,7 +1063,7 @@ def test_idle_run_is_the_only_filled_action(make_window):
def test_connecting_cancel_stays_filled(make_window): def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight.""" """Cancel is the way out of a hung device open, so it keeps the weight."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker() stuck = ui_check._StuckWorker()
@@ -1197,7 +1197,7 @@ def test_overview_region_is_the_timeline_control(make_window):
def test_the_charts_start_in_follow_mode(make_window): def test_the_charts_start_in_follow_mode(make_window):
"""Every live entry point resumes following; only browsing is a gesture.""" """Every live entry point resumes following; only browsing is a gesture."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1229,7 +1229,7 @@ def test_the_charts_start_in_follow_mode(make_window):
def test_the_charts_zoom_and_pan_in_every_view(make_window): def test_the_charts_zoom_and_pan_in_every_view(make_window):
"""Zoom/pan must be reachable from the live view, not only while browsing.""" """Zoom/pan must be reachable from the live view, not only while browsing."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
panel = window.force_plots panel = window.force_plots
@@ -1306,7 +1306,7 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
def test_the_sensor_summary_opens_the_dialog(make_window): def test_the_sensor_summary_opens_the_dialog(make_window):
"""The rail states the sensor; the pickers live in the dialog it opens.""" """The rail states the sensor; the pickers live in the dialog it opens."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
def test_the_source_picker_follows_the_view(make_window): def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, the replay a recorded video — and no residue.""" """Live offers a camera, the replay a recorded video — and no residue."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible() assert window.toolbar.camera_group.isVisible()
@@ -1425,11 +1425,11 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
def test_the_calibration_view_does_not_clip_its_controls(make_window): def test_the_calibration_view_does_not_clip_its_controls(make_window):
from PyQt5.QtWidgets import QHeaderView from PySide6.QtWidgets import QHeaderView
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880)) window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
window.brand_bar.btn_calibration.click() window.brand_bar.btn_calibration.click()
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
for _ in range(3): for _ in range(3):
QApplication.processEvents() QApplication.processEvents()
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
cal = window.cal_panel cal = window.cal_panel
assert cal.scroll.isVisible(), "the calibration panel cannot scroll" assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
assert ( assert (
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
), "the diagnostic columns can still be elided" ), "the diagnostic columns can still be elided"
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation): for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
assert button.height() >= button.fontMetrics().height(), button.text() assert button.height() >= button.fontMetrics().height(), button.text()
@@ -1468,7 +1468,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click() window.brand_bar.btn_live.click()
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
QApplication.processEvents() QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes()) live_sizes = tuple(window.content_splitter.sizes())
@@ -1496,7 +1496,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording): def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
"""The replay view reads the recording: charts, 2D panel and transport.""" """The replay view reads the recording: charts, 2D panel and transport."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1602,7 +1602,7 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display from orisys_studio.ui.force_plots import unit_display
from PyQt5.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
plots = window.force_plots plots = window.force_plots
@@ -1734,7 +1734,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
While the replay view is up, its frames own the 2D panel; a live packet used While the replay view is up, its frames own the 2D panel; a live packet used
to paint over the recording. Leaving the view hands the pane back. to paint over the recording. Leaving the view hands the pane back.
""" """
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
window.brand_bar.btn_playback.click() window.brand_bar.btn_playback.click()
@@ -1786,7 +1786,7 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
A replay used to state the live acquisition in cards sitting next to a chart A replay used to state the live acquisition in cards sitting next to a chart
of 7.5e5 raw counts, which reads as "the replay never opened". of 7.5e5 raw counts, which reads as "the replay never opened".
""" """
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1820,7 +1820,7 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
for the axes it calibrated. Asking for newton anyway drew three blank charts for the axes it calibrated. Asking for newton anyway drew three blank charts
and a dash in every card, which looks exactly like "the replay did not load". and a dash in every card, which looks exactly like "the replay did not load".
""" """
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1847,7 +1847,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
"""Play/pause like an editor: 1× forward, and a manual gesture takes over.""" """Play/pause like an editor: 1× forward, and a manual gesture takes over."""
import time import time
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() window.brand_bar.btn_playback.click()
@@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
make_window, replay_recording make_window, replay_recording
): ):
"""Off: the recorded CSV. On: the recorded video through the vision chain.""" """Off: the recorded CSV. On: the recorded video through the vision chain."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() window.brand_bar.btn_playback.click()
@@ -1929,7 +1929,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
wrong. So the rail's second button becomes an unconditional stop in this view, and wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass. it covers both things that can run there: plain playback and a recompute pass.
""" """
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -1989,7 +1989,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
def test_escape_stops_the_replay_too(make_window, replay_recording): def test_escape_stops_the_replay_too(make_window, replay_recording):
"""Esc is the documented way out; in the replay view it stops the replay.""" """Esc is the documented way out; in the replay view it stops the replay."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() window.brand_bar.btn_playback.click()
@@ -2007,7 +2007,7 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
def test_the_live_view_keeps_the_tracker_reset(make_window): def test_the_live_view_keeps_the_tracker_reset(make_window):
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click() window.brand_bar.btn_live.click()
@@ -2028,7 +2028,7 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
import cv2 import cv2
import numpy as np import numpy as np
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -2091,7 +2091,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
""" """
import cv2 import cv2
import numpy as np import numpy as np
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
@@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
""" """
import cv2 import cv2
import numpy as np import numpy as np
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -2239,7 +2239,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
make_window, record_root make_window, record_root
): ):
"""Not a recording *and* not decodable: say what is missing and offer recompute.""" """Not a recording *and* not decodable: say what is missing and offer recompute."""
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -2272,7 +2272,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
def test_a_video_inside_a_recording_opens_the_recording_and_plays( def test_a_video_inside_a_recording_opens_the_recording_and_plays(
make_window, replay_recording make_window, replay_recording
): ):
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -2300,7 +2300,7 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
import csv import csv
import json import json
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -2399,7 +2399,7 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
""" """
import time import time
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
+5 -5
View File
@@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None:
def _inside_scroll_area(widget) -> bool: def _inside_scroll_area(widget) -> bool:
"""True when ``widget`` lives inside a QScrollArea's content.""" """True when ``widget`` lives inside a QScrollArea's content."""
from PyQt5.QtWidgets import QScrollArea from PySide6.QtWidgets import QScrollArea
node = widget node = widget
while node is not None: while node is not None:
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
def main() -> int: def main() -> int:
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.i18n import set_locale, t
@@ -1491,7 +1491,7 @@ def main() -> int:
window.brand_bar.btn_calibration.click() window.brand_bar.btn_calibration.click()
for _ in range(3): for _ in range(3):
app.processEvents() app.processEvents()
from PyQt5.QtWidgets import QHeaderView as _QHeaderView from PySide6.QtWidgets import QHeaderView as _QHeaderView
sizes = window.content_splitter.sizes() sizes = window.content_splitter.sizes()
check( check(
@@ -2000,7 +2000,7 @@ def main() -> int:
"import os, sys\n" "import os, sys\n"
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n" "os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n" "os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
"from PyQt5.QtCore import QTimer\n" "from PySide6.QtCore import QTimer\n"
"import orisys_studio.main as studio_main\n" "import orisys_studio.main as studio_main\n"
"show = studio_main.StudioMainWindow.show\n" "show = studio_main.StudioMainWindow.show\n"
"def show_and_close(self):\n" "def show_and_close(self):\n"
@@ -2259,7 +2259,7 @@ def main() -> int:
import orisys_studio.ui.cal_panel as _cal_panel_module import orisys_studio.ui.cal_panel as _cal_panel_module
import orisys_studio.calibration_service as _cal_service_module import orisys_studio.calibration_service as _cal_service_module
from PyQt5.QtWidgets import QMessageBox as _QMessageBox from PySide6.QtWidgets import QMessageBox as _QMessageBox
from orisys_app_shared.force.calibration_io import ( from orisys_app_shared.force.calibration_io import (
build_force_calibration_v2 as _build_v2b, build_force_calibration_v2 as _build_v2b,
+1 -1
View File
@@ -391,7 +391,7 @@ def main() -> int:
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss from orisys_app_shared.theme import build_qss
+13 -4
View File
@@ -24,7 +24,7 @@ dist/
OrisysStudio/ OrisysStudio/
OrisysStudio.exe OrisysStudio.exe
config/ # lean locales + sensors (no finger visualization/) config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, … _internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, …
``` ```
## Why there are two steps ## Why there are two steps
@@ -137,11 +137,20 @@ loaded" can only be printed if those modules imported.)
## Dependency notes ## Dependency notes
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls - **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
without a `cv2` module) and verifies `import cv2`. (`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`).
`setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one
binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover
PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes
`PyQt5`/`PyQt6`/`PySide2` outright, and must never exclude `PySide6` itself.
- App shared dep uses **`opencv-python-headless`** (PySide6 owns the GUI). `setup.ps1`
uninstalls any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped
wheels without a `cv2` module) and verifies `import cv2`.
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra. - **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped. Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
- **`QOpenGLWidget` lives in `PySide6.QtOpenGLWidgets`**, not `QtWidgets` (Qt6 moved it): the
spec names it in `hiddenimports` so the module and `Qt6OpenGLWidgets.dll` are bundled.
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is - **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
Qt `QOpenGLWidget` + PyOpenGL. Qt `QOpenGLWidget` + PyOpenGL.
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force` - `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
+9 -4
View File
@@ -4,7 +4,11 @@
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one # on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
# machine and produces a non-starting EXE on another is usually a version difference that # machine and produces a non-starting EXE on another is usually a version difference that
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's # nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set. # own loose requirements (PySide6>=6.6, scipy>=1.10.1, ...) resolve to exactly this set.
#
# PySide6 rather than PyQt5: the apps are LGPLv3-bound. PyQt5 must not be present in the
# build environment at all — `pyqtgraph` binds to whichever toolkit it finds first, so a
# leftover PyQt5 produces plots built from the wrong binding (setup.ps1 uninstalls it).
# #
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately # `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
# from PyPI with -FromPypi), and pinning it by name would fight the editable install. # from PyPI with -FromPypi), and pinning it by name would fight the editable install.
@@ -15,9 +19,10 @@
PyInstaller==6.22.2 PyInstaller==6.22.2
pyinstaller-hooks-contrib==2026.7 pyinstaller-hooks-contrib==2026.7
PyOpenGL==3.1.10 PyOpenGL==3.1.10
PyQt5==5.15.11 PySide6==6.11.2
PyQt5-Qt5==5.15.2 PySide6-Essentials==6.11.2
PyQt5-sip==12.19.0 PySide6-Addons==6.11.2
shiboken6==6.11.2
numpy==2.2.6 numpy==2.2.6
scipy==1.15.3 scipy==1.15.3
numba==0.67.0 numba==0.67.0
+49 -16
View File
@@ -79,7 +79,12 @@ datas = _branding_datas()
binaries: list = _extra_runtime_binaries() binaries: list = _extra_runtime_binaries()
hiddenimports = [ hiddenimports = [
"cv2", "cv2",
"PyQt5.sip", # PySide6's runtime is shiboken6; the Qt binding itself is found from the imports, but
# QtOpenGLWidgets is unusual enough to name explicitly: it is a separate module and DLL
# (Qt6OpenGLWidgets), and without it the 3D pane reports "no OpenGL widgets" while the
# rest of the app looks fine.
"shiboken6",
"PySide6.QtOpenGLWidgets",
"pyqtgraph.graphicsItems", "pyqtgraph.graphicsItems",
"scipy.signal", "scipy.signal",
"numba", "numba",
@@ -126,22 +131,50 @@ a = Analysis(
"jupyter", "jupyter",
"notebook", "notebook",
"pytest", "pytest",
"PyQt5.QtBluetooth", # The app uses PySide6 only. Qt modules it never imports are excluded to keep the
"PyQt5.QtNfc", # bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole
"PyQt5.QtPositioning", # binding produces an EXE with no Qt at all (it fails at start-up, not at build
"PyQt5.QtLocation", # time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately
"PyQt5.QtMultimedia", # absent from this list.
"PyQt5.QtMultimediaWidgets", "PySide6.Qt3DAnimation",
"PyQt5.QtSensors", "PySide6.Qt3DCore",
"PyQt5.QtSerialPort", "PySide6.Qt3DExtras",
"PyQt5.QtWebChannel", "PySide6.Qt3DInput",
"PyQt5.QtWebEngine", "PySide6.Qt3DLogic",
"PyQt5.QtWebEngineCore", "PySide6.Qt3DRender",
"PyQt5.QtWebEngineWidgets", "PySide6.QtBluetooth",
"PyQt5.QtWebSockets", "PySide6.QtCharts",
"PySide2", "PySide6.QtDataVisualization",
"PySide6", "PySide6.QtGraphs",
"PySide6.QtHelp",
"PySide6.QtLocation",
"PySide6.QtMultimedia",
"PySide6.QtMultimediaWidgets",
"PySide6.QtNfc",
"PySide6.QtPdf",
"PySide6.QtPdfWidgets",
"PySide6.QtPositioning",
"PySide6.QtQml",
"PySide6.QtQuick",
"PySide6.QtQuick3D",
"PySide6.QtQuickControls2",
"PySide6.QtQuickWidgets",
"PySide6.QtRemoteObjects",
"PySide6.QtScxml",
"PySide6.QtSensors",
"PySide6.QtSerialBus",
"PySide6.QtSerialPort",
"PySide6.QtSpatialAudio",
"PySide6.QtStateMachine",
"PySide6.QtTextToSpeech",
"PySide6.QtWebChannel",
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineQuick",
"PySide6.QtWebEngineWidgets",
"PySide6.QtWebSockets",
"PyQt5",
"PyQt6", "PyQt6",
"PySide2",
], ],
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
win_private_assemblies=False, win_private_assemblies=False,
+13 -2
View File
@@ -237,12 +237,19 @@ $install += @($PyInstallerPin, $CvPin)
& $VenvPy @install & $VenvPy @install
if ($LASTEXITCODE -ne 0) { throw "pip install failed" } if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns # Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PySide6 owns
# the GUI), and 5.x can arrive without a `cv2` module. # the GUI), and 5.x can arrive without a `cv2` module.
& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null & $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null
& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin & $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin
if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" } if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" }
# PyQt5 must not be in the build environment, even though the SDK's `gui` extra asks for it
# when someone installs that: `pyqtgraph` binds to whichever toolkit it imports first, and a
# frozen app built where PyQt5 is present can end up with plots built from the wrong binding.
# The apps are PySide6-only (see apps/studio/tests/test_binding_purity.py).
Write-Host "Removing any other Qt binding from the build venv ..."
& $VenvPy -m pip uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip PyQt6 PySide2 2>$null | Out-Null
# Open3D must not be in the build env: it is an offline tool, and the spec excludes it. # Open3D must not be in the build env: it is an offline tool, and the spec excludes it.
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)" $hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
if ($hasOpen3d -eq "True") { if ($hasOpen3d -eq "True") {
@@ -256,8 +263,12 @@ if (-not $NoVerify) {
Write-Host "Verifying the provisioned environment ..." Write-Host "Verifying the provisioned environment ..."
# These are the imports the frozen app makes before it can draw anything. Checking them # These are the imports the frozen app makes before it can draw anything. Checking them
# here is what turns "the build succeeded" into "the build can start". # here is what turns "the build succeeded" into "the build can start".
& $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')" & $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PySide6.QtWidgets, PySide6.QtOpenGLWidgets, PyInstaller; print('imports OK')"
if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" } if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" }
# A second binding in the venv is the failure mode the uninstall above exists for: it
# imports fine here and breaks `pyqtgraph` at run time.
& $VenvPy -c "import importlib.util as u; bad=[m for m in ('PyQt5','PyQt6','PySide2') if u.find_spec(m)]; assert not bad, 'another Qt binding is installed: '+str(bad); print('binding OK: PySide6 only')"
if ($LASTEXITCODE -ne 0) { throw "the build venv has more than one Qt binding" }
# Theme colours are package data: PyInstaller only bundles them if the package is # Theme colours are package data: PyInstaller only bundles them if the package is
# importable at build time, so a stale editable install loses them silently. # importable at build time, so a stale editable install loses them silently.
& $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')" & $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
+7 -6
View File
@@ -13,9 +13,10 @@ from concurrent.futures import Executor
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from PyQt5.QtCore import QEvent, pyqtBoundSignal from PySide6.QtCore import QEvent, SignalInstance
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent from PySide6.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QWidget
from orisys_app_shared.acquisition.models import SdkPacket from orisys_app_shared.acquisition.models import SdkPacket
from orisys.finger.runtime import FingerRuntime from orisys.finger.runtime import FingerRuntime
@@ -343,9 +344,9 @@ class GLRenderer(Protocol):
class Sensor3DWidget(QOpenGLWidget): class Sensor3DWidget(QOpenGLWidget):
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities. capabilitiesChanged: SignalInstance # Payload: GLCapabilities.
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic. diagnosticRaised: SignalInstance # Payload: Diagnostic.
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited. renderStatsChanged: SignalInstance # Payload: RenderStats; rate-limited.
def __init__(self, renderer: GLRenderer, camera: OrbitCamera, def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
parent: QWidget | None = None) -> None: ... parent: QWidget | None = None) -> None: ...
def set_scene(self, assets: SceneAssets) -> None: def set_scene(self, assets: SceneAssets) -> None:
+4 -3
View File
@@ -6,7 +6,7 @@
## 1. 决策与实施范围 ## 1. 决策与实施范围
**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。 **推荐使用现有 Qt 技术栈的 QOpenGLWidget + PyOpenGL(Qt 绑定为 PySide6,LGPLv3),使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。 目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
@@ -14,7 +14,7 @@
|---|---|---| |---|---|---|
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 | | QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 | | Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 | | Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PySide6 暴露的接口。Qt6 把 `QOpenGLWidget` 移到了 `QtOpenGLWidgets`,打包时该模块与 `Qt6OpenGLWidgets.dll` 都要收进 bundle |
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 | | 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html) Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
@@ -366,7 +366,8 @@ Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形
### 11.1 依赖 ### 11.1 依赖
- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。 - 继续使用项目验证过的 Python、**PySide6**、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
Qt 绑定选 PySide6(LGPLv3)而不是 PyQt5(GPLv3-or-commercial):两个 app 都只允许 PySide6,由 `apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 静态强制;构建环境里出现第二个 binding 会让 `pyqtgraph` 绑错(`setup.ps1` 会卸载 PyQt5/PyQt6/PySide2,`main.py` 在 import pyqtgraph 之前设 `PYQTGRAPH_QT_LIB=PySide6`)。
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html) - PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。 - 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。 - PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
+3 -3
View File
@@ -186,9 +186,9 @@ graph TB
| 规则 | 是否成立 | 证据 | | 规则 | 是否成立 | 证据 |
|---|---|---| |---|---|---|
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** | | 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PyQt5 的 21 个模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PyQt5 **画图标时**才 import(函数内 + try 保护) | | 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `tests/test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) |
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 | | OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PyQt5/OpenGL/SDK | | 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK |
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" | | 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 | | 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 |
| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO | | 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO |
@@ -300,7 +300,7 @@ graph TB
|---|---|---|---|---| |---|---|---|---|---|
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 | | 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` | | 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PyQt5/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 | | 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` | | 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 | | 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) | | 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
+4 -1
View File
@@ -9,7 +9,10 @@ description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }] authors = [{ name = "Orisys-Tech" }]
dependencies = [ dependencies = [
"PyQt5>=5.15", # Shared by both apps, which are PySide6-only (LGPLv3). `theme/qss.py` paints the chevron
# icons with it; nothing here may import another binding — enforced by
# apps/autocal/tests/test_binding_purity.py and apps/studio/tests/test_binding_purity.py.
"PySide6>=6.6",
"opencv-python-headless>=4.10.0.84,<5", "opencv-python-headless>=4.10.0.84,<5",
"numpy>=1.24.4", "numpy>=1.24.4",
"scipy>=1.10.1", "scipy>=1.10.1",
+6 -6
View File
@@ -33,8 +33,8 @@ def _arrow_icon_cache_dir() -> Path:
def _paint_arrow(kind: str, fill: str) -> str: def _paint_arrow(kind: str, fill: str) -> str:
"""Draw a small chevron PNG on demand; return its path ('' on failure).""" """Draw a small chevron PNG on demand; return its path ('' on failure)."""
try: try:
from PyQt5.QtCore import QPointF, Qt from PySide6.QtCore import QPointF, Qt
from PyQt5.QtGui import QColor, QImage, QPainter, QPolygonF from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF
except Exception: # pragma: no cover - headless import failure except Exception: # pragma: no cover - headless import failure
return "" return ""
try: try:
@@ -43,12 +43,12 @@ def _paint_arrow(kind: str, fill: str) -> str:
path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png" path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png"
if path.is_file(): if path.is_file():
return str(path) return str(path)
image = QImage(size, size, QImage.Format_ARGB32) image = QImage(size, size, QImage.Format.Format_ARGB32)
image.fill(Qt.transparent) image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image) painter = QPainter(image)
painter.setRenderHint(QPainter.Antialiasing, True) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setBrush(QColor(fill)) painter.setBrush(QColor(fill))
painter.setPen(Qt.NoPen) painter.setPen(Qt.PenStyle.NoPen)
half = size / 2.0 half = size / 2.0
top = size * 0.32 top = size * 0.32
bottom = size * 0.68 bottom = size * 0.68
+3 -1
View File
@@ -464,6 +464,8 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only
proves what some earlier test happened to import (it failed once the studio tests proves what some earlier test happened to import (it failed once the studio tests
painted a widget before this file ran, which says nothing about the contract layer). painted a widget before this file ran, which says nothing about the contract layer).
The Qt name checked is the binding the app uses (PySide6), which is the only one that
could be pulled in — the earlier spelling named PyQt5, before the migration.
""" """
import subprocess import subprocess
import sys import sys
@@ -471,7 +473,7 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
script = ( script = (
"import sys;" "import sys;"
"import orisys_app_shared.scene3d.contracts as c;" "import orisys_app_shared.scene3d.contracts as c;"
"print(','.join(m for m in ('OpenGL', 'PyQt5', 'cv2') if m in sys.modules))" "print(','.join(m for m in ('OpenGL', 'PySide6', 'cv2') if m in sys.modules))"
) )
result = subprocess.run( result = subprocess.run(
[sys.executable, "-c", script], [sys.executable, "-c", script],
+1 -1
View File
@@ -310,7 +310,7 @@ def measure(
) -> tuple[dict, int]: ) -> tuple[dict, int]:
"""Run the assembled view and report what each stage cost.""" """Run the assembled view and report what each stage cost."""
import numpy as np import numpy as np
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_app_shared.scene3d.contracts import DisplaySettings
+9 -5
View File
@@ -43,8 +43,9 @@ def _bootstrap_paths() -> None:
def collect(profile: str, *, keep_open_s: float) -> dict: def collect(profile: str, *, keep_open_s: float) -> dict:
"""Create a real window, let Qt initialise it, and report what it got.""" """Create a real window, let Qt initialise it, and report what it got."""
from PyQt5.QtCore import QTimer from PySide6.QtCore import QTimer
from PyQt5.QtWidgets import QApplication, QOpenGLWidget from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QApplication
from orisys_studio.scene3d.gl_backend import ( from orisys_studio.scene3d.gl_backend import (
GLDispatch, GLDispatch,
@@ -103,7 +104,7 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
self.ready = True self.ready = True
def report_caps(self, capabilities) -> None: def report_caps(self, capabilities) -> None:
from PyQt5.QtGui import QSurfaceFormat from PySide6.QtGui import QSurfaceFormat
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat() fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
ratio = float(self.devicePixelRatioF() or 1.0) ratio = float(self.devicePixelRatioF() or 1.0)
@@ -118,8 +119,11 @@ def collect(profile: str, *, keep_open_s: float) -> dict:
"max_texture_size": capabilities.max_texture_size, "max_texture_size": capabilities.max_texture_size,
"qt_format": { "qt_format": {
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}", "version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
"renderable_type": int(fmt.renderableType()), # PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises
"profile": int(fmt.profile()), # TypeError, so read `.value`. (PyQt5 exposed them as ints, which is
# why this worked before the migration.)
"renderable_type": int(fmt.renderableType().value),
"profile": int(fmt.profile().value),
"depth_buffer": int(fmt.depthBufferSize()), "depth_buffer": int(fmt.depthBufferSize()),
"samples": int(fmt.samples()), "samples": int(fmt.samples()),
}, },
+1 -1
View File
@@ -141,7 +141,7 @@ def run(
view = None view = None
widget = None widget = None
if use_gui: if use_gui:
from PyQt5.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView from orisys_studio.scene3d.view import Scene3DView