e2570925e8
- What:
1. **能力声明**:`contracts.SceneCapabilities`(quantity/unit/displacement_available/
displacement_quality/source_kind);两个适配器都声明 `displacement_available = False`(它们只把标量
画在表面上,从不发布位移);`Scene3DView.capabilities()` 从**实际绑定的适配器**读出并推给面板。
`ThreeDPanel.set_capabilities()` 因此把"放大形变"/增益置灰并给出原因(新键 `scene3d.deform_unavailable`),
`settings()` 在能力缺失时**强制** `deform_enabled=False`(否则持久化的增益会悄悄作用在没有东西可放大的源上);
禁用状态在 `set_enabled`/`apply_settings` 之后仍然成立(原来 `apply_settings` 只按复选框状态使能增益)。
2. **真实色标**:`GLRenderer` 记录并暴露本次绘制**真正使用的**上下限与模式(`effective_scale()`),
写进 `RenderStats.scale_lower/scale_upper/scale_mode`;`_scalar_range` 在 `release()` 与
`upload_scene()` 重置(原来换场景/重建 context 后首个自动范围沿用旧场景);面板新增 `ColorScaleBar`
(用 `gl_backend.lut_rgba` 的同一张 256×1 表绘制),图例改为
`{quantity} [{unit}] · {自动|固定}色标 {lower} – {upper} · {quality}`(新键 `scene3d.legend_effective`/
`legend_auto`/`legend_fixed`),自动模式下不再让被禁用的输入框冒充"当前范围"。
3. **文案**:`scene3d.quantity_flow_magnitude`="光流幅值"/`quantity_depth_response`,preset "default" 显示为
"默认视角"(数据仍是资产里的真名),`scene3d.stats` 的"丢弃"改为"显示跳帧"(与录像丢帧区分)。
4. **复位语义**:`Scene3DView.reset_view()` 改为回到**资产自己的默认视角**(`camera_presets[0]`),
与首次显示一致;只有在没有 preset 时才退回 `OrbitCamera.reset()`。原来它把 yaw/pitch 清零并 refit,
与首帧呈现的 preset 视角不是同一个画面。
5. `describe()` 记录 `capabilities`(含 `displacement_available`)与 `effective_scale`(实际上下限与模式),
这样一条录像能说明"当时的颜色代表什么",而不是只留下操作者输入框里的数字。
- Why: 审查报告 R6:`FingerFrameAdapter` 只输出光流幅值、`displacement_sensor_m` 始终为 None,而面板仍允许
"放大形变"和 0×..500×——用户能操作一个没有真实输入的功能;自动色标每帧按实际范围变化,但面板范围输入框
仍是保存的 0..1,没有把颜色和数值联系起来的图例;`reset_view()` 与首次显示用的资产 preset 语义不一致。
报告明确要求"由 SceneCapabilities / 当前 FieldDescriptor 控制控件可用性""新增独立 effective_scale 输出"
"复位也应统一语义"。
- Verification:
· `test_scene3d_view.py` +3:无位移源 → 形变控件禁用且带原因、`settings().deform_enabled` 仍为 False、
`describe()["capabilities"]["displacement_available"] is False`;图例显示实际上下限(0.25–7.5)与"自动"、
不再出现内部键、`describe()["effective_scale"]` 三项一致;复位后的相机矩阵等于
`apply_preset(camera_presets[0])` 的结果(先 orbit+zoom 离开再复位)。
· `test_scene3d_skeleton.py` 的图例断言改为"不含内部键、含操作者语言的名字"。
· 全量:apps/studio 240 项 0 失败 14 跳过;真 GL 冒烟 14 项 0 失败 0 跳过;ui_check 587 项 0 失败;
locale_check 0 缺失。
- Not in this change: **画布布局(2D/3D 页签容器、参数收进"显示设置"抽屉、窄窗口回退)留作阶段 8 的第二批**
(需要同时改 `ui_check` 的布局断言与截图对比,风险与工作量都更大,独立提交更易审阅);曲线量程的
"可见区间自动/固定"开关(报告建议项)也留到第二批;诊断抽屉的结构调整同理;阶段 9(限速、真实耗时统计、
保护包密钥注入、ARM 与 2 小时门禁)未开始。
175 lines
7.4 KiB
Python
175 lines
7.4 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 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_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 True, (
|
|
"the panel's resting state and DisplaySettings' default must agree"
|
|
)
|
|
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()
|