feat(studio-3d): 能力控件、真实色标与复位语义 —— 只提供数据源真正喂得出来的控制(阶段 8 第一批)
- 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 小时门禁)未开始。
This commit is contained in:
@@ -33,6 +33,7 @@ import pytest
|
||||
import ui_check
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
@@ -870,6 +871,91 @@ def test_a_scene_without_a_device_description_is_shown_as_unverified(qt_app):
|
||||
view.close()
|
||||
|
||||
|
||||
# --------------------------------------- stage 8: capability-controlled controls
|
||||
def test_a_source_with_no_displacement_cannot_offer_exaggeration(qt_app):
|
||||
"""R6: the control must describe an input that exists."""
|
||||
view = _stale_scene_view(qt_app)
|
||||
try:
|
||||
panel = view.panel
|
||||
assert panel is not None
|
||||
capabilities = view.capabilities()
|
||||
assert capabilities is not None
|
||||
assert capabilities.displacement_available is False, (
|
||||
"the finger adapter publishes no displacement: the capability says so"
|
||||
)
|
||||
assert not panel.deform_check.isEnabled(), (
|
||||
"exaggeration is offered for a source that has no deformation field"
|
||||
)
|
||||
assert not panel.gain_spin.isEnabled()
|
||||
assert t("scene3d.deform_unavailable") in panel.deform_check.toolTip()
|
||||
# And the setting itself is forced off, so a persisted gain cannot silently
|
||||
# apply to a source with nothing to enlarge.
|
||||
panel.deform_check.setChecked(True)
|
||||
assert view.settings().deform_enabled is False
|
||||
|
||||
described = view.describe()
|
||||
assert described["capabilities"]["displacement_available"] is False
|
||||
assert described["capabilities"]["quantity"] == "flow_magnitude"
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
|
||||
def test_the_legend_shows_the_range_the_shader_actually_used(qt_app):
|
||||
"""R6: the spin boxes are disabled in automatic mode; the legend must not be."""
|
||||
from orisys_app_shared.scene3d.contracts import RenderStats
|
||||
|
||||
view = _stale_scene_view(qt_app)
|
||||
try:
|
||||
panel = view.panel
|
||||
assert panel is not None
|
||||
from orisys_app_shared.scene3d.contracts import FieldDescriptor
|
||||
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
# The real path: the widget emits stats, the view forwards them to the panel.
|
||||
view._on_stats(
|
||||
RenderStats(
|
||||
displayed_frames=9,
|
||||
scale_lower=0.25,
|
||||
scale_upper=7.5,
|
||||
scale_mode="automatic",
|
||||
)
|
||||
)
|
||||
legend = panel.legend_label.text()
|
||||
assert "0.25" in legend and "7.5" in legend, legend
|
||||
assert t("scene3d.legend_auto") in legend, legend
|
||||
assert panel.scale_bar.isVisible() or panel.scale_bar.parent() is not None
|
||||
assert "flow_magnitude" not in legend
|
||||
described = view.describe()
|
||||
assert described["effective_scale"]["lower"] == 0.25
|
||||
assert described["effective_scale"]["upper"] == 7.5
|
||||
assert described["effective_scale"]["mode"] == "automatic"
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
|
||||
def test_reset_goes_back_to_the_asset_default_view_not_to_a_zeroed_camera(qt_app):
|
||||
"""The first picture and "reset" must be the same view, or they disagree."""
|
||||
view = _stale_scene_view(qt_app)
|
||||
try:
|
||||
assets = view.assets
|
||||
assert assets is not None and assets.camera_presets
|
||||
preset = assets.camera_presets[0]
|
||||
view.apply_preset(preset.name)
|
||||
expected = view._camera.matrices((320, 240)).view.copy()
|
||||
# Move away from it the way the mouse does.
|
||||
view._camera.orbit((30.0, 12.0), (320, 240))
|
||||
view._camera.zoom(1.5)
|
||||
assert not np.allclose(
|
||||
view._camera.matrices((320, 240)).view, expected
|
||||
), "the camera never moved"
|
||||
view.reset_view()
|
||||
assert np.allclose(view._camera.matrices((320, 240)).view, expected), (
|
||||
"reset did not return to the asset's own default view"
|
||||
)
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------- stage 5: the record block
|
||||
def test_a_recording_says_what_the_3d_pane_was(
|
||||
make_window, next_record_root, write_poc, qt_app, monkeypatch
|
||||
|
||||
Reference in New Issue
Block a user