feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)

- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source`
  只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置,
  `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因),
  `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的
  第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。
- Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的
  "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。
- Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、
  adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项;
  shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败
  (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐;
  记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同;
  生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。
- Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度);
  ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5);
  仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
This commit is contained in:
ykcheungab
2026-09-14 15:47:32 +08:00
parent ea1027e5a6
commit ac4dcd643f
20 changed files with 2718 additions and 83 deletions
@@ -74,12 +74,41 @@ def _shape_of(array: Optional[np.ndarray]) -> str:
return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2])
def _intersect_device_mask(
packet: SdkPacket, mask: np.ndarray, shape_hw: tuple[int, int]
) -> np.ndarray:
"""AND in the device's own validity, when it describes this grid.
A device mask of the wrong shape is ignored rather than resized: a mask from
another grid says nothing about these pixels, and stretching it would mark
samples valid that the device never claimed.
"""
explicit = getattr(packet, "valid_mask", None)
if explicit is None:
return freeze(mask, dtype=np.bool_)
explicit_array = np.asarray(explicit, dtype=bool)
if tuple(explicit_array.shape) == tuple(shape_hw):
return freeze(np.asarray(mask, dtype=bool) & explicit_array, dtype=np.bool_)
return freeze(mask, dtype=np.bool_)
class FingerFrameAdapter:
"""The finger runtime's field, in the scene's UV domain.
``map_x``/``map_y`` are the unroll map's source coordinates. When they are
supplied the field is remapped through them; when they are not, the field is
used directly and only if its shape already *is* the domain.
**The live flow is already unrolled, measured rather than assumed.** The
pipeline computes optical flow on the frame the unroll stage produced
(``FrameUnwarper`` output, ``camera.resolution_stitch`` = 280x300), and
``Sensor.flow`` is that tracker's field; the scene's UV domain is the same
grid (300, 280) with the same valid mask. So the app passes **no** remap here:
remapping an already-unrolled field through ``map_x``/``map_y`` would be a
double remap — plausible-looking and wrong. The parameters exist for a source
that really does deliver source-domain flow; the app must not use them for
this one.
The device's own ``valid_mask`` is intersected in, because outside the UV
island the tracker is looking at black border: those samples are not
measurements, and colouring them would invent a signal at the edge of the
model.
"""
def __init__(
@@ -143,6 +172,9 @@ class FingerFrameAdapter:
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
)
return None
# Inside the UV island is the only place a flow value means anything: the
# tracker is looking at masked black outside it.
mask = _intersect_device_mask(packet, mask, self.shape_hw)
self.last_diagnostic = None
return SurfaceFieldFrame(
identity=identity,
@@ -195,13 +227,10 @@ class BoxFrameAdapter:
array = np.asarray(field, dtype=np.float32)
if array.ndim != 2 or tuple(array.shape) != self.shape_hw:
continue
mask = np.isfinite(array)
explicit = getattr(packet, "valid_mask", None)
if explicit is not None:
explicit_array = np.asarray(explicit, dtype=bool)
if tuple(explicit_array.shape) == self.shape_hw:
# The device's own mask wins: it knows where its surface is.
mask = mask & explicit_array
# The device's own mask wins: it knows where its surface is.
mask = _intersect_device_mask(
packet, np.isfinite(array), self.shape_hw
)
self.last_diagnostic = None
return SurfaceFieldFrame(
identity=identity,
@@ -0,0 +1,181 @@
"""Where the application's 3D scene comes from, and how it fails when it is absent.
Stage 4 in ``docs/architecture/studio-3d-stages.md``.
The runtime loads a **compiled** package. It does not import the Blender export at
start-up, and above all it does not invent the two facts only a person can supply:
the unit scale (how many metres one source unit is) and the sensor-to-source rigid
transform. ``legacy.LegacyFingerImporter`` refuses to guess them, and a 3D pane that
quietly assumed "one source unit is a millimetre" would draw a model that looks
right and is wrong by whatever the real factor is. So the search below looks for
artifacts that already carry those decisions, and its failure message is the
instructions for producing one.
Search order (first hit wins):
1. ``prefs["scene3d_package"]`` — an explicit path from the settings file, which is
also how a developer points the app at a freshly compiled package;
2. ``<config>/sensors/<sensor>/scene/*.oryscene`` — a package shipped beside the
sensor configuration, which is where a release puts it;
3. nothing: the caller reports ``no_asset`` and shows the compile command.
Loading reads a file and validates a package: it must not run on the GUI thread.
:class:`SceneAssetLoader` does that work on one worker thread and hands the result
back through a signal, because a 200 ms freeze while the operator presses Run is
exactly the kind of thing this feature is not allowed to cause.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from PyQt5.QtCore import QObject, pyqtSignal
from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
ExpectedGeometry,
SceneAssets,
diagnostic,
error,
)
logger = logging.getLogger(__name__)
#: The container suffix written by ``tools/scene_assets/compile_scene.py``.
PACKAGE_SUFFIX = ".oryscene"
#: The command that produces a package, quoted in the failure message so the pane
#: tells the operator what to do instead of only what is missing.
COMPILE_HINT = (
"python tools/scene_assets/compile_scene.py "
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
"--meters-per-source-unit <stated> --output "
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
)
@dataclass(frozen=True)
class SceneSource:
"""A package the app can load, and where it came from."""
path: Path
kind: str # "package"
directory: Path
def describe(self) -> str:
return f"{self.kind}: {self.path}"
def scene_dir(sensor_type: str, *, root: Optional[Path] = None) -> Path:
"""Where a compiled package for this sensor belongs."""
base = Path(root) if root is not None else config_dir()
return base / "sensors" / str(sensor_type) / "scene"
def find_package(
sensor_type: str,
*,
override: str | Path | None = None,
root: Optional[Path] = None,
) -> Optional[SceneSource]:
"""The package to load, or ``None``. Never raises: absence is a state."""
if override:
path = Path(str(override)).expanduser()
if path.is_file():
return SceneSource(path=path, kind="package", directory=path.parent)
logger.warning("the configured 3D package does not exist: %s", path)
directory = scene_dir(sensor_type, root=root)
if directory.is_dir():
candidates = sorted(p for p in directory.glob(f"*{PACKAGE_SUFFIX}") if p.is_file())
if candidates:
# A single package is the normal case; several means a release shipped
# more than one version, and the name order picks the last one, which is
# also what a person reading the directory would do.
return SceneSource(path=candidates[-1], kind="package", directory=directory)
return None
def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None) -> Diagnostic:
"""What to say when there is no package: where it looked, and how to make one.
The message stays in English like every other diagnostic (it is also logged);
the operator-facing wording is the panel's own ``panels.mesh3d_no_asset`` label,
and the compile command travels in ``details`` for support to quote exactly.
"""
searched = scene_dir(sensor_type)
details = {"directory": str(searched), "compile": COMPILE_HINT}
if override:
details["configured"] = str(override)
return diagnostic(
"asset_rejected",
f"no compiled scene package for this sensor: looked in {searched} "
f"for *{PACKAGE_SUFFIX}",
**details,
)
def load_package(
source: SceneSource, expected: Optional[ExpectedGeometry] = None
) -> SceneAssets:
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``."""
from orisys_app_shared.scene3d.package import PackagedAssetLoader
if not source.path.is_file():
raise error("asset_rejected", f"the scene package is missing: {source.path}")
return PackagedAssetLoader().load(source.path, expected)
class SceneAssetLoader(QObject):
"""Loads one scene off the GUI thread and reports the result as a signal.
One load is kept: the pane only ever needs the newest, and a second request while
one is running is a request for the same file.
"""
loaded = pyqtSignal(object, str) # SceneAssets, source description
failed = pyqtSignal(object) # Diagnostic
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self._executor: Optional[ThreadPoolExecutor] = None
self._inflight = False
def load(self, source: SceneSource, expected: Optional[ExpectedGeometry] = None) -> None:
if self._inflight:
return
if self._executor is None:
self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="scene3d-assets"
)
self._inflight = True
future = self._executor.submit(load_package, source, expected)
future.add_done_callback(
lambda done: self._finished(done, source)
)
def _finished(self, future, source: SceneSource) -> None:
self._inflight = False
try:
assets = future.result()
except Exception as exc: # noqa: BLE001 - a bad package is a diagnostic
item = getattr(exc, "diagnostic", None) or diagnostic(
"asset_rejected", f"{type(exc).__name__}: {exc}"
)
logger.warning("3D scene %s was rejected: %s", source.path, item.message)
# Emitted from this worker thread; the receiver is a Qt object on the
# GUI thread, so the connection is queued (see the view's `_on_loaded`).
self.failed.emit(item)
return
logger.info("3D scene loaded from %s", source.path)
self.loaded.emit(assets, source.describe())
def close(self) -> None:
if self._executor is not None:
self._executor.shutdown(wait=False)
self._executor = None
@@ -206,6 +206,51 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
return header + _VERTEX_BODY, header + body
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
"""The range of the *valid* samples, or ``None`` when there are none.
Only valid and finite samples count. Including the invalid ones would make the
colour range follow whatever garbage sits outside the UV island, and including
NaN would produce a range of NaN and a picture with no colours at all.
"""
import numpy as np
values = np.asarray(scalar, dtype=np.float64).reshape(-1)
mask = np.asarray(valid, dtype=bool).reshape(-1)
if values.size != mask.size:
return None
usable = values[mask & np.isfinite(values)]
if usable.size == 0:
return None
return float(usable.min()), float(usable.max())
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
"""The colour range to draw with: the operator's, or the data's own.
``automatic`` mode exists because the quantities differ in magnitude by orders
(a flow magnitude in pixels, a deformation response around 1, a calibrated
displacement in metres): a single fixed range would leave one of them as a flat
colour. An automatic range with no valid samples falls back to a fixed 0..1 so
the shader always receives a usable interval — never NaN, and never a division
by a span of zero.
"""
if scale is None:
fallback = automatic or (0.0, 1.0)
elif str(getattr(scale, "mode", "fixed")) == "automatic" and automatic is not None:
fallback = automatic
else:
fallback = (float(scale.lower), float(scale.upper))
lower, upper = float(fallback[0]), float(fallback[1])
if not (lower == lower) or not (upper == upper): # NaN
return 0.0, 1.0
if upper <= lower:
# A constant field still has to be visible rather than all one clipped colour.
pad = max(abs(lower) * 0.5, 0.5)
return lower - pad, lower + pad
return lower, upper
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
"""A 256x1 RGBA8 lookup table.
@@ -458,6 +503,9 @@ class GLRenderer:
self._parts: dict[str, _PartBuffers] = {}
self._lut: int = 0
self._lut_id: str = ""
#: Range of the valid samples of the frame currently uploaded, for the
#: automatic colour scale. ``None`` until a frame carries valid data.
self._scalar_range: Optional[tuple[float, float]] = None
self._sensing_part_id: str = ""
self._estimated_gpu_bytes: int = 0
self._initialized = False
@@ -547,6 +595,7 @@ class GLRenderer:
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
self._scalar_range = _valid_scalar_range(frame.scalar, frame.valid)
def draw(
self,
@@ -570,10 +619,7 @@ class GLRenderer:
view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4)
projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4)
scale = settings.color_scale
lower = float(scale.lower) if scale is not None else 0.0
upper = float(scale.upper) if scale is not None else 1.0
if upper <= lower:
upper = lower + 1.0
lower, upper = _scale_bounds(scale, self._scalar_range)
self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential")
for part in self._parts.values():
if part.role == "housing" and not settings.housing_visible:
+345 -13
View File
@@ -1,37 +1,73 @@
"""``ThreeDPanel``: the controls for the three-dimensional view.
"""``ThreeDPanel``: the controls and the status line for the three-dimensional view.
Stage 4 in ``docs/architecture/studio-3d-stages.md``.
UI only. It reads and writes :class:`DisplaySettings`, camera presets and
visibility, and it shows what the scene says about itself — quantity, unit,
quality, frame number, "no live data". It does not open assets, read the SDK, or
call GL, and it never presents an ``approximate`` displacement as millimetres.
call GL.
The measurement colour bar is hidden (not left showing the previous range)
whenever the frame's descriptor is ``unavailable`` — the reference model is not a
measurement and must not look like one.
Two rules it exists to enforce in the interface:
* the measurement colour bar is **hidden**, not left showing the previous range,
whenever the frame's descriptor is ``unavailable``: the reference model is not a
measurement and must not look like one;
* an ``approximate`` displacement is labelled "relative deformation, ×N". There is
no code path here that can print a millimetre figure for it.
"""
from __future__ import annotations
from typing import Optional
from PyQt5.QtWidgets import QWidget
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
ColorScale,
Diagnostic,
DisplaySettings,
FieldDescriptor,
RenderStats,
SceneAssets,
SceneState,
)
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.prefs import FPS_CHOICES
from orisys_studio.scene3d.widget import Sensor3DWidget
_STAGE = "stage 4 (product integration)"
#: Width of one control in the pane. Chosen against
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen:
#: the pane is a splitter child that the operator can drag narrow.
FIELD_WIDTH = 88
SPIN_WIDTH = 68
#: Display limits offered in the panel. ``max_fps`` is a *display* budget; it never
#: reaches the camera or the recording. Defined once, in ``prefs``, because the
#: saved-settings repair has to accept exactly the values this combo offers.
__all__ = ["FPS_CHOICES", "ThreeDPanel"]
class ThreeDPanel(QWidget):
class ThreeDPanel(QFrame):
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
enabledChanged = pyqtSignal(bool)
settingsChanged = pyqtSignal(object) # DisplaySettings
resetRequested = pyqtSignal()
presetRequested = pyqtSignal(str)
def __init__(
self,
coordinator: ThreeDCoordinator,
@@ -39,16 +75,312 @@ class ThreeDPanel(QWidget):
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._coordinator = coordinator
self._widget = widget
self._assets: Optional[SceneAssets] = None
self._descriptor: Optional[FieldDescriptor] = None
self._state: SceneState = "disabled"
self._syncing = False
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""Show one diagnostic line; the 2D views stay usable behind it."""
raise NotImplementedError(f"{_STAGE}: show a diagnostic")
root = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 8)
root.setSpacing(6)
self.enable_check = QCheckBox()
self.enable_check.toggled.connect(self._on_enabled_toggled)
root.addWidget(self.enable_check)
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
self.preset_label = QLabel()
self.preset_combo = QComboBox()
# The pane is 240 px wide at its narrowest (``MIN_PANEL_WIDTHS["mesh3d"]``),
# so the fields are compact and the panel's own minimum stays below that.
# Without this the layout's minimum is set by two spin boxes side by side,
# the splitter cannot honour the panel width, and the picture is squeezed out.
self.preset_combo.setMaximumWidth(FIELD_WIDTH)
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
form.addRow(self.preset_label, self.preset_combo)
self.housing_check = QCheckBox()
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
# starting state and the settings it reports must be the same thing.
self.housing_check.setChecked(True)
self.housing_check.toggled.connect(self._emit_settings)
form.addRow(self.housing_check)
self.deform_check = QCheckBox()
self.deform_check.toggled.connect(self._on_deform_toggled)
self.gain_spin = QDoubleSpinBox()
self.gain_spin.setRange(0.0, 500.0)
self.gain_spin.setSingleStep(1.0)
self.gain_spin.setDecimals(0)
self.gain_spin.setSuffix(" ×")
self.gain_spin.setValue(0.0)
self.gain_spin.setMaximumWidth(SPIN_WIDTH)
self.gain_spin.valueChanged.connect(self._emit_settings)
gain_row = QHBoxLayout()
gain_row.addWidget(self.deform_check)
gain_row.addWidget(self.gain_spin)
gain_row.addStretch(1)
form.addRow(gain_row)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
self.scale_combo.addItem("automatic", "automatic")
# Automatic by default: the quantities differ by orders of magnitude (pixels,
# a response around 1, metres), so one fixed range would show one of them as
# a single flat colour. The fixed bounds are still there for a comparison
# across frames, which is the reason to prefer them when they are wanted.
self.scale_combo.setCurrentIndex(1)
self.scale_combo.setMaximumWidth(FIELD_WIDTH)
self.scale_combo.currentIndexChanged.connect(self._on_scale_mode_changed)
form.addRow(self.scale_label, self.scale_combo)
# The two bounds get their own row rather than sharing the mode's: three
# controls side by side made this row the widest thing in the pane, and a pane
# whose content demands more than ``MIN_PANEL_WIDTHS["mesh3d"]`` clips its own
# controls when the operator drags the splitter to the declared minimum.
self.range_label = QLabel()
self.lower_spin = QDoubleSpinBox()
self.lower_spin.setRange(-1e6, 1e6)
self.lower_spin.setDecimals(3)
self.lower_spin.setMaximumWidth(SPIN_WIDTH)
self.lower_spin.valueChanged.connect(self._emit_settings)
self.upper_spin = QDoubleSpinBox()
self.upper_spin.setRange(-1e6, 1e6)
self.upper_spin.setDecimals(3)
self.upper_spin.setValue(1.0)
self.upper_spin.setMaximumWidth(SPIN_WIDTH)
self.upper_spin.valueChanged.connect(self._emit_settings)
range_row = QHBoxLayout()
range_row.addWidget(self.lower_spin)
range_row.addWidget(self.upper_spin)
range_row.addStretch(1)
form.addRow(self.range_label, range_row)
self.fps_label = QLabel()
self.fps_combo = QComboBox()
for value in FPS_CHOICES:
self.fps_combo.addItem(f"{value} fps", value)
self.fps_combo.setCurrentIndex(FPS_CHOICES.index(30))
self.fps_combo.setMaximumWidth(FIELD_WIDTH)
self.fps_combo.currentIndexChanged.connect(self._emit_settings)
form.addRow(self.fps_label, self.fps_combo)
root.addLayout(form)
buttons = QHBoxLayout()
self.reset_button = QPushButton()
self.reset_button.clicked.connect(self.resetRequested.emit)
buttons.addWidget(self.reset_button)
buttons.addStretch(1)
root.addLayout(buttons)
self.legend_label = QLabel()
self.legend_label.setObjectName("panelMeta")
self.legend_label.setWordWrap(True)
self.legend_label.setMinimumWidth(1)
root.addWidget(self.legend_label)
self.state_label = QLabel()
self.state_label.setObjectName("hintText")
self.state_label.setWordWrap(True)
self.state_label.setMinimumWidth(1)
root.addWidget(self.state_label)
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setWordWrap(True)
self.stats_label.setMinimumWidth(1)
root.addWidget(self.stats_label)
root.addStretch(1)
self.retranslate()
self.set_scene(None)
self.set_state("disabled")
# ------------------------------------------------------------------ inputs
def set_scene(self, assets: Optional[SceneAssets]) -> None:
"""Fill the preset list from the asset: its default view is the asset's."""
self._assets = assets
self._syncing = True
try:
self.preset_combo.clear()
if assets is not None:
for preset in assets.camera_presets:
self.preset_combo.addItem(preset.name, preset.name)
finally:
self._syncing = False
self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
self.reset_button.setEnabled(assets is not None)
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
"""The measurement currently on screen — or none, which hides the bar."""
self._descriptor = descriptor
if descriptor is None or descriptor.quantity == "unavailable":
# No measurement: do not leave the previous range on display.
self.legend_label.setText(t("scene3d.legend_none"))
self.legend_label.setVisible(True)
self.scale_combo.setEnabled(False)
self.lower_spin.setEnabled(False)
self.upper_spin.setEnabled(False)
return
self.scale_combo.setEnabled(True)
fixed = self.scale_combo.currentData() == "fixed"
self.lower_spin.setEnabled(fixed)
self.upper_spin.setEnabled(fixed)
self.legend_label.setText(
t(
"scene3d.legend",
quantity=descriptor.quantity,
unit=descriptor.unit,
quality=t(f"scene3d.quality_{descriptor.quality}"),
)
)
self.legend_label.setVisible(True)
def set_state(self, state: SceneState) -> None:
raise NotImplementedError(f"{_STAGE}: reflect the scene state")
self._state = state
self.state_label.setText(t(f"scene3d.state_{state}"))
self.state_label.setVisible(state != "live")
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""One diagnostic line. The 2D views stay usable behind it."""
if diagnostic is None:
self._legend_reset_note()
return
prefix = t(f"scene3d.severity_{diagnostic.severity}")
self.legend_label.setText(f"{prefix}: {diagnostic.message}")
self.legend_label.setVisible(True)
def set_render_stats(self, stats: RenderStats) -> None:
"""Display-side statistics only: never shown as recording health."""
raise NotImplementedError(f"{_STAGE}: show render statistics")
self.stats_label.setText(
t(
"scene3d.stats",
displayed=stats.displayed_frames,
dropped=stats.superseded_frames,
compose=f"{stats.compose_ms:.1f}",
paint=f"{stats.paint_ms:.1f}",
)
)
def set_enabled(self, enabled: bool) -> None:
self._syncing = True
try:
self.enable_check.setChecked(bool(enabled))
finally:
self._syncing = False
for widget in (
self.preset_combo,
self.housing_check,
self.deform_check,
self.gain_spin,
self.scale_combo,
self.fps_combo,
self.reset_button,
):
widget.setEnabled(bool(enabled))
self.set_descriptor(self._descriptor if enabled else None)
# ----------------------------------------------------------------- outputs
def apply_settings(self, settings: DisplaySettings) -> None:
"""Move the controls to these settings and tell the view about them.
Used when a session's settings are restored from the preferences file. The
values have already been repaired (``scene3d.prefs``); anything that did not
fit a control is repaired again here by Qt's own ranges, which is the only
thing that keeps a hand-edited preference from being applied literally.
"""
self._syncing = True
try:
self.deform_check.setChecked(bool(settings.deform_enabled))
self.gain_spin.setValue(float(settings.display_gain))
self.housing_check.setChecked(bool(settings.housing_visible))
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
if index >= 0:
self.scale_combo.setCurrentIndex(index)
self.lower_spin.setValue(float(scale.lower))
self.upper_spin.setValue(float(scale.upper))
index = self.fps_combo.findData(int(settings.max_fps))
if index >= 0:
self.fps_combo.setCurrentIndex(index)
finally:
self._syncing = False
self.gain_spin.setEnabled(self.deform_check.isChecked())
self._emit_settings()
def settings(self) -> DisplaySettings:
descriptor = self._descriptor or FieldDescriptor(
"unavailable", "1", "display_only", False
)
lower = float(self.lower_spin.value())
upper = float(self.upper_spin.value())
if upper <= lower:
upper = lower + 1.0
return DisplaySettings(
display_gain=float(self.gain_spin.value()),
deform_enabled=self.deform_check.isChecked(),
color_scale=ColorScale(
descriptor=descriptor,
lower=lower,
upper=upper,
lut_id="diverging" if descriptor.signed else "sequential",
mode=str(self.scale_combo.currentData()),
),
housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()),
)
# ------------------------------------------------------------------ signals
def _on_enabled_toggled(self, enabled: bool) -> None:
if self._syncing:
return
self.set_enabled(enabled)
self.enabledChanged.emit(bool(enabled))
def _on_preset_chosen(self, index: int) -> None:
if self._syncing or index < 0:
return
name = self.preset_combo.itemData(index)
if name:
self.presetRequested.emit(str(name))
def _on_deform_toggled(self, enabled: bool) -> None:
self.gain_spin.setEnabled(bool(enabled) and self.enable_check.isChecked())
self._emit_settings()
def _on_scale_mode_changed(self, index: int) -> None:
"""Only a fixed scale has bounds to type in; automatic ones come from data."""
self.set_descriptor(self._descriptor)
self._emit_settings()
def _emit_settings(self) -> None:
if self._syncing:
return
self.settingsChanged.emit(self.settings())
def _legend_reset_note(self) -> None:
self.set_descriptor(self._descriptor)
# --------------------------------------------------------------- i18n / UI
def retranslate(self) -> None:
self.enable_check.setText(t("scene3d.enable"))
self.preset_label.setText(t("scene3d.preset"))
self.housing_check.setText(t("scene3d.housing"))
self.deform_check.setText(t("scene3d.deform"))
self.deform_check.setToolTip(t("scene3d.deform_tip"))
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
self.scale_combo.setItemText(0, t("scene3d.scale_fixed"))
self.scale_combo.setItemText(1, t("scene3d.scale_auto"))
self.fps_label.setText(t("scene3d.max_fps"))
self.fps_label.setToolTip(t("scene3d.max_fps_tip"))
self.reset_button.setText(t("scene3d.reset_view"))
self.state_label.setText(t(f"scene3d.state_{self._state}"))
self.set_descriptor(self._descriptor)
@@ -0,0 +1,137 @@
"""Display settings as a preference value: read, write, and repair.
Stage 4 in ``docs/architecture/studio-3d-stages.md``.
The 3D pane's display settings are the operator's, so they are persisted between
sessions — and a preferences file is a file a person can edit, hand-edit, or carry
over from an older version. So this module's real job is not serialisation, it is
**repair**: a settings block that names a colour range of the wrong type, a frame
rate the build does not offer, or a gain that is not a number has to become a
usable setting rather than a start-up exception. Nothing in the 3D view is worth a
window that will not open.
One rule is not negotiable here: ``deform_enabled`` and ``display_gain`` describe a
*picture*, so restoring them cannot change a measurement. The saved block carries
the descriptor it was captured with, but the restored settings do **not** inherit
it — the colour scale's descriptor comes from the live frame every time, so a stale
file cannot relabel a measurement as another quantity.
"""
from __future__ import annotations
from typing import Any, Mapping, Optional
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplaySettings,
FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
)
#: Frame rates the pane offers. A saved value outside this set is repaired to the
#: default rather than passed to the coordinator as a budget it never promised.
FPS_CHOICES = (15, 30, 60)
DEFAULT_MAX_FPS = 30
MIN_GAIN = 0.0
MAX_GAIN = 500.0
SCALE_MODES = ("fixed", "automatic")
#: The largest magnitude a colour bound may carry. Beyond this the LUT is useless
#: and the number is certainly a typo ("1e308" in a hand-edited file).
MAX_SCALE_BOUND = 1e12
def _number(value: Any, fallback: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if number != number or number in (float("inf"), float("-inf")): # NaN / inf
return fallback
return number
def _clamp_bound(value: float) -> float:
"""A colour bound inside the interval a spin box and a LUT can actually use."""
return min(max(float(value), -MAX_SCALE_BOUND), MAX_SCALE_BOUND)
def _flag(value: Any, fallback: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return fallback
def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"""A JSON-safe block. The descriptor is recorded, never trusted back."""
scale = settings.color_scale
block: dict[str, Any] = {
"deform_enabled": bool(settings.deform_enabled),
"display_gain": float(settings.display_gain),
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
}
if scale is not None:
block["color_scale"] = {
"mode": str(scale.mode),
"lower": float(scale.lower),
"upper": float(scale.upper),
# Recorded for support: if a saved file is in front of us, this says
# what the picture was showing when it was written.
"quantity": str(scale.descriptor.quantity),
"unit": str(scale.descriptor.unit),
"quality": str(scale.descriptor.quality),
}
return block
def settings_from_prefs(
raw: Optional[Mapping[str, Any]],
*,
descriptor: Optional[FieldDescriptor] = None,
) -> DisplaySettings:
"""Rebuild the display settings, repairing anything unusable.
``descriptor`` is the field the colour scale is for; when it is not given, the
restored block carries no colour scale at all (the panel then labels the bar from
the live frame). It is never read back out of the saved file.
"""
if not isinstance(raw, Mapping):
return DisplaySettings(max_fps=DEFAULT_MAX_FPS)
gain = min(max(_number(raw.get("display_gain"), 0.0), MIN_GAIN), MAX_GAIN)
max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS))
if max_fps not in FPS_CHOICES:
max_fps = DEFAULT_MAX_FPS
scale: Optional[ColorScale] = None
saved = raw.get("color_scale")
if isinstance(saved, Mapping):
mode = str(saved.get("mode") or "fixed")
if mode not in SCALE_MODES:
mode = "fixed"
lower = _clamp_bound(_number(saved.get("lower"), 0.0))
upper = _clamp_bound(_number(saved.get("upper"), 1.0))
if upper <= lower:
# Zero-width or inverted: widened, and the widened pair is clamped again so
# the repair cannot itself leave the allowed interval (a bound sitting
# exactly at the limit would otherwise come back as limit + 1).
upper = _clamp_bound(lower + 1.0)
if upper <= lower:
lower = _clamp_bound(lower - 1.0)
upper = _clamp_bound(lower + 1.0)
scale = ColorScale(
descriptor=descriptor or UNAVAILABLE_DESCRIPTOR,
lower=lower,
upper=upper,
lut_id=str(saved.get("lut_id") or "sequential"),
mode=mode, # type: ignore[arg-type]
)
return DisplaySettings(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps,
)
@@ -0,0 +1,412 @@
"""``Scene3DView``: everything the application has to assemble for a 3D pane.
Stage 4 in ``docs/architecture/studio-3d-stages.md``. The application's job should
be "put this widget in the window and hand it packets", so the assembly — widget,
camera, renderer, composer, coordinator, timer, adapters — lives here instead of in
``app_window``.
Three properties this class is responsible for, all of them about not breaking the
application:
* **it always constructs.** With no PyOpenGL, no GPU, or no asset it reports itself
unavailable and emits a diagnostic; the two-dimensional views are untouched. A 3D
feature that can stop acquisition from starting is worse than no 3D feature.
* **the producer side is O(1).** ``submit_packet`` runs on the acquisition thread and
only adapts one packet and offers it to a capacity-one mailbox: no IO, no GL, no
Qt, and no way to make the recording wait.
* **it is display-only.** Nothing here writes to the session store, the CSV or the
camera; the same packets reach the record channel through their own sinks.
The adapter for each sensor is chosen by the asset, not by a global: a scene whose
sensing geometry is a finger's is fed the finger's flow, and a scene whose device
description disagrees with the asset is refused with both shapes named.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QVBoxLayout, QWidget
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
DisplaySettings,
ExpectedGeometry,
FieldDescriptor,
FrameIdentity,
RenderStats,
SceneAssets,
SceneState,
diagnostic,
validate_assets,
)
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.panel import ThreeDPanel
from orisys_studio.scene3d.widget import Sensor3DWidget
logger = logging.getLogger(__name__)
#: Display settings when the panel is not built (no GL). Kept here so ``settings()``
#: has one answer rather than two.
DEFAULT_SETTINGS = DisplaySettings()
#: Smallest useful picture height, in logical pixels. Below this the controls win the
#: layout and the GL widget is allocated a height of zero.
MIN_VIEW_HEIGHT = 120
def gl_available() -> tuple[bool, str]:
"""Whether a Qt OpenGL widget and a GL binding can be built at all.
Imported lazily and guarded: this is what lets the application construct the 3D
pane on a machine where PyOpenGL is not installed.
"""
try:
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
except Exception as exc: # noqa: BLE001
return False, f"PyQt5 OpenGL widgets are unavailable: {exc}"
try:
import OpenGL # noqa: F401
except Exception as exc: # noqa: BLE001
return False, f"PyOpenGL is not installed: {exc}"
return True, ""
class Scene3DView(QWidget):
"""One 3D pane: the GL widget on top, its controls underneath."""
stateChanged = pyqtSignal(object) # SceneState
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
enabledChanged = pyqtSignal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._available, self._reason = gl_available()
self._widget: Optional[Sensor3DWidget] = None
self._panel: Optional[ThreeDPanel] = None
self._coordinator: Optional[ThreeDCoordinator] = None
self._camera = OrbitCamera()
self._executor: Optional[ThreadPoolExecutor] = None
self._assets: Optional[SceneAssets] = None
self._expected: Optional[ExpectedGeometry] = None
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
self._source_id = ""
self._enabled = False
self._last_diagnostic: Optional[Diagnostic] = None
self._last_descriptor: Optional[FieldDescriptor] = None
self._last_stats: Optional[RenderStats] = None
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
if self._available:
try:
self._build()
except Exception as exc: # noqa: BLE001 - never let 3D stop the app
logger.exception("Could not build the 3D view")
self._fail(f"the 3D view could not be built: {type(exc).__name__}: {exc}")
# ------------------------------------------------------------------- build
def _build(self) -> None:
from orisys_studio.scene3d.gl_backend import GLRenderer
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
self._coordinator = ThreeDCoordinator(
self._widget, SceneComposer(), self._executor, max_fps=30
)
self._panel = ThreeDPanel(self._coordinator, self._widget)
# The picture must not be squeezed out of existence by the controls above it:
# a GL widget with zero height draws nothing and looks like a broken view.
self._widget.setMinimumHeight(MIN_VIEW_HEIGHT)
self._layout.addWidget(self._widget, 1)
self._layout.addWidget(self._panel, 0)
self._widget.capabilitiesChanged.connect(self._on_capabilities)
self._widget.diagnosticRaised.connect(self._on_diagnostic)
self._widget.renderStatsChanged.connect(self._on_stats)
self._widget.descriptorChanged.connect(self._on_descriptor)
self._panel.enabledChanged.connect(self.set_enabled)
self._panel.settingsChanged.connect(self.set_settings)
self._panel.resetRequested.connect(self.reset_view)
self._panel.presetRequested.connect(self.apply_preset)
def _fail(self, message: str) -> None:
self._available = False
self._reason = message
self._on_diagnostic(diagnostic("gl_unsupported", message))
# ------------------------------------------------------------------ assets
def use_scene(
self,
assets: SceneAssets,
source_id: str,
expected: Optional[ExpectedGeometry] = None,
) -> bool:
"""Adopt a validated scene. Returns whether the view accepted it.
``expected`` is the device's own description. It is optional for the finger
(whose adapter needs nothing but the packet) and required for a box, where it
is the only statement of what the device's field means — an adapter built
without it would be guessing which grid to trust.
"""
if not self._available or self._coordinator is None:
return False
try:
validate_assets(assets, None)
except Exception as exc: # noqa: BLE001
self._on_diagnostic(
getattr(exc, "diagnostic", None)
or diagnostic("asset_rejected", f"{type(exc).__name__}: {exc}")
)
return False
snapshot = self._make_snapshot(assets, expected)
if snapshot is None:
return False
self._assets = assets
self._expected = expected
self._snapshot = snapshot
self._source_id = str(source_id)
self._last_descriptor = None
self._coordinator.activate(assets, self._source_id)
if self._panel is not None:
self._panel.set_scene(assets)
return True
def _make_snapshot(
self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
) -> Optional[Callable[[Any, FrameIdentity], Any]]:
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
The two adapters have different call signatures because they need different
things (contracts' own protocols): the finger adapter takes the frame's cost
from the packet, while the box adapter needs the device description to know
whether the field's grid is the one the scene was built for. Binding the call
here keeps the acquisition path free of both variants.
"""
asset_id = str(getattr(assets, "asset_id", "") or "")
common = {
"geometry_id": assets.geometry_id,
"field_domain_id": assets.field_domain_id,
"shape_hw": tuple(assets.shape_hw),
}
if asset_id.startswith(("box", "mini")):
if expected is None:
self._on_diagnostic(
diagnostic(
"asset_rejected",
"a box scene needs the device description to place its field",
)
)
return None
adapter = BoxFrameAdapter(**common)
return lambda packet, identity: adapter.snapshot(packet, identity, expected)
# The finger's flow arrives already unrolled on the scene's UV domain
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
# passed here. Remapping it again would index the flow at source pixels.
adapter = FingerFrameAdapter(**common)
return lambda packet, identity: adapter.snapshot(packet, None, identity)
# --------------------------------------------------------------- producer
def submit_packet(self, packet: Any) -> None:
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
coordinator = self._coordinator
snapshot = self._snapshot
if coordinator is None or snapshot is None or not self._enabled:
return
try:
identity = self.identity_for(packet)
if identity is None:
return
frame = snapshot(packet, identity)
if frame is not None:
coordinator.submit(frame)
except Exception: # noqa: BLE001 - a display sink must never break capture
logger.exception("the 3D sink failed for one packet")
def identity_for(self, packet: Any) -> Optional[FrameIdentity]:
"""The identity a field frame built from this packet would carry."""
if self._coordinator is None or self._assets is None:
return None
return FrameIdentity(
source_id=self._source_id,
session_id=int(getattr(packet, "session_id", 0) or 0),
frame_id=int(getattr(packet, "frame_id", 0) or 0),
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
generation=self._coordinator.generation,
)
# ------------------------------------------------------------------- view
def set_enabled(self, enabled: bool) -> None:
"""Turn the pane's work on or off. Idempotent, and never touches the record."""
enabled = bool(enabled) and self._available
self._enabled = enabled
if self._coordinator is not None:
self._coordinator.set_visible(enabled)
if self._panel is not None:
self._panel.set_enabled(enabled)
self.enabledChanged.emit(enabled)
def settings(self) -> DisplaySettings:
if self._panel is not None:
return self._panel.settings()
return DEFAULT_SETTINGS
def restore_settings(self, settings: DisplaySettings) -> None:
"""Adopt persisted display settings.
Two entry points because the panel and the coordinator hold the same values:
the panel's controls have to *show* the restored setting, and the coordinator
has to *use* it. Going through the panel's own signal keeps one path.
"""
self.set_settings(settings)
if self._panel is not None:
self._panel.apply_settings(settings)
def set_settings(self, settings: DisplaySettings) -> None:
if self._coordinator is not None:
self._coordinator.set_settings(settings)
def apply_preset(self, name: str) -> None:
if self._assets is None:
return
for preset in self._assets.camera_presets:
if preset.name == str(name):
try:
self._camera.set_preset(preset)
except ValueError as exc:
logger.warning("ignoring preset %s: %s", name, exc)
return
if self._widget is not None:
self._widget.update()
return
def reset_view(self) -> None:
if self._assets is not None and self._widget is not None:
self._camera.reset(self._assets.bounds_sensor_m)
self._widget.update()
# ------------------------------------------------------------------ state
@property
def available(self) -> bool:
return self._available
@property
def reason(self) -> str:
return self._reason
@property
def enabled(self) -> bool:
return self._enabled
@property
def assets(self) -> Optional[SceneAssets]:
return self._assets
@property
def coordinator(self) -> Optional[ThreeDCoordinator]:
return self._coordinator
@property
def gl_widget(self) -> Optional[Sensor3DWidget]:
return self._widget
@property
def panel(self) -> Optional[ThreeDPanel]:
return self._panel
@property
def last_diagnostic(self) -> Optional[Diagnostic]:
return self._last_diagnostic
@property
def last_descriptor(self) -> Optional[FieldDescriptor]:
return self._last_descriptor
def state(self) -> SceneState:
if not self._available:
return "unavailable"
if self._coordinator is None:
return "disabled"
return self._coordinator.state
def render_stats(self) -> Optional[RenderStats]:
return self._last_stats
def close(self) -> None:
"""Release the GPU resources, stop the worker. Idempotent."""
if self._coordinator is not None:
self._coordinator.close()
if self._widget is not None:
try:
self._widget.release_resources()
except Exception: # noqa: BLE001 - teardown must not raise on the way out
logger.exception("releasing the 3D GPU resources failed")
if self._executor is not None:
self._executor.shutdown(wait=False)
self._executor = None
self._enabled = False
# ------------------------------------------------------------------- ticks
def tick(self) -> None:
"""Advance the display one step. Called by the application's run timer.
Deliberately **not** a timer of its own: the window already has a 16 ms
timer while a session runs, and a component that started threads or timers
by itself would be a component whose cost the window cannot see. Nothing
here reaches the acquisition thread — it starts at most one compose on the
view's own worker and takes at most one finished frame.
"""
if self._coordinator is None or not self._enabled:
return
self._coordinator.tick()
state = self._coordinator.state
if self._panel is not None:
self._panel.set_state(state)
self.stateChanged.emit(state)
def _on_capabilities(self, capabilities: Any) -> None:
if self._panel is not None and getattr(capabilities, "software_renderer", False):
self._panel.set_diagnostic(
diagnostic(
"gl_unsupported",
"the OpenGL context is a software rasteriser: expect a slow view",
)
)
def _on_descriptor(self, descriptor: Any) -> None:
"""The frame on screen changed what it claims to be.
Skipped when it is the same descriptor as last time: this runs once per
displayed frame, and the label and the settings only change when the claim
does. When it does change, the display settings are re-issued so the colour
scale's descriptor — hence the choice between a sequential and a diverging
ramp — follows the live field rather than the one that was on screen when the
operator last touched a control.
"""
if descriptor == self._last_descriptor:
return
self._last_descriptor = descriptor
if self._panel is not None:
self._panel.set_descriptor(descriptor)
self.set_settings(self._panel.settings())
self.descriptorChanged.emit(descriptor)
def _on_diagnostic(self, item: Diagnostic) -> None:
self._last_diagnostic = item
if self._panel is not None:
self._panel.set_diagnostic(item)
self.diagnosticRaised.emit(item)
def _on_stats(self, stats: RenderStats) -> None:
self._last_stats = stats
if self._panel is not None:
self._panel.set_render_stats(stats)
self.renderStatsChanged.emit(stats)
@@ -51,6 +51,10 @@ class Sensor3DWidget(QOpenGLWidget):
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
#: What the frame on screen claims to be — quantity, unit, quality. The panel
#: labels the colour bar from this and nothing else, so an unmeasured surface
#: cannot inherit the previous frame's range.
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
def __init__(
self,
@@ -93,6 +97,7 @@ class Sensor3DWidget(QOpenGLWidget):
"""Keep the newest CPU frame and ask for a repaint. Never requires a context."""
self._frame = frame
self._needs_surface_upload = True
self.descriptorChanged.emit(frame.scalar_descriptor)
self.update()
def set_settings(self, settings: DisplaySettings) -> None:
@@ -224,6 +229,27 @@ class Sensor3DWidget(QOpenGLWidget):
)
)
def paintEvent(self, event: QEvent) -> None: # noqa: N802 - Qt name
"""Qt's own paint, plus the one check Qt never reports as an error.
When the platform cannot create a GL context (the offscreen plugin, a broken
driver, a board without EGL) ``initializeGL`` is simply never called: there is
no exception to catch, and the widget would sit there looking like a working
view that has no data yet. Qt does answer the question, though — ``context()``
stays ``None`` — so the first paint asks it and turns "no context" into the
state the rest of the application already knows how to show.
"""
super().paintEvent(event)
if self._unavailable or self._capabilities is not None:
return
if self.context() is None:
self._fail(
diagnostic(
"gl_unsupported",
"Qt could not create an OpenGL context for the 3D view",
)
)
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover - needs a context
"""Nothing to cache: the projection is rebuilt from the live viewport."""
self._camera.aspect = max(float(w), 1.0) / max(float(h), 1.0)