fix(studio-3d): 审计三维改动 —— 修「第一个会话之后画面永久冻结」,并把面板/视图接口显式化
- What:
1. **会话代际(真 bug)**:`ThreeDCoordinator.begin_session()` 新增——新采集会话开始时要显式宣告
(清掉已锁定的 session_id、generation+1、清空 mailbox、把参考模型放回屏幕);`Scene3DView.begin_session()`、
`Mesh3dPanel.begin_session()` 转发,`app_window._start_run()` 在启动 worker 后调用。
另外 `submit()` 在"会话不符"时不再只计数,也给出 `stale_frame` 诊断(信息级),否则界面只是"不动"。
2. **接口显式化(可维护性)**:`Mesh3dPanel.attach_view()` 成为唯一装配入口(真实视图与测试替身都走它,
测试不再写 `_view`);`_view` 类型改为 `Optional["Scene3DView"]`(TYPE_CHECKING 导入,保持 GL 惰性);
容器里 7 处 `hasattr(...)` 鸭子调用改为直接调用(方法改名会立刻报错,而不是静默不生效);
`Scene3DView.describe()`/`render_totals()` 与 `Mesh3dPanel.describe()`/`render_totals()` 让面板自己回答
"我是什么"——`app_window` 里那段走 5 个属性路径拼 `visualization3d` 的代码缩成一次调用 + 一个 try;
`scene3d/panel.py` 对 coordinator/widget 的导入移到 TYPE_CHECKING;`scene3d/__init__.py` 写上各模块职责图
(原文还写着"应用尚未导入本包")。
- Why: 用户要求全面回顾三维改动并按"实时/回放/接口清晰/性能"四条检查 bug。审计确实抓到一个严重缺陷:
协调器记住第一个会话的 session_id 后,**之后每个会话的每一帧都被判为 stale 而拒绝**(实测 session 1 正常、
session 2 stale=3 且 0 帧上屏),而状态仍显示 live —— 表现为"第一次运行之后三维就再也不动了"。
这是"回放不太行"的直接原因之一(回放里的重算通道就是第二个会话)。顺带把"面板↔窗口"的隐式耦合收紧。
- Verification:
· 回归用例:`test_scene3d_coordinator.py` +2(新会话在宣告前 stale 且带诊断、宣告后接受并回到 live;
`begin_session` 在无场景/已关闭时安全)、`test_scene3d_view.py` +1(每次 _start_run 代际递增、第二个会话的帧被接受)。
· 实测(真 GPU,apps/studio 配置里那个包):`audit_live_path.py` —— 三个连续会话 state=live、stale=0、
每次都有 26260 个非背景像素、descriptor=flow_magnitude;
`audit_replay_path.py` —— 回放时间轴正常(1.97 s 记录,播放 0.6 s 后位置 0.60 s,2D 有图,停止按钮生效,
面板标签"回放:录像不含三维场"),随后**真实重算通道**跑完(59 个样本、2.6 s、正常报"视频已结束"),
三维面板进入 state=live(62 帧上屏、2 帧显示丢弃、stale=0),标签切到"重算通道:由录像重算,非记录值"。
· 性能(真 GPU,shipped 包):submit p50 0.26 / p95 0.38 ms,upload p50 0.18 ms,paint p50 0.90 ms,
latency p50 33.08 ms(= 30 fps 上限);20k 顶点 CPU-only compose p50 5.97 ms(与已提交报告的 6.15 ms 一致)。
· apps/studio 全量通过;ui_check 583 项 0 失败(连跑两次);locale_check 0 缺失。
· 修掉 ui_check 自身一处错误断言:场景已加载但本机无 GL context 时,面板显示"无法创建 context"是**正确**行为,
原断言却要求"不显示任何消息"(偶发失败的原因就是它,与产品无关)。
- Not in this change: 测试里仍直接读 `view._camera`/`widget._renderer`/`coordinator._composer` 等私有量(测试替身已收敛到
`attach_view`,其余留待需要时再抽);`update_surface` 里每帧重算色标范围(`_valid_scalar_range`)可以做条件化,
实测开销很小故未动;SDK 对极小合成视频的解码问题(640×480 mp4v)——现在会 10 秒后明确报错而不是空转。
This commit is contained in:
@@ -469,22 +469,21 @@ class StudioMainWindow(QMainWindow):
|
||||
self._publish_record_report(self.recording.stop(wait=wait))
|
||||
|
||||
def _log_visualization3d_totals(self) -> None:
|
||||
"""One event with the display-side counters. Never raises, never blocks."""
|
||||
"""One event with the display-side counters. Never raises, never blocks.
|
||||
|
||||
Written whenever this session had a 3D view at all — including the case where it
|
||||
displayed nothing, which is a fact about the session ("the pane was up and no
|
||||
valid field ever arrived") rather than noise. With no view, ``session.json``'s
|
||||
block already says the pane was off.
|
||||
"""
|
||||
try:
|
||||
view = self.mesh3d_panel.view
|
||||
if view is None:
|
||||
if self.mesh3d_panel.view is None:
|
||||
return
|
||||
coordinator = view.coordinator
|
||||
stats = view.render_stats()
|
||||
shown = int(getattr(stats, "displayed_frames", 0) or 0)
|
||||
dropped = int(
|
||||
coordinator.superseded_frames if coordinator is not None else 0
|
||||
)
|
||||
totals = self.mesh3d_panel.render_totals()
|
||||
self.recorder.log_event(
|
||||
"visualization3d",
|
||||
f"shown={shown} dropped_display={dropped} "
|
||||
f"stale={int(getattr(coordinator, 'stale_frames', 0) or 0)} "
|
||||
f"invalid={int(getattr(coordinator, 'invalid_frames', 0) or 0)}",
|
||||
"shown={shown} dropped_display={dropped_display} "
|
||||
"stale={stale} invalid={invalid}".format(**totals),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a picture must not fail a recording
|
||||
logger.exception("Could not log the 3D display totals")
|
||||
@@ -624,88 +623,23 @@ class StudioMainWindow(QMainWindow):
|
||||
def _visualization3d_metadata(self) -> dict:
|
||||
"""The pane's state for ``session.json``. Must never block a recording.
|
||||
|
||||
Every read is guarded and every value is a plain JSON type: this method runs
|
||||
on the way into a recording, and a widget that has been torn down or a scene
|
||||
that was refused is not a reason to fail one.
|
||||
The pane describes itself (``Mesh3dPanel.describe``) and this only wraps the
|
||||
answer in the record's block shape: the window does not need to know what a
|
||||
view, a scene or a capability record looks like to record a picture. Guarded
|
||||
because it runs on the way into a recording, and a widget that has been torn
|
||||
down is not a reason to fail one.
|
||||
"""
|
||||
from orisys_app_shared.record import build_visualization3d_block
|
||||
|
||||
enabled = False
|
||||
available = False
|
||||
reason = ""
|
||||
scene: dict = {}
|
||||
gl: dict = {}
|
||||
display: dict = {}
|
||||
stream: dict = {}
|
||||
try:
|
||||
pane = self.mesh3d_panel
|
||||
view = pane.view
|
||||
enabled = bool(pane.enabled())
|
||||
available = bool(pane.is_available())
|
||||
diagnostic = pane.last_diagnostic
|
||||
assets = view.assets if view is not None else None
|
||||
if assets is None:
|
||||
reason = (
|
||||
diagnostic.message
|
||||
if diagnostic is not None
|
||||
else str(t("panels.mesh3d_no_scene"))
|
||||
)
|
||||
else:
|
||||
scene = {
|
||||
"path": str(pane.scene_path() or ""),
|
||||
"asset_id": str(getattr(assets, "asset_id", "")),
|
||||
"asset_version": str(getattr(assets, "asset_version", "")),
|
||||
"asset_hash": str(getattr(assets, "asset_hash", "")),
|
||||
"geometry_id": str(getattr(assets, "geometry_id", "")),
|
||||
"field_domain_id": str(getattr(assets, "field_domain_id", "")),
|
||||
"platform_lock": (
|
||||
assets.platform_lock.to_dict()
|
||||
if getattr(assets, "platform_lock", None) is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
widget = getattr(view, "gl_widget", None)
|
||||
capabilities = widget.capabilities() if widget is not None else None
|
||||
if capabilities is not None:
|
||||
gl = {
|
||||
"profile": str(capabilities.profile),
|
||||
"vendor": str(capabilities.vendor),
|
||||
"renderer": str(capabilities.renderer),
|
||||
"gl_version": str(capabilities.gl_version),
|
||||
"glsl_version": str(capabilities.glsl_version),
|
||||
"software_renderer": bool(capabilities.software_renderer),
|
||||
}
|
||||
settings = view.settings()
|
||||
scale = settings.color_scale
|
||||
display = {
|
||||
"deform_enabled": bool(settings.deform_enabled),
|
||||
"display_gain": float(settings.display_gain),
|
||||
"housing_visible": bool(settings.housing_visible),
|
||||
"max_fps": int(settings.max_fps),
|
||||
"scale_mode": str(scale.mode) if scale is not None else "none",
|
||||
"scale_lower": float(scale.lower) if scale is not None else None,
|
||||
"scale_upper": float(scale.upper) if scale is not None else None,
|
||||
}
|
||||
descriptor = view.last_descriptor
|
||||
if descriptor is not None:
|
||||
stream = {
|
||||
"quantity": str(descriptor.quantity),
|
||||
"unit": str(descriptor.unit),
|
||||
"quality": str(descriptor.quality),
|
||||
"signed": bool(descriptor.signed),
|
||||
}
|
||||
return build_visualization3d_block(**self.mesh3d_panel.describe())
|
||||
except Exception: # noqa: BLE001 - a recording must not fail over a picture
|
||||
logger.exception("Could not describe the 3D pane for the recording")
|
||||
reason = reason or "the 3D pane could not be described"
|
||||
return build_visualization3d_block(
|
||||
enabled=enabled,
|
||||
available=available,
|
||||
reason=reason,
|
||||
scene=scene or None,
|
||||
gl=gl or None,
|
||||
display=display or None,
|
||||
stream=stream or None,
|
||||
)
|
||||
return build_visualization3d_block(
|
||||
enabled=False,
|
||||
available=False,
|
||||
reason="the 3D pane could not be described",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _config_hash(config_ref: str) -> str:
|
||||
@@ -2512,6 +2446,11 @@ class StudioMainWindow(QMainWindow):
|
||||
# ``_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 3D pane has to know that a *new* session is starting: its frames carry
|
||||
# this session's id, and to the coordinator an unfamiliar id is otherwise a
|
||||
# stale frame from a previous session — which is how the pane ended up frozen
|
||||
# on the first run's last picture for every run after it.
|
||||
self.mesh3d_panel.begin_session()
|
||||
# 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.
|
||||
|
||||
@@ -6,8 +6,25 @@ use, and PyOpenGL decides that at its first import — so the import happens ins
|
||||
:func:`~orisys_studio.scene3d.gl_backend.GLDispatch.bind_current_context`, once a
|
||||
real context exists (design document §11.1).
|
||||
|
||||
Nothing in the application imports this package yet: the skeleton is inert until
|
||||
stage 4 wires the panel into ``app_window``.
|
||||
Who owns what, so a change stays local (stages 4–5 of ``studio-3d-stages.md``):
|
||||
|
||||
``view.py``
|
||||
the assembly. ``Scene3DView`` builds the widget, camera, renderer, composer and
|
||||
coordinator, owns the tick, and is the **only** module the application talks to
|
||||
for anything but the pane's chrome.
|
||||
``coordinator.py``
|
||||
identity and scheduling. It decides *which* frames are current; it knows nothing
|
||||
about Qt, GL or the file system.
|
||||
``adapters.py`` / ``asset_source.py`` / ``prefs.py``
|
||||
the three boundaries: acquisition arrays in, packages in, preferences in.
|
||||
``widget.py`` / ``gl_backend.py`` / ``camera.py``
|
||||
the drawing: Qt context, GL objects, view maths. No acquisition, no policy.
|
||||
``panel.py``
|
||||
controls and status text. It reads and writes :class:`DisplaySettings` and never
|
||||
reaches for a frame.
|
||||
``ui/mesh3d_panel.py`` (in the ``ui`` package)
|
||||
the pane's frame in the window: which view, which asset, which state — the place
|
||||
the window learns "there is no 3D here, and why".
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
@@ -164,6 +164,35 @@ class ThreeDCoordinator:
|
||||
# Come back on the newest frame rather than replaying what was dropped.
|
||||
self._set_state("ready" if self._assets is not None else "disabled")
|
||||
|
||||
def begin_session(self) -> None:
|
||||
"""A new acquisition session starts: accept its frames, not the old ones.
|
||||
|
||||
``session_id`` is part of a frame's identity and a frame from another session is
|
||||
refused — that is what keeps a leftover picture from being read as the current
|
||||
measurement. But a *new* session is not a stale one, and something has to say
|
||||
so: the application calls this when a run starts (live, or a recompute pass over
|
||||
a recording). Without it the coordinator latched onto the first session it ever
|
||||
saw and silently refused **every later session's frames** while still reporting
|
||||
state ``live`` — which is exactly "the 3D view stopped working after the first
|
||||
run". A new session is a new generation, so anything already in flight belongs
|
||||
to the past and is dropped here as well.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
self._cancel_inflight()
|
||||
self._incoming.clear()
|
||||
self._composed.clear()
|
||||
self._session_id = None
|
||||
self._generation += 1
|
||||
self._last_composed_identity = None
|
||||
self._last_field_frame = None
|
||||
self._settings_dirty = False
|
||||
if self._assets is not None:
|
||||
# The reference model, not the last measurement: the new session has not
|
||||
# produced anything yet, and an old picture would be a claim about it.
|
||||
self._widget.present(self._composer.rest_frame(self._assets, self._identity(0)))
|
||||
self._set_state("ready" if self._visible else "suspended")
|
||||
|
||||
# ---------------------------------------------------------------- producers
|
||||
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
|
||||
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
|
||||
@@ -186,7 +215,16 @@ class ThreeDCoordinator:
|
||||
)
|
||||
return "stale"
|
||||
if session_id is not None and identity.session_id != session_id:
|
||||
# Counted *and* reported: a pane that silently refuses a whole session
|
||||
# looks like a frozen picture, and "a frame from another session was
|
||||
# dropped" is the sentence that explains it.
|
||||
self._stale_frames += 1
|
||||
self._last_diagnostic = diagnostic(
|
||||
"stale_frame",
|
||||
"a frame from another acquisition session was dropped",
|
||||
frame=str(identity.session_id),
|
||||
scene=str(session_id),
|
||||
)
|
||||
return "stale"
|
||||
problem = self._reject_reason(frame, assets)
|
||||
if problem is not None:
|
||||
|
||||
@@ -18,7 +18,7 @@ Two rules it exists to enforce in the interface:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
@@ -44,9 +44,11 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
SceneAssets,
|
||||
SceneState,
|
||||
)
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
from orisys_studio.scene3d.prefs import FPS_CHOICES
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
#: Width of one control in the pane. Chosen against
|
||||
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen:
|
||||
|
||||
@@ -315,6 +315,16 @@ class Scene3DView(QWidget):
|
||||
if self._panel is not None:
|
||||
self._panel.set_source_kind(kind)
|
||||
|
||||
def begin_session(self) -> None:
|
||||
"""The application is starting an acquisition session for this pane.
|
||||
|
||||
Called from the run path, before the first frame of the session arrives: the
|
||||
coordinator has to know that a new ``session_id`` is legitimate rather than a
|
||||
leftover (see ``ThreeDCoordinator.begin_session``).
|
||||
"""
|
||||
if self._coordinator is not None:
|
||||
self._coordinator.begin_session()
|
||||
|
||||
def show_reference_only(self) -> None:
|
||||
"""Drop whatever measurement is on screen and show the surface model.
|
||||
|
||||
@@ -374,6 +384,81 @@ class Scene3DView(QWidget):
|
||||
def render_stats(self) -> Optional[RenderStats]:
|
||||
return self._last_stats
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""What the pane is, for a recording's ``visualization3d`` block.
|
||||
|
||||
One method instead of the caller walking ``assets``, ``gl_widget``,
|
||||
``capabilities()``, ``settings()`` and ``last_descriptor``: the pane owns the
|
||||
answer to "what was on screen", and the window only has to ask. Everything here
|
||||
is display state, so it can be recorded without touching the measurement.
|
||||
"""
|
||||
assets = self._assets
|
||||
if assets is None:
|
||||
return {}
|
||||
widget = self._widget
|
||||
capabilities = widget.capabilities() if widget is not None else None
|
||||
settings = self.settings()
|
||||
scale = settings.color_scale
|
||||
descriptor = self._last_descriptor
|
||||
return {
|
||||
"scene": {
|
||||
"asset_id": str(getattr(assets, "asset_id", "")),
|
||||
"asset_version": str(getattr(assets, "asset_version", "")),
|
||||
"asset_hash": str(getattr(assets, "asset_hash", "")),
|
||||
"geometry_id": str(getattr(assets, "geometry_id", "")),
|
||||
"field_domain_id": str(getattr(assets, "field_domain_id", "")),
|
||||
"platform_lock": (
|
||||
assets.platform_lock.to_dict()
|
||||
if getattr(assets, "platform_lock", None) is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"gl": (
|
||||
{
|
||||
"profile": str(capabilities.profile),
|
||||
"vendor": str(capabilities.vendor),
|
||||
"renderer": str(capabilities.renderer),
|
||||
"gl_version": str(capabilities.gl_version),
|
||||
"glsl_version": str(capabilities.glsl_version),
|
||||
"software_renderer": bool(capabilities.software_renderer),
|
||||
}
|
||||
if capabilities is not None
|
||||
else {}
|
||||
),
|
||||
"display": {
|
||||
"deform_enabled": bool(settings.deform_enabled),
|
||||
"display_gain": float(settings.display_gain),
|
||||
"housing_visible": bool(settings.housing_visible),
|
||||
"max_fps": int(settings.max_fps),
|
||||
"scale_mode": str(scale.mode) if scale is not None else "none",
|
||||
"scale_lower": float(scale.lower) if scale is not None else None,
|
||||
"scale_upper": float(scale.upper) if scale is not None else None,
|
||||
},
|
||||
"stream": (
|
||||
{
|
||||
"quantity": str(descriptor.quantity),
|
||||
"unit": str(descriptor.unit),
|
||||
"quality": str(descriptor.quality),
|
||||
"signed": bool(descriptor.signed),
|
||||
}
|
||||
if descriptor is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
def render_totals(self) -> dict:
|
||||
"""Display-side counters: shown frames and display drops, never recording ones."""
|
||||
coordinator = self._coordinator
|
||||
stats = self._last_stats
|
||||
return {
|
||||
"shown": int(getattr(stats, "displayed_frames", 0) or 0),
|
||||
"dropped_display": int(
|
||||
coordinator.superseded_frames if coordinator is not None else 0
|
||||
),
|
||||
"stale": int(getattr(coordinator, "stale_frames", 0) or 0),
|
||||
"invalid": int(getattr(coordinator, "invalid_frames", 0) or 0),
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the GPU resources, stop the worker. Idempotent."""
|
||||
if self._coordinator is not None:
|
||||
|
||||
@@ -14,13 +14,13 @@ acquisition, and no path that reports a 3D problem as a recording problem.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, 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_app_shared.scene3d.contracts import Diagnostic, ExpectedGeometry, SceneAssets
|
||||
from orisys_studio.scene3d.asset_source import (
|
||||
SceneAssetLoader,
|
||||
SceneSource,
|
||||
@@ -28,6 +28,9 @@ from orisys_studio.scene3d.asset_source import (
|
||||
no_asset_diagnostic,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
#: 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.
|
||||
@@ -65,7 +68,7 @@ class Mesh3dPanel(QFrame):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._view: Optional[QWidget] = None
|
||||
self._view: Optional["Scene3DView"] = None
|
||||
self._loader: Optional[SceneAssetLoader] = None
|
||||
self._source: Optional[SceneSource] = None
|
||||
self._sensor_type = ""
|
||||
@@ -143,6 +146,25 @@ class Mesh3dPanel(QFrame):
|
||||
return
|
||||
self._show_message(_LOADING, name=self._source.path.name)
|
||||
|
||||
def attach_view(self, view: "Scene3DView") -> None:
|
||||
"""Adopt a view: the real one (built lazily) or a double in a test.
|
||||
|
||||
Public because it is the seam tests use — poking ``_view`` from a test would
|
||||
make every rename of a private attribute a test edit, and the container's
|
||||
contract with the view is exactly the set of methods below.
|
||||
"""
|
||||
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:
|
||||
view.restore_settings(self._pending_settings)
|
||||
# The kind was decided before the view existed (the window sets it when the
|
||||
# view preset changes), so it is pushed again here rather than lost.
|
||||
view.set_source_kind(self._source_kind)
|
||||
if not view.available:
|
||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||
|
||||
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":
|
||||
@@ -150,20 +172,9 @@ class Mesh3dPanel(QFrame):
|
||||
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)
|
||||
# The kind was decided before the view existed (the window sets it when the
|
||||
# view preset changes), so it is pushed again here rather than lost.
|
||||
if hasattr(view, "set_source_kind"):
|
||||
view.set_source_kind(self._source_kind)
|
||||
if not view.available:
|
||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||
return view
|
||||
self._load_scene()
|
||||
self.attach_view(view)
|
||||
if view.available:
|
||||
self._load_scene()
|
||||
return view
|
||||
|
||||
def _load_scene(self) -> None:
|
||||
@@ -185,19 +196,56 @@ class Mesh3dPanel(QFrame):
|
||||
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"):
|
||||
if self._view is not None:
|
||||
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"):
|
||||
if self._view is not None:
|
||||
return self._view.settings()
|
||||
if self._pending_settings is not None:
|
||||
return self._pending_settings
|
||||
return settings_from_prefs(None)
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""Everything a recording's ``visualization3d`` block needs, in one call.
|
||||
|
||||
The pane answers for itself — whether it is on, what it is showing, with which
|
||||
GL context and which display settings — so the window does not have to know the
|
||||
shape of a view (or of a scene, or of a capability record) to record a picture.
|
||||
"""
|
||||
available = self.is_available()
|
||||
diagnostic = self._reported
|
||||
assets_loaded = self._view is not None and getattr(self._view, "assets", None) is not None
|
||||
reason = ""
|
||||
if not assets_loaded:
|
||||
reason = (
|
||||
diagnostic.message
|
||||
if diagnostic is not None
|
||||
else t("panels.mesh3d_no_scene")
|
||||
)
|
||||
described = self._view.describe() if self._view is not None else {}
|
||||
return {
|
||||
"enabled": bool(self._enabled),
|
||||
"available": bool(available),
|
||||
"reason": reason,
|
||||
"scene": {
|
||||
"path": str(self._source.path) if self._source is not None else "",
|
||||
**dict(described.get("scene") or {}),
|
||||
},
|
||||
"gl": dict(described.get("gl") or {}),
|
||||
"display": dict(described.get("display") or {}),
|
||||
"stream": dict(described.get("stream") or {}),
|
||||
}
|
||||
|
||||
def render_totals(self) -> dict:
|
||||
"""Display-side counters for the recording's stop event."""
|
||||
if self._view is None:
|
||||
return {"shown": 0, "dropped_display": 0, "stale": 0, "invalid": 0}
|
||||
return self._view.render_totals()
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
def _on_loaded(self, assets, description: str) -> None:
|
||||
view = self._view
|
||||
@@ -234,7 +282,7 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
@property
|
||||
def view(self) -> Optional[QWidget]:
|
||||
def view(self) -> Optional["Scene3DView"]:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
@@ -255,7 +303,7 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
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))
|
||||
return self._view is not None and bool(self._view.available)
|
||||
|
||||
def scene_path(self) -> Optional[Path]:
|
||||
return None if self._source is None else self._source.path
|
||||
@@ -263,22 +311,27 @@ class Mesh3dPanel(QFrame):
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
"""Where the field comes from: live, replay (model only) or recompute."""
|
||||
self._source_kind = str(kind)
|
||||
if self._view is not None and hasattr(self._view, "set_source_kind"):
|
||||
if self._view is not None:
|
||||
self._view.set_source_kind(self._source_kind)
|
||||
|
||||
def show_reference_only(self) -> None:
|
||||
"""Clear a measurement that is no longer being produced (a stopped pass)."""
|
||||
if self._view is not None and hasattr(self._view, "show_reference_only"):
|
||||
if self._view is not None:
|
||||
self._view.show_reference_only()
|
||||
|
||||
def begin_session(self) -> None:
|
||||
"""A new acquisition session is starting: the view must accept its frames."""
|
||||
if self._view is not None:
|
||||
self._view.begin_session()
|
||||
|
||||
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"):
|
||||
if self._view is not None:
|
||||
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"):
|
||||
if self._view is not None:
|
||||
self._view.close()
|
||||
if self._loader is not None:
|
||||
self._loader.close()
|
||||
|
||||
Reference in New Issue
Block a user