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:
ykcheungab
2026-09-14 21:35:47 +08:00
parent 7e415c4ae9
commit e2570925e8
10 changed files with 400 additions and 22 deletions
+5 -1
View File
@@ -147,7 +147,11 @@ def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
panel.set_state("ready")
assert panel.state_label.text()
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
assert "flow_magnitude" in panel.legend_label.text()
# 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()
+86
View File
@@ -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