三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - 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,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