三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - 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:
@@ -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")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user