界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽

- What(隐藏配置): `ThreeDPanel` 把**所有设置**收进一个块
  (`controls_widget`:总开关、观察方向、显示外壳、放大形变+增益、色标模式与范围、显示帧率、
  复位按钮、显示统计),面板本身只保留读图需要的东西:色条 + 一行紧凑图例(数量/自动色标范围/
  来源)+ 状态行。新增 `scene3d/settings_dialog.py` 的 `Scene3dSettingsDialog`:非模态
  (调参时画面还在动),承载的就是面板那套控件本身(同一批 widget,信号通路不变),
  关闭只是隐藏(控件必须活着,下一次读设置还要用)。
  两个入口:视图菜单新增 `三维显示设置…`(`act_scene3d_settings` → `Mesh3dPanel.open_settings()`)
  与面板标题栏的齿轮;无三维可配时(box 传感器 / 无 GL / 无场景包)菜单项会把面板上的原因
  原样提示出来,而不是弹一个空对话框。标题栏另有「复位视角」图标(新画的 `reset_view` 图标,
  环形箭头,随主题着色)。
- What(提高占比): live/replay 预设把三维面板设为最宽:`VIEW_SPLITTER_SIZES` (620, 300, 520)、
  `DEFAULT_SPLITTER_SIZES` (620, 320, 500)、`DEFAULT_3D_PANE_WIDTH` 340 → 620、
  `MIN_PANEL_WIDTHS["mesh3d"]` 260 → 200(面板里没有表单要放了,画面才是重点)、
  `ViewLayoutController.apply_panel_visibility` 的拉伸权重 2 → 5(2D 3、曲线 4)。
  1440×880 实测:三维约 430 px(原约 280)、2D 约 265、曲线约 320。
- What(拍视频/评审用): `tools/ui_snapshot.py` 新增 `--scene flow3d`:驱动一批**带真实光流场**的
  packets(适配器只接受声明了 provenance 的字段),所以三维面板真的有颜色、曲线也真的在动;
  这是本次界面评审与演示视频的取景脚本。配 `--sensor finger --panels 3d --preset live`。
- What(测试): 新增 `apps/studio/tests/test_scene3d_pane_ui.py`(5 项):面板只有图例没有设置、
  对话框承载的是面板自己的控件且设置信号照旧、关闭只隐藏不销毁、标题栏两个动作可用、
  无场景时 `open_settings()` 返回 False、`reset_view` 转发、`reset_view` 图标两种主题都能画、
  视图菜单项能打开对话框(无 GL 时给原因)。`test_view_layout.py` 增一条"出厂 live 布局里
  三维最宽"的断言(并更正我自己那条 340 的旧期望)。
- 文档: `studio-3d-stages.md` 的「尚未完成」第 1 条(阶段 8 第二批)改写为"部分已做",
  写清已完成的部分与仍留后的四项(页签容器/窄窗口回退/曲线量程开关/独立诊断抽屉)。
- Why: 三维面板原来下半屏全是表单,画面被挤成一小块——拍视频要的是"三维是主角"。
  配置不是不需要,而是不该和画面抢地方;放进对话框后画面变大、参数反而更好找(一处看全)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)650 passed / **2 failed** / 15 skipped,
  两个失败都是 `shared/tests/test_worker_frame_rate.py` 的**墙钟计时**用例(30 fps 设备被测成
  24–25 fps):已在 `d539ce9` 的干净 worktree 上复现同样的失败,属本机负载(DSH/Cursor/飞书 占 CPU)
  而非本次改动;真 GL 用例 `test_scene3d_gl_smoke` + `test_scene3d_view` 61 项 0 失败(1 跳过);
  `tools/ui_check.py` 587 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 6 个 key 双语齐备)。
  目视验收:`--scene flow3d` 渲染的 1440×880 截图(拆分前后各一张)见提交说明附言。
- Not in this change: 不改三维渲染/契约/记录;不做页签容器与窄窗口回退;
  曲线量程仍是出厂 ±20 N(演示取景脚本会临时调窄,产品默认值另议)。
