三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - What: 按 docs/architecture/studio-3d-design.md 与 studio-3d-contracts.pyi 搭建骨架(阶段 0)。shared/src/orisys_app_shared/scene3d/:contracts.py 把 .pyi 的规格变成可执行代码——全部 DTO/枚举(FrameIdentity/FieldDescriptor/SurfaceFieldFrame/MeshPart/SurfaceBinding/CameraPreset/ExpectedGeometry/SceneAssets/ColorScale/DisplaySettings/VertexFields/RenderFrame/CameraMatrices/GLCapabilities/RenderStats/CompileOptions/CompileReport)、标准诊断码表(DIAGNOSTIC_SEVERITY,含 stale_frame=info、field_unavailable=warning 等)、ThreeDError、freeze()(拥有数据 + C 连续 + 只读)、五个校验器(validate_surface_frame/validate_binding/validate_mesh_part/validate_assets/validate_display_settings/validate_render_frame)、LatestFrameMailbox(容量 1、替换计数、锁内只换引用)、UNAVAILABLE_DESCRIPTOR/rest_descriptor() 与 summarize_parts(),以及全部 Protocol;该模块不 import Qt/OpenGL/SDK。assets.py/sampling.py/composer.py 为签名骨架(未实现处抛 NotImplementedError 并写明归属阶段)。apps/studio/src/orisys_studio/scene3d/:camera/adapters/coordinator/gl_backend/widget/panel 签名骨架,其中 configure_surface_format() 已实现(QApplication 之前调用,desktop33=GL3.3 Core / gles30=GLES3.0,深度 24、无 MSAA);模块级一律不 import GL 绑定,PyOpenGL 只在 GLDispatch.bind_current_context 里加载。tools/scene_assets/{probe_gl,validate_scene,compile_scene}.py 提供入口与参数,未实现时输出 not_implemented 并返回 2(避免被 CI 当成通过)。docs/architecture/studio-3d-stages.md:阶段 0-5 的产出、每阶段的验收矩阵、并行度与回归门。测试:shared/tests/test_scene3d_contracts.py 26 项(freeze 拥有数据、可写/非连续数组被拒、scalar 与 valid 形状一致、位移缺 quality 被拒、非有限 scalar 标为 valid 只报警不静默、绑定越界/-1 约定/权重和为 1、三角形索引越界、感知法线单位长度、恰好一个 sensing part、binding 必须属于 sensing part、设备几何/域/尺寸不符被拒、色标 lower<upper、邮箱替换计数与多线程不阻塞生产者、身份区分 source/generation、骨架可导入且不引入 PyOpenGL);apps/studio/tests/test_scene3d_skeleton.py 4 项(骨架不 import OpenGL、surface format 两个 profile、widget 构造不需要 context、各 stub 报出归属阶段)。 - Why: 用户要求"先从骨架搭起,然后计划功能实现顺序,最后逐阶段实现与验收"。骨架阶段的价值在于把接口变成可执行的(.pyi 本身无法被测试),并把"数组只读、单位与质量必须声明、近似位移不得当毫米、参考帧不得冒充测量、显示丢帧不得计入记录丢帧"这些容易在实现时被绕过的规则先固定下来;同时明确不 import Qt/OpenGL 的边界,使采集线程与 CI 能在无 GPU 环境导入它。 - Verification: shared 全量 187 passed(原 161 + 26);studio 95 passed(原 91 + 4);ui_check 554 项检查 0 失败;compileall 通过;probe_gl --profile desktop33 在未实现时返回 2 并说明将产出什么报告。 - Not in this change: 三维功能本身(阶段 1 起);app_window/main.py 未接线,骨架对现有应用完全惰性(无任何模块被应用导入)。

