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:
@@ -39,6 +39,7 @@ from orisys_app_shared.record import RecordConfig
|
||||
from orisys_app_shared.record.sample import to_record_sample
|
||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||
from orisys_studio.replay import ReplayController
|
||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||
@@ -69,7 +70,11 @@ RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
||||
DEFAULT_SPLITTER_SIZES = (340, 480, 400)
|
||||
MIN_PANEL_WIDTHS = {
|
||||
"mesh3d": 240,
|
||||
# The 3D pane is the only one whose content is a real control panel rather than a
|
||||
# label or a picture, and its minimum is measured under the application
|
||||
# stylesheet in both languages: the widest case is English at 241 px
|
||||
# (``tests/test_scene3d_view.py`` pins it), so 240 would have been 1 px short.
|
||||
"mesh3d": 260,
|
||||
"arrow": 300,
|
||||
"plots": 340,
|
||||
"cal": 300,
|
||||
@@ -252,6 +257,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.content_splitter.addWidget(self.cal_panel)
|
||||
self.content_splitter.setChildrenCollapsible(False)
|
||||
root.addWidget(self.content_splitter, 1)
|
||||
# The pane's own switch is the same state as 视图 ▸ 三维网格. The window owns
|
||||
# the state; the checkbox is a second view of it (see `_on_3d_pane_toggled`).
|
||||
self.mesh3d_panel.enabledChanged.connect(self._on_3d_pane_toggled)
|
||||
|
||||
# Playback drives the *chart cursor* (the playhead) instead of a slider
|
||||
# under the panels: the two views share one layout, and the transport is
|
||||
@@ -277,6 +285,11 @@ class StudioMainWindow(QMainWindow):
|
||||
self._wire_sensor_picker()
|
||||
self._wire_replay()
|
||||
self._restore_prefs_into_ui()
|
||||
# The 3D pane's sensor and its persisted display settings, before anything
|
||||
# asks it to draw: the pane resolves its asset from the sensor, and restoring
|
||||
# the settings later would make the first frame use the defaults.
|
||||
self._sync_3d_source()
|
||||
self._restore_3d_settings()
|
||||
# Visibility and the nav highlight come from the restored view; the
|
||||
# stored splitter sizes still win at startup (``apply_sizes=False``).
|
||||
self._apply_view_preset(self._view_preset, apply_sizes=False, persist=False)
|
||||
@@ -325,6 +338,11 @@ class StudioMainWindow(QMainWindow):
|
||||
# Both workers publish into the same record channel.
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
worker.add_sink(self._on_packet_recorded)
|
||||
# The 3D pane is a second sink on the same thread. It is registered even
|
||||
# while the pane is off, because a sink that is added and removed as the
|
||||
# pane is toggled is a sink whose cost depends on the click order; the
|
||||
# view's own enabled flag is what makes it do nothing.
|
||||
worker.add_sink(self._on_packet_for_3d)
|
||||
|
||||
# ------------------------------------------------------- experiment record
|
||||
@property
|
||||
@@ -1206,6 +1224,11 @@ class StudioMainWindow(QMainWindow):
|
||||
show_arrow = preset != "calibration"
|
||||
show_cal = self._show_cal if preset == "custom" else preset == "calibration"
|
||||
self._show_cal = show_cal
|
||||
if show_3d:
|
||||
# Built on first use only: until the pane is actually shown, nothing
|
||||
# imports GL and no scene is read from disk.
|
||||
self.mesh3d_panel.ensure_view()
|
||||
self.mesh3d_panel.set_enabled(show_3d)
|
||||
self.mesh3d_panel.setVisible(show_3d)
|
||||
self.arrow_panel.setVisible(show_arrow)
|
||||
self.force_plots.setVisible(True)
|
||||
@@ -1915,6 +1938,49 @@ class StudioMainWindow(QMainWindow):
|
||||
self._apply_panel_visibility()
|
||||
self._persist_prefs()
|
||||
|
||||
# ------------------------------------------------------------------- 3D pane
|
||||
def _on_3d_pane_toggled(self, enabled: bool) -> None:
|
||||
"""The pane's own switch. Same state as the View menu entry, one owner.
|
||||
|
||||
The early return is what keeps the two controls from driving each other: the
|
||||
window synchronises the pane after every visibility change, and this handler
|
||||
is the way back.
|
||||
"""
|
||||
if bool(enabled) == bool(self._show_finger_3d):
|
||||
return
|
||||
self._show_finger_3d = bool(enabled)
|
||||
self.prefs["show_finger_3d"] = self._show_finger_3d
|
||||
self._apply_panel_visibility()
|
||||
self._persist_prefs()
|
||||
|
||||
def _sync_3d_source(self) -> None:
|
||||
"""Tell the 3D pane which sensor it is looking at. No IO, no scene read."""
|
||||
self.mesh3d_panel.set_sensor(
|
||||
self.sensor_picker.sensor_type(),
|
||||
package_override=self.prefs.get("scene3d_package"),
|
||||
)
|
||||
|
||||
def _restore_3d_settings(self) -> None:
|
||||
"""Persisted display settings, repaired by ``scene3d.prefs`` on the way in."""
|
||||
from orisys_studio.scene3d.prefs import settings_from_prefs
|
||||
|
||||
self.mesh3d_panel.set_display_settings(
|
||||
settings_from_prefs(self.prefs.get("scene3d_display"))
|
||||
)
|
||||
|
||||
def _on_packet_for_3d(self, packet) -> None:
|
||||
"""Runs on the **acquisition thread**.
|
||||
|
||||
The pane copies what it needs into a read-only frame and offers it to a
|
||||
capacity-one mailbox; it never touches the recording, the session store or the
|
||||
camera. The view is the one that decides it is switched off, so this stays a
|
||||
reference read and a method call.
|
||||
"""
|
||||
view = self.mesh3d_panel.view
|
||||
if view is None:
|
||||
return
|
||||
view.submit_packet(packet)
|
||||
|
||||
def _on_show_fps_toggled(self, visible: bool) -> None:
|
||||
self._show_fps = bool(visible)
|
||||
self.prefs["show_fps"] = self._show_fps
|
||||
@@ -1929,6 +1995,9 @@ class StudioMainWindow(QMainWindow):
|
||||
sensor_type=str(sensor_type),
|
||||
config_ref=self._resolve_config_ref(),
|
||||
)
|
||||
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
|
||||
# missing package must not be reported while looking at a box.
|
||||
self._sync_3d_source()
|
||||
self._apply_panel_visibility()
|
||||
self._apply_arrow_rotation_for_sensor()
|
||||
self._update_run_enabled()
|
||||
@@ -2210,6 +2279,7 @@ class StudioMainWindow(QMainWindow):
|
||||
# it is wired even if the worker was installed by something other than
|
||||
# ``_ensure_workers`` (``add_sink`` is idempotent).
|
||||
self._active_worker.add_sink(self._on_packet_recorded)
|
||||
self._active_worker.add_sink(self._on_packet_for_3d)
|
||||
# The filters for this session, before it opens the device: the chain runs
|
||||
# in the SDK, so this is the one moment the choice can be handed over.
|
||||
# The acquisition thread applies it and records what the device reports.
|
||||
@@ -2326,6 +2396,11 @@ class StudioMainWindow(QMainWindow):
|
||||
logger.info("%s", message)
|
||||
|
||||
def _on_run_tick(self) -> None:
|
||||
# The 3D pane advances on this timer rather than one of its own: the window's
|
||||
# refresh rate is then the only one that exists, and hiding or disabling the
|
||||
# pane cannot leave a timer behind. It starts at most one compose on the
|
||||
# pane's own worker and takes at most one finished frame.
|
||||
self.mesh3d_panel.tick()
|
||||
if self._active_worker is None:
|
||||
return
|
||||
# Record bookkeeping happens before anything that can return early: a
|
||||
@@ -2625,6 +2700,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.prefs.setdefault("plot_shear_raw_max", DEFAULT_PLOT_SHEAR_RAW_MAX)
|
||||
self.prefs["show_cal_panel"] = bool(self._show_cal)
|
||||
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
|
||||
self.prefs["scene3d_display"] = settings_to_prefs(
|
||||
self.mesh3d_panel.display_settings()
|
||||
)
|
||||
self.prefs["show_fps"] = bool(self._show_fps)
|
||||
self.prefs["settings_expanded"] = self.toolbar.settings_expanded()
|
||||
self.prefs["view_preset"] = self._view_preset
|
||||
@@ -2640,10 +2718,15 @@ class StudioMainWindow(QMainWindow):
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
if worker is not None and hasattr(worker, "remove_sink"):
|
||||
worker.remove_sink(self._on_packet_recorded)
|
||||
worker.remove_sink(self._on_packet_for_3d)
|
||||
# Blocking: on the way out we must not leave a device handle or a
|
||||
# half-written experiment behind.
|
||||
self._stop_run(reason="user_stop", wait=True)
|
||||
self._stop_recording(wait=True)
|
||||
# The pane releases its GL objects from its own context and stops its worker.
|
||||
# Last, because the window is still referring to it above; idempotent, because
|
||||
# closing twice must not raise (Qt can deliver closeEvent more than once).
|
||||
self.mesh3d_panel.close_view()
|
||||
self._persist_prefs()
|
||||
logger.info("Studio shell closed; preferences saved")
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -21,6 +21,35 @@ from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The GL profiles the 3D view can be started with. One per process: the surface
|
||||
#: format is a start-up decision, so it is read from the preferences here rather
|
||||
#: than switched at runtime (design document §11.1).
|
||||
GL_PROFILES = ("desktop33", "gles30")
|
||||
DEFAULT_GL_PROFILE = "desktop33"
|
||||
|
||||
|
||||
def _configure_gl(prefs: dict) -> str:
|
||||
"""Select the default GL surface format. Must run before ``QApplication``.
|
||||
|
||||
Optional and failure-tolerant on purpose: a machine without PyOpenGL, or with a
|
||||
preference that names a profile this build does not know, still gets the whole
|
||||
two-dimensional application. The 3D pane reports its own state; a start-up that
|
||||
refused to run because a picture might not appear would be the worse bug.
|
||||
"""
|
||||
profile = str(prefs.get("gl_profile") or DEFAULT_GL_PROFILE)
|
||||
if profile not in GL_PROFILES:
|
||||
logger.warning("Unknown gl_profile %r; using %s", profile, DEFAULT_GL_PROFILE)
|
||||
profile = DEFAULT_GL_PROFILE
|
||||
try:
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
|
||||
configure_surface_format(profile) # type: ignore[arg-type]
|
||||
except Exception: # noqa: BLE001 - 3D is optional, the window is not
|
||||
logger.exception("Could not configure the OpenGL surface format")
|
||||
return profile
|
||||
logger.info("OpenGL surface format requested: profile=%s", profile)
|
||||
return profile
|
||||
|
||||
|
||||
def main() -> int:
|
||||
init_logging("orisys-studio")
|
||||
@@ -41,6 +70,7 @@ def main() -> int:
|
||||
language = str(prefs.get("language") or "zh_CN")
|
||||
theme = str(prefs.get("theme") or "dark")
|
||||
set_locale(language)
|
||||
gl_profile = _configure_gl(prefs)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Orisys Studio")
|
||||
@@ -49,7 +79,12 @@ def main() -> int:
|
||||
|
||||
window = StudioMainWindow(prefs=prefs)
|
||||
window.show()
|
||||
logger.info("Studio shell started (theme=%s, language=%s)", theme, language)
|
||||
logger.info(
|
||||
"Studio shell started (theme=%s, language=%s, gl_profile=%s)",
|
||||
theme,
|
||||
language,
|
||||
gl_profile,
|
||||
)
|
||||
code = int(app.exec_())
|
||||
|
||||
# Leave the process immediately.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,35 +1,270 @@
|
||||
"""Placeholder panel for finger 3D mesh view."""
|
||||
"""``Mesh3dPanel``: the 3D pane's frame, and what it shows when 3D cannot run.
|
||||
|
||||
Stage 4 in ``docs/architecture/studio-3d-stages.md``. The splitter slot has to exist
|
||||
and has to be safe to show in every configuration, so this class answers one
|
||||
question — *is there a picture, and if not, why not* — and never raises.
|
||||
|
||||
The pane degrades in one direction only. No PyOpenGL, no GL context, no compiled
|
||||
scene package, a rejected package: each one replaces the picture with a line of text
|
||||
in the operator's language, and the two-dimensional views, the charts, the recording
|
||||
and the camera keep running exactly as before. There is no path here that can stop
|
||||
acquisition, and no path that reports a 3D problem as a recording problem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import QFrame, QLabel, QStackedLayout, QVBoxLayout, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import Diagnostic, ExpectedGeometry
|
||||
from orisys_studio.scene3d.asset_source import (
|
||||
SceneAssetLoader,
|
||||
SceneSource,
|
||||
find_package,
|
||||
no_asset_diagnostic,
|
||||
)
|
||||
|
||||
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
|
||||
_NO_SCENE = "no_scene"
|
||||
#: Shown when the sensor could have a scene but no package is installed.
|
||||
_NO_ASSET = "no_asset"
|
||||
#: Shown when GL cannot run here.
|
||||
_UNAVAILABLE = "unavailable"
|
||||
#: Shown while a package is being read (off the GUI thread).
|
||||
_LOADING = "loading"
|
||||
#: Shown when a package exists but was refused.
|
||||
_REJECTED = "rejected"
|
||||
|
||||
#: Diagnostics that mean "this view cannot draw here", as opposed to "this one frame
|
||||
#: was refused". The first kind replaces the picture; the second must not.
|
||||
_TERMINAL_CODES = frozenset({"gl_unsupported", "shader_compile_failed"})
|
||||
|
||||
#: One locale key per state, so ``retranslate`` can rebuild whatever is on screen.
|
||||
_MESSAGE_KEYS = {
|
||||
_NO_SCENE: "panels.mesh3d_no_scene",
|
||||
_NO_ASSET: "panels.mesh3d_no_asset",
|
||||
_UNAVAILABLE: "panels.mesh3d_unavailable_gl_reason",
|
||||
_LOADING: "panels.mesh3d_loading",
|
||||
_REJECTED: "panels.mesh3d_rejected",
|
||||
}
|
||||
|
||||
|
||||
class Mesh3dPanel(QFrame):
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Hosts the 3D view, or says why there is none."""
|
||||
|
||||
#: Emitted when the operator turns the pane on. The window wires the packet sink
|
||||
#: from this, so no packet is adapted while the pane is off.
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
layout = QVBoxLayout(self)
|
||||
self._view: Optional[QWidget] = None
|
||||
self._loader: Optional[SceneAssetLoader] = None
|
||||
self._source: Optional[SceneSource] = None
|
||||
self._sensor_type = ""
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._enabled = False
|
||||
self._loaded = False
|
||||
self._reported: Optional[Diagnostic] = None
|
||||
self._pending_settings = None
|
||||
self._message_kind = _UNAVAILABLE
|
||||
self._message_args: dict[str, str] = {"reason": ""}
|
||||
|
||||
header = QVBoxLayout()
|
||||
header.setSpacing(2)
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
header.addWidget(self.title)
|
||||
layout.addLayout(header)
|
||||
root.addWidget(self.title)
|
||||
|
||||
self.body = QLabel()
|
||||
self.body.setObjectName("hintText")
|
||||
self.body.setAlignment(Qt.AlignCenter)
|
||||
self.body.setWordWrap(True)
|
||||
layout.addWidget(self.body, 1)
|
||||
self._stack = QStackedLayout()
|
||||
root.addLayout(self._stack, 1)
|
||||
|
||||
self.message = QLabel()
|
||||
self.message.setObjectName("hintText")
|
||||
self.message.setAlignment(Qt.AlignCenter)
|
||||
self.message.setWordWrap(True)
|
||||
self._stack.addWidget(self.message)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ scene
|
||||
def set_sensor(
|
||||
self,
|
||||
sensor_type: str,
|
||||
*,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
package_override: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Tell the pane which sensor it is looking at. Cheap; no IO.
|
||||
|
||||
Called when the sensor changes, so a box sensor reports "no 3D scene for this
|
||||
sensor" rather than the finger's missing-package message.
|
||||
"""
|
||||
sensor_type = str(sensor_type)
|
||||
if sensor_type == self._sensor_type and self._source is not None:
|
||||
# Same sensor, already resolved: re-resolving would drop a loaded scene
|
||||
# every time the window re-applied its panel visibility.
|
||||
self._expected = expected if expected is not None else self._expected
|
||||
return
|
||||
self._sensor_type = sensor_type
|
||||
self._expected = expected
|
||||
self._loaded = False
|
||||
if self._sensor_type != "finger":
|
||||
self._show_message(_NO_SCENE)
|
||||
return
|
||||
self._source = find_package(self._sensor_type, override=package_override)
|
||||
if self._source is None:
|
||||
self._reported = no_asset_diagnostic(self._sensor_type, override=package_override)
|
||||
self._show_message(
|
||||
_NO_ASSET, directory=str(self._reported.details["directory"])
|
||||
)
|
||||
return
|
||||
self._show_message(_LOADING, name=self._source.path.name)
|
||||
|
||||
def ensure_view(self) -> Optional[QWidget]:
|
||||
"""Build the 3D view on first use, so a headless machine never imports GL."""
|
||||
if self._view is not None or self._sensor_type != "finger":
|
||||
return self._view
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
view = Scene3DView(self)
|
||||
self._view = view
|
||||
self._stack.addWidget(view)
|
||||
view.diagnosticRaised.connect(self._on_diagnostic)
|
||||
view.enabledChanged.connect(self._on_view_enabled)
|
||||
if self._pending_settings is not None and hasattr(view, "restore_settings"):
|
||||
view.restore_settings(self._pending_settings)
|
||||
if not view.available:
|
||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||
return view
|
||||
self._load_scene()
|
||||
return view
|
||||
|
||||
def _load_scene(self) -> None:
|
||||
if self._loaded or self._source is None or self._view is None:
|
||||
return
|
||||
self._loaded = True
|
||||
view = self._view
|
||||
if self._loader is None:
|
||||
self._loader = SceneAssetLoader(self)
|
||||
self._loader.loaded.connect(self._on_loaded)
|
||||
self._loader.failed.connect(self._on_load_failed)
|
||||
self._loader.load(self._source, self._expected)
|
||||
|
||||
# -------------------------------------------------------------- settings
|
||||
def set_display_settings(self, settings) -> None:
|
||||
"""Persisted display settings, applied now if the view exists.
|
||||
|
||||
They are held until then because the view — and with it the only widget that
|
||||
can show a colour range or an exaggeration — is built on first use.
|
||||
"""
|
||||
self._pending_settings = settings
|
||||
if self._view is not None and hasattr(self._view, "restore_settings"):
|
||||
self._view.restore_settings(settings)
|
||||
|
||||
def display_settings(self):
|
||||
"""The settings currently in force, for the preferences file."""
|
||||
from orisys_studio.scene3d.prefs import settings_from_prefs
|
||||
|
||||
if self._view is not None and hasattr(self._view, "settings"):
|
||||
return self._view.settings()
|
||||
if self._pending_settings is not None:
|
||||
return self._pending_settings
|
||||
return settings_from_prefs(None)
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
def _on_loaded(self, assets, description: str) -> None:
|
||||
view = self._view
|
||||
if view is None:
|
||||
return
|
||||
if not view.use_scene(assets, source_id=description, expected=self._expected):
|
||||
return
|
||||
self._stack.setCurrentWidget(view)
|
||||
# Adopting a scene is not the same as showing one: the pane stays off until
|
||||
# the operator (or the window, restoring a preference) turns it on.
|
||||
view.set_enabled(self._enabled)
|
||||
|
||||
def _on_load_failed(self, item: Diagnostic) -> None:
|
||||
self._reported = item
|
||||
self._show_message(_REJECTED, message=item.message)
|
||||
|
||||
def _on_diagnostic(self, item: Diagnostic) -> None:
|
||||
"""A 3D problem. The pane reports it; nothing else changes state.
|
||||
|
||||
Two kinds of diagnostic arrive here and they mean different things. A frame
|
||||
that was refused (``geometry_mismatch``, ``field_domain_mismatch``) is a
|
||||
transient: the view keeps drawing the reference model and must stay on screen.
|
||||
A view that cannot draw at all (``gl_unsupported``, ``shader_compile_failed``)
|
||||
is terminal: showing an empty GL widget instead would read as "no data yet".
|
||||
"""
|
||||
self._reported = item
|
||||
if item.code in _TERMINAL_CODES:
|
||||
self._show_message(_UNAVAILABLE, reason=item.message)
|
||||
|
||||
def _on_view_enabled(self, enabled: bool) -> None:
|
||||
self.enabledChanged.emit(bool(enabled))
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
@property
|
||||
def view(self) -> Optional[QWidget]:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
return self._reported
|
||||
|
||||
@property
|
||||
def showing_message(self) -> bool:
|
||||
return self._stack.currentWidget() is self.message
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._enabled)
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
self._enabled = bool(enabled)
|
||||
if self._view is not None:
|
||||
self._view.set_enabled(self._enabled)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Whether a 3D picture is possible here at all (GL and an asset)."""
|
||||
return self._view is not None and bool(getattr(self._view, "available", False))
|
||||
|
||||
def scene_path(self) -> Optional[Path]:
|
||||
return None if self._source is None else self._source.path
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Advance the pane. Called by the window's run timer, never by itself."""
|
||||
if self._view is not None and hasattr(self._view, "tick"):
|
||||
self._view.tick()
|
||||
|
||||
def close_view(self) -> None:
|
||||
"""Release GL and the loader. Idempotent, and safe to call twice."""
|
||||
if self._view is not None and hasattr(self._view, "close"):
|
||||
self._view.close()
|
||||
if self._loader is not None:
|
||||
self._loader.close()
|
||||
self._loader = None
|
||||
|
||||
# --------------------------------------------------------------- i18n / UI
|
||||
def _show_message(self, kind: str, **args: str) -> None:
|
||||
"""Show one state line. Every state is a sentence, never a blank pane."""
|
||||
self._message_kind = str(kind)
|
||||
self._message_args = {str(key): str(value) for key, value in args.items()}
|
||||
self._render_message()
|
||||
|
||||
def _render_message(self) -> None:
|
||||
text = t(_MESSAGE_KEYS[self._message_kind], **self._message_args)
|
||||
self.message.setText(text)
|
||||
self.message.setToolTip(text)
|
||||
self.message.setProperty("mesh3dState", self._message_kind)
|
||||
self._stack.setCurrentWidget(self.message)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.mesh3d"))
|
||||
self.body.setText(t("panels.mesh3d_empty"))
|
||||
self.body.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||
self.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||
self._render_message()
|
||||
|
||||
Reference in New Issue
Block a user