This commit is contained in:
ykcheungab
2026-09-15 20:55:33 +08:00
parent d539ce93ad
commit 650578d738
13 changed files with 629 additions and 53 deletions
+114 -3
View File
@@ -147,6 +147,112 @@ def _run_scene(window, app, scene: str):
app.processEvents()
def _flow_scene(window, app, *, frames: int = 240) -> None:
"""Drive a synthetic session whose frames carry a real optical-flow field.
The 3D pane is coloured by the frame's *own* flow (see
``orisys_studio.scene3d.adapters``): a packet that does not state one is refused,
so a picture with colour needs packets that do. This is the scene the interface is
reviewed from — the real widgets, the real renderer, the real adapter, and a field
shaped like a pressed fingertip (strongest at the centre, fading to the rim).
"""
import time as _time
import numpy as np
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from ui_check import _StuckWorker
stuck = _StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
for _ in range(4):
app.processEvents()
# The unrolled finger domain: 280x300, which is the grid the scene's UV island
# uses. The flow is already unrolled, so the adapter is given no remap.
height, width = 300, 280
grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32)
centre_y, centre_x = height * 0.52, width / 2.0
dy, dx = grid_y - centre_y, grid_x - centre_x
radius = np.sqrt(dx * dx + dy * dy) + 1e-3
profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32)
flow = np.stack(
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
).astype(np.float32)
# A recognisable camera image as well, so the 2D panel is not black next to it.
frame = np.zeros((height, width, 3), np.uint8)
frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8)
frame[..., 2] = (profile * 90.0).astype(np.uint8)
for index in range(frames):
# The forces move: a flat line reads as "the demo is not running", and the
# charts are half of what the video has to show.
phase = index / 30.0
# A period of a couple of seconds, so the trace visibly moves inside the chart's
# rolling window instead of looking like a slow ramp.
normal = 3.4 + 2.4 * math.sin(phase * 2.4)
shear_x = 1.6 * math.sin(phase * 3.1 + 0.4)
shear_y = -1.3 * math.cos(phase * 2.7)
stuck.emit(
SdkPacket(
raw_frame=frame,
processed_frame=frame,
flow=flow,
timestamp=1000.0 + index / 30.0,
mono_t=float(index) / 30.0,
fps=30.0,
process_ms=6.0,
capture_fps=30.0,
session_id=1,
frame_id=index,
valid=True,
raw_available=True,
normal_force=normal,
shear_x=shear_x,
shear_y=shear_y,
normal_force_raw=9.0 + index * 0.1,
shear_x_raw=0.4,
shear_y_raw=-0.4,
force_unit="N",
force_calibrated=True,
calibrated_axes=("fn", "fx", "fy"),
flow_valid=True,
flow_frame_id=index,
depth_valid=None,
depth_frame_id=None,
forces_valid=True,
)
)
# The window's own presenter runs *while* the frames arrive, the way it does in
# a session: the charts then hold a real time series instead of one sample.
if index % 4 == 0:
window._on_run_tick()
app.processEvents()
# The 3D picture is composed on the view's own worker and taken one finished frame
# at a time by the run timer, so pump both until something has actually been shown.
view = window.mesh3d_panel.view
deadline = _time.perf_counter() + 20.0
while _time.perf_counter() < deadline:
window._on_run_tick()
app.processEvents()
if view is not None and view.render_totals().get("shown", 0) > 0:
break
_time.sleep(0.01)
window._run_timer.stop()
# The overview strip is rebuilt at most once a second (it is a whole-session
# decimation) and this scene fills eight seconds of session in well under that, so
# it is rebuilt once here — the same thing ``_demo_data`` does for the same reason.
window.force_plots._overview_dirty = True
window.force_plots._last_overview_at = 0.0
window.force_plots._sync_overview()
for _ in range(6):
app.processEvents()
def _demo_data(window) -> None:
"""Fill the plots, the calibration table and the arrow canvas."""
from orisys_app_shared.force.session import SessionSample, SessionStore
@@ -242,14 +348,16 @@ def main() -> int:
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument(
"--scene",
choices=("idle", "connecting", "recording", "replay"),
choices=("idle", "connecting", "recording", "replay", "flow3d"),
default="idle",
help=(
"connecting renders the Stop/cancel state of a hung device open; "
"recording renders the record strip of a running recording "
"(mocked stats: no file is written, no device is opened); "
"replay writes one short synthetic recording and opens it in the "
"replay view so the progress bar can be reviewed"
"replay view so the progress bar can be reviewed; "
"flow3d drives packets that carry a real optical-flow field, which is "
"what colours the 3D pane (use with --sensor finger --panels 3d)"
),
)
parser.add_argument(
@@ -317,7 +425,10 @@ def main() -> int:
if args.scene == "replay":
_run_scene(window, app, "replay")
if args.demo and args.scene != "replay":
if args.scene == "flow3d":
_flow_scene(window, app)
if args.demo and args.scene not in ("replay", "flow3d"):
_demo_data(window)
for _ in range(4):
app.processEvents()