This commit is contained in:
ykcheungab
2026-09-14 14:32:38 +08:00
parent c10e579371
commit 0b309e7edf
18 changed files with 2327 additions and 0 deletions
@@ -0,0 +1,13 @@
"""Studio-side three-dimensional display: Qt, GL and the frame schedule.
Import rule for this package: **nothing may import a GL binding at module level.**
The binding's platform (GLX/EGL) has to match the Qt platform that is actually in
use, and PyOpenGL decides that at its first import — so the import happens inside
: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``.
"""
__all__: list[str] = []
@@ -0,0 +1,51 @@
"""Frame adapters: ``SdkPacket`` (+ runtime) to a publishable ``SurfaceFieldFrame``.
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
The constraints here are about *where* the data may be read, not about optics:
* the adapter runs on the acquisition thread and only while that thread owns the
runtime, because the SDK's arrays are that thread's working memory;
* it copies what the frame needs and publishes it read-only — the GUI thread must
never reach back into a live runtime;
* it copies **nothing** when the three-dimensional view is not subscribed to, and
returns ``None`` when the frame carries no usable field. A frame is never
assembled from two different acquisitions, and a missing field is never filled
in from the previous one.
"""
from __future__ import annotations
from typing import Any, Optional
from orisys_app_shared.acquisition.models import SdkPacket
from orisys_app_shared.scene3d.contracts import (
ExpectedGeometry,
FrameIdentity,
SurfaceFieldFrame,
)
_STAGE = "stage 3 (live data)"
class FingerFrameAdapter:
"""The finger runtime already computes a UV field; copy it, with its identity."""
def snapshot(
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]:
raise NotImplementedError(f"{_STAGE}: snapshot the finger UV field")
class BoxFrameAdapter:
"""Box/mini: a scalar field only.
There is no verified spatial scale for these sensors yet, so no displacement is
invented: a millimetre height field that was never calibrated is worse than no
height field at all.
"""
def snapshot(
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
) -> Optional[SurfaceFieldFrame]:
raise NotImplementedError(f"{_STAGE}: snapshot the box scalar field")
@@ -0,0 +1,50 @@
"""The observation camera: orbit, pan, zoom, presets. Pure maths.
Stage 1 gets a minimal ``fit``/``orbit`` so a static scene can be looked at; the
full interaction (pan, zoom limits, presets, reset) is stage 4 in
``docs/architecture/studio-3d-stages.md``.
It is deliberately *not* the algorithm camera. It changes ``view`` and
``projection`` only: ``window_to_world`` for the pixel→surface mapping, the
calibration and the force axes are untouched by anything a user does here. The
default view comes from the asset's ``CameraPreset``, so it is versioned with the
geometry rather than with a user's preference file.
"""
from __future__ import annotations
from orisys_app_shared.scene3d.contracts import (
CameraMatrices,
CameraPreset,
F32,
)
_STAGE = "stage 1 (fit/orbit) and stage 4 (full interaction)"
class OrbitCamera:
"""A target, a distance and two angles. Everything else is derived."""
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
"""Frame the whole scene: set the target, distance, near and far planes."""
raise NotImplementedError(f"{_STAGE}: fit the camera to the scene bounds")
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
"""Rotate about the target. The model data does not move."""
raise NotImplementedError(f"{_STAGE}: orbit")
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
"""Slide the target in the view plane, scaled by distance."""
raise NotImplementedError(f"{_STAGE}: pan")
def zoom(self, wheel_steps: float) -> None:
"""Exponential zoom, clamped so the surface cannot be entered or lost."""
raise NotImplementedError(f"{_STAGE}: zoom")
def set_preset(self, preset: CameraPreset) -> None:
"""Front / side / top / isometric / asset default."""
raise NotImplementedError(f"{_STAGE}: apply a camera preset")
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices:
"""``p_camera = view @ p_sensor``, ``p_clip = projection @ p_camera``."""
raise NotImplementedError(f"{_STAGE}: build the view and projection matrices")
@@ -0,0 +1,89 @@
"""Frame schedule and state machine for the three-dimensional view.
Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility, settings) in
``docs/architecture/studio-3d-stages.md``.
The whole point of this class is that **the acquisition loop never waits for the
display**. Producers call :meth:`submit`, which validates cheaply and drops the
previous un-consumed frame; a GUI timer calls :meth:`tick`, which picks up the
newest composed frame and repaints. Display drops are counted here and never in
the recording channel, and no CPU task is submitted while one is still running.
"""
from __future__ import annotations
from concurrent.futures import Executor
from typing import Optional
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
LatestFrameMailbox,
OfferResult,
RenderFrame,
SceneAssets,
SceneState,
SurfaceFieldFrame,
)
from orisys_studio.scene3d.widget import Sensor3DWidget
_STAGE = "stage 3 (schedule) and stage 4 (Qt integration)"
class ThreeDCoordinator:
"""Owns the two mailboxes, the compose worker and the scene generation."""
def __init__(
self,
widget: Sensor3DWidget,
composer: object,
executor: Executor,
max_fps: int = 30,
) -> None:
self._widget = widget
self._composer = composer
self._executor = executor
self._max_fps = int(max_fps)
self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox()
self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox()
self._state: SceneState = "disabled"
self._generation = 0
self._assets: Optional[SceneAssets] = None
self._visible = False
def activate(self, assets: SceneAssets, source_id: str) -> int:
"""Adopt a scene and a data source; returns the new generation."""
raise NotImplementedError(f"{_STAGE}: activate a scene")
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
raise NotImplementedError(f"{_STAGE}: accept or reject an incoming frame")
def tick(self) -> None:
"""GUI timer: take the newest composed frame, present it, schedule work."""
raise NotImplementedError(f"{_STAGE}: pump the mailboxes")
def set_visible(self, visible: bool) -> None:
"""Hiding suspends composing, uploading and rendering — not acquisition."""
raise NotImplementedError(f"{_STAGE}: suspend or resume the view")
def set_settings(self, settings: DisplaySettings) -> None:
raise NotImplementedError(f"{_STAGE}: apply display settings")
def close(self) -> None:
"""Cancel work, stop the timer, clear both mailboxes. Idempotent."""
raise NotImplementedError(f"{_STAGE}: shut the view down")
@property
def state(self) -> SceneState:
return self._state
@property
def generation(self) -> int:
return self._generation
@property
def superseded_frames(self) -> int:
"""Frames the display never showed. Not a recording drop."""
return (
self._incoming.superseded_frames + self._composed.superseded_frames
)
@@ -0,0 +1,99 @@
"""OpenGL: surface format, capability probing, shaders and GPU resources.
Stage 1 (platform PoC) in ``docs/architecture/studio-3d-stages.md``.
Import discipline, and why it is not negotiable:
* :func:`configure_surface_format` runs **before** ``QApplication`` exists and
only touches ``QSurfaceFormat``;
* the PyOpenGL import happens inside :meth:`GLDispatch.bind_current_context`,
after Qt has made a real context current, so the binding is chosen for the
platform Qt actually used (GLX or EGL) instead of being guessed;
* ``GLRenderer.release`` is idempotent and requires the owning context to be
current — resources are freed by the context that made them.
The probe is what decides the profile on a board. A missing GL, a software
renderer, or a failed shader compile means the three-dimensional view reports
itself unavailable and the two-dimensional application carries on unchanged.
"""
from __future__ import annotations
from typing import Any
from orisys_app_shared.scene3d.contracts import (
CameraMatrices,
DisplaySettings,
GLCapabilities,
Profile,
RenderFrame,
RenderStats,
SceneAssets,
)
_STAGE = "stage 1 (platform PoC)"
def configure_surface_format(profile: Profile = "desktop33") -> None:
"""Select the default GL surface format. Call before ``QApplication``.
One profile per process: the two profiles are two different start-up
configurations, not something to switch at runtime (design document §11.1).
Multisampling stays off in v1: it multiplies the cost of the one thing this
component must not slow down (the acquisition loop feeds the scene through a
capacity-one mailbox), and the design document defers anti-aliasing work.
"""
from PyQt5.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
if profile == "gles30":
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
fmt.setVersion(3, 0)
elif profile == "desktop33":
fmt.setRenderableType(QSurfaceFormat.OpenGL)
fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.CoreProfile)
else: # pragma: no cover - a typo must not reach a board silently
raise ValueError(f"unknown GL profile: {profile!r}")
fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8)
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
fmt.setSamples(0)
QSurfaceFormat.setDefaultFormat(fmt)
class GLDispatch:
"""Bind the GL entry points for the context Qt actually created."""
def bind_current_context(self, context: Any) -> GLCapabilities:
raise NotImplementedError(f"{_STAGE}: probe the context and bind PyOpenGL")
class GLRenderer:
"""Shaders, buffers and draw calls for one scene.
Two parts, two materials, one draw call per part: the sensing surface is
re-uploaded per frame, the housing is uploaded once and then left alone.
"""
def initialize(self, capabilities: GLCapabilities) -> None:
raise NotImplementedError(f"{_STAGE}: compile the shaders for this profile")
def upload_scene(self, assets: SceneAssets) -> None:
raise NotImplementedError(f"{_STAGE}: upload static buffers and the LUT")
def update_surface(self, frame: RenderFrame) -> None:
raise NotImplementedError(f"{_STAGE}: update the dynamic surface buffers")
def draw(
self,
camera: CameraMatrices,
settings: DisplaySettings,
framebuffer: int,
viewport: tuple[int, int],
) -> RenderStats:
raise NotImplementedError(f"{_STAGE}: draw one frame")
def release(self) -> None:
raise NotImplementedError(f"{_STAGE}: release GPU resources")
@@ -0,0 +1,54 @@
"""``ThreeDPanel``: the controls 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.
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.
"""
from __future__ import annotations
from typing import Optional
from PyQt5.QtWidgets import QWidget
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
RenderStats,
SceneState,
)
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.widget import Sensor3DWidget
_STAGE = "stage 4 (product integration)"
class ThreeDPanel(QWidget):
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
def __init__(
self,
coordinator: ThreeDCoordinator,
widget: Sensor3DWidget,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent)
self._coordinator = coordinator
self._widget = widget
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")
def set_state(self, state: SceneState) -> None:
raise NotImplementedError(f"{_STAGE}: reflect the scene state")
def set_render_stats(self, stats: RenderStats) -> None:
"""Display-side statistics only: never shown as recording health."""
raise NotImplementedError(f"{_STAGE}: show render statistics")
@@ -0,0 +1,84 @@
"""``Sensor3DWidget``: the Qt widget that owns the GL context and nothing else.
Stage 1 (a static scene, resize, close) and stage 4 (product integration) in
``docs/architecture/studio-3d-stages.md``.
Qt owns the context and the framebuffer, so the widget must never bind target 0:
it draws into the FBO Qt has already set up for the current ``paintGL`` call. The
widget holds CPU-side data (the latest :class:`RenderFrame`) and schedules the
upload; it does no composing, no asset IO and no SDK access, so a slow render can
only drop *display* frames.
"""
from __future__ import annotations
from typing import Optional
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
DisplaySettings,
GLCapabilities,
RenderFrame,
RenderStats,
SceneAssets,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import GLRenderer
_STAGE = "stage 1 (static scene) and stage 4 (product integration)"
class Sensor3DWidget(QOpenGLWidget):
"""Renders one scene. Signals carry state, never per-frame arrays."""
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
def __init__(
self,
renderer: GLRenderer,
camera: OrbitCamera,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent)
self._renderer = renderer
self._camera = camera
self._assets: Optional[SceneAssets] = None
self._frame: Optional[RenderFrame] = None
self._settings = DisplaySettings()
self._capabilities: Optional[GLCapabilities] = None
self._needs_scene_upload = False
self._needs_surface_upload = False
def set_scene(self, assets: SceneAssets) -> None:
"""Retain the asset and schedule its upload for the next current context."""
self._assets = assets
self._needs_scene_upload = True
self.update()
def present(self, frame: RenderFrame) -> None:
"""Keep the newest CPU frame and ask for a repaint. Never requires a context."""
self._frame = frame
self._needs_surface_upload = True
self.update()
def set_settings(self, settings: DisplaySettings) -> None:
self._settings = settings
self.update()
# ------------------------------------------------------------ Qt overrides
def initializeGL(self) -> None: # pragma: no cover - needs a real context
raise NotImplementedError(f"{_STAGE}: bind the backend and connect teardown")
def paintGL(self) -> None: # pragma: no cover - needs a real context
raise NotImplementedError(f"{_STAGE}: upload what changed and draw")
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover
raise NotImplementedError(f"{_STAGE}: calibrate DPR and update the projection")
def release_resources(self) -> None: # pragma: no cover - needs a real context
raise NotImplementedError(f"{_STAGE}: makeCurrent, release, doneCurrent")
+141
View File
@@ -0,0 +1,141 @@
"""The studio-side three-dimensional skeleton: importable, inert, Qt-safe.
Acceptance for stage 0 (``docs/architecture/studio-3d-stages.md``): the module
tree exists, it can be imported without PyOpenGL, and the one piece that must run
before ``QApplication`` — the surface format — selects a complete profile.
Nothing here renders. These tests are the floor a later stage must keep: the GL
binding is imported only once a real context exists, and the stubs say which stage
fills them in.
"""
from __future__ import annotations
import sys
import pytest
@pytest.fixture
def restore_surface_format():
"""The default surface format is process-wide; put it back afterwards."""
from PyQt5.QtGui import QSurfaceFormat
previous = QSurfaceFormat.defaultFormat()
try:
yield
finally:
QSurfaceFormat.setDefaultFormat(previous)
def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
"""PyOpenGL must be imported for the platform Qt chose, not at import time.
The binding decides GLX versus EGL when it first loads, so importing it while
merely constructing the module tree would freeze a guess. The test fails if a
future change adds a module-level ``import OpenGL``.
"""
for name in [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]:
del sys.modules[name]
import orisys_studio.scene3d.camera # noqa: F401
import orisys_studio.scene3d.coordinator # noqa: F401
import orisys_studio.scene3d.gl_backend # noqa: F401
import orisys_studio.scene3d.panel # noqa: F401
import orisys_studio.scene3d.widget # noqa: F401
loaded = [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]
assert loaded == [], f"importing the skeleton pulled in {loaded}"
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
from PyQt5.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.CoreProfile
assert desktop.depthBufferSize() == 24
assert desktop.samples() == 0, "v1 does not pay for multisampling"
configure_surface_format("gles30")
embedded = QSurfaceFormat.defaultFormat()
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
with pytest.raises(ValueError):
configure_surface_format("vulkan") # type: ignore[arg-type]
def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
"""Constructing the widget must not need a current context or a GPU."""
from orisys_app_shared.scene3d.contracts import DisplaySettings, FrameIdentity
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import GLRenderer
from orisys_studio.scene3d.widget import Sensor3DWidget
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
try:
assert isinstance(widget, Sensor3DWidget)
widget.set_settings(DisplaySettings(display_gain=3.0))
assert widget._settings.display_gain == 3.0
# Nothing has been presented yet, and no context was needed to get here.
assert widget._frame is None
finally:
widget.deleteLater()
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
DisplaySettings,
FrameIdentity,
GLCapabilities,
RenderFrame,
SceneAssets,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer
from orisys_studio.scene3d.widget import Sensor3DWidget
camera = OrbitCamera()
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, camera)
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
try:
assert coordinator.state == "disabled"
assert coordinator.generation == 0
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
with pytest.raises(NotImplementedError) as camera_error:
camera.set_preset(CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7))
assert "stage 1" in str(camera_error.value)
with pytest.raises(NotImplementedError) as gl_error:
GLDispatch().bind_current_context(object())
assert "stage 1" in str(gl_error.value)
with pytest.raises(NotImplementedError) as activate_error:
coordinator.activate(SceneAssets, "live-A") # type: ignore[arg-type]
assert "stage 3" in str(activate_error.value)
with pytest.raises(NotImplementedError) as settings_error:
coordinator.set_settings(DisplaySettings())
assert "stage 3" in str(settings_error.value)
with pytest.raises(NotImplementedError) as initialize_error:
renderer.initialize(GLCapabilities("desktop33", "", "", "", "", "", False, 0))
assert "stage 1" in str(initialize_error.value)
with pytest.raises(NotImplementedError) as update_error:
renderer.update_surface(None) # type: ignore[arg-type]
assert "stage 1" in str(update_error.value)
assert FrameIdentity("live-A", 1, 0, 0.0, 1).generation == 1
assert RenderFrame is not None and SceneAssets is not None
finally:
widget.deleteLater()