175 lines
7.5 KiB
Python
175 lines
7.5 KiB
Python
"""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 PySide6.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 orisys_studio.scene3d.gl_backend import configure_surface_format
|
|
from PySide6.QtGui import QSurfaceFormat
|
|
|
|
configure_surface_format("desktop33")
|
|
desktop = QSurfaceFormat.defaultFormat()
|
|
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
|
|
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
|
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.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.RenderableType.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
|
|
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_no_scene3d_entry_point_is_still_a_stub(qt_app):
|
|
"""The stage-0 floor, kept as a gate: nothing left says "not implemented".
|
|
|
|
Every stage of the plan replaces stubs with the real thing, and a stub that
|
|
survives its stage is worse than a missing feature: it looks implemented. This
|
|
fails if a ``raise NotImplementedError`` comes back into either scene3d package,
|
|
and it exercises the last entry points that used to be stubs (stage 4's panel)
|
|
so "implemented" is also checked by calling them.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
import orisys_studio.scene3d
|
|
from orisys_app_shared.scene3d.contracts import (
|
|
CameraPreset,
|
|
DisplaySettings,
|
|
FieldDescriptor,
|
|
FrameIdentity,
|
|
RenderStats,
|
|
diagnostic,
|
|
)
|
|
from orisys_studio.scene3d.camera import OrbitCamera
|
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
|
from orisys_studio.scene3d.gl_backend import GLRenderer
|
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
|
|
|
roots = [Path(orisys_studio.scene3d.__file__).resolve().parent]
|
|
import orisys_app_shared.scene3d as shared_scene3d
|
|
|
|
roots.append(Path(shared_scene3d.__file__).resolve().parent)
|
|
for root in roots:
|
|
for path in sorted(root.rglob("*.py")):
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "NotImplementedError" not in text or "__init__" in path.name, path
|
|
if path.name == "__init__.py":
|
|
# The package docstring may mention the word; code may not.
|
|
assert "raise NotImplementedError" not in text, path
|
|
|
|
camera = OrbitCamera()
|
|
widget = Sensor3DWidget(GLRenderer(), camera)
|
|
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
|
panel = ThreeDPanel(coordinator, widget)
|
|
try:
|
|
# Stage 1 is real: the camera works with no context at all.
|
|
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 camera.matrices((100, 100)).view.shape == (4, 4)
|
|
|
|
# Stage 3 is real too: the coordinator's schedule exists with no Qt event.
|
|
assert coordinator.state == "disabled"
|
|
assert coordinator.generation == 0
|
|
assert coordinator.superseded_frames == 0
|
|
coordinator.close() # idempotent, and safe before any scene is active
|
|
|
|
# Stage 4: the panel is the operator's view of all of the above.
|
|
panel.set_state("ready")
|
|
assert panel.state_label.text()
|
|
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
|
# The legend states the operator-facing name, not the internal key: the key is
|
|
# what a recording carries, the label is what a person reads.
|
|
legend = panel.legend_label.text()
|
|
assert "flow_magnitude" not in legend, legend
|
|
assert "光流幅值" in legend or "Optical flow magnitude" in legend, legend
|
|
panel.set_descriptor(None)
|
|
panel.set_render_stats(RenderStats(displayed_frames=3, superseded_frames=1))
|
|
assert "3" in panel.stats_label.text()
|
|
panel.set_diagnostic(None)
|
|
panel.set_diagnostic(diagnostic("field_unavailable", "no field in this frame"))
|
|
assert "no field in this frame" in panel.legend_label.text()
|
|
panel.set_scene(None)
|
|
panel.set_enabled(False)
|
|
settings = panel.settings()
|
|
assert settings.max_fps == 30
|
|
assert settings.display_gain == 0.0
|
|
assert settings.housing_visible is False, (
|
|
"the panel's resting state and DisplaySettings' default must agree: the housing "
|
|
"ships hidden, and the checkbox starts unchecked to match"
|
|
)
|
|
assert settings.color_scale is not None
|
|
assert settings.color_scale.mode == "automatic"
|
|
assert DisplaySettings(max_fps=30).max_fps == settings.max_fps
|
|
del FrameIdentity
|
|
finally:
|
|
widget.deleteLater()
|