feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)
- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source` 只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置, `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因), `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的 第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。 - Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的 "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。 - Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、 adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项; shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败 (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐; 记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同; 生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。 - Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度); ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5); 仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
This commit is contained in:
@@ -377,3 +377,184 @@ def test_the_probe_reports_this_machine_truthfully():
|
||||
assert context["profile_used"] == "desktop33"
|
||||
assert not context["software_renderer"], "the dev machine has a real GPU"
|
||||
assert report["renderer_initialized"] and report["released"]
|
||||
|
||||
|
||||
# ------------------------------------------------- stage 4: the assembled view
|
||||
class _Packet:
|
||||
"""The parts of an ``SdkPacket`` the finger adapter and the identity need."""
|
||||
|
||||
def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None:
|
||||
self.session_id = 1
|
||||
self.frame_id = int(frame_id)
|
||||
self.mono_t = float(frame_id) / 30.0
|
||||
self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32)
|
||||
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def assembled_view(qt_app):
|
||||
"""A real ``Scene3DView``: pane, coordinator, composer, widget, real context."""
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
view = Scene3DView()
|
||||
if not view.available:
|
||||
pytest.skip(f"no GL view here: {view.reason}")
|
||||
assets = synthetic.poc_scene()
|
||||
if not view.use_scene(assets, source_id="poc-live"):
|
||||
pytest.skip("the synthetic scene was refused")
|
||||
view.resize(360, 300)
|
||||
view.show()
|
||||
view.set_enabled(True)
|
||||
renderer = view.gl_widget._renderer
|
||||
painted = _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
||||
if not painted:
|
||||
view.close()
|
||||
pytest.skip(f"no usable GL context here: {view.gl_widget.diagnostic()}")
|
||||
yield view, renderer, assets
|
||||
view.close()
|
||||
view.close()
|
||||
qt_app.processEvents()
|
||||
|
||||
|
||||
def test_the_assembled_view_draws_what_the_acquisition_thread_hands_it(assembled_view, qt_app):
|
||||
"""The whole stage-4 path: packet in, pixels out, and no recording involved."""
|
||||
view, renderer, assets = assembled_view
|
||||
widget = view.gl_widget
|
||||
for index in range(30):
|
||||
view.submit_packet(_Packet(index, assets.shape_hw, value=0.4 + index * 0.05))
|
||||
view.tick()
|
||||
qt_app.processEvents()
|
||||
assert _pump(qt_app, widget, lambda: view.state() in ("live", "no_data"), timeout_s=5.0)
|
||||
assert view.state() in ("live", "no_data"), view.state()
|
||||
stats = widget._last_stats_at and None # stats are published on a timer
|
||||
del stats
|
||||
assert view.last_descriptor is not None, "the pane never learned what it is showing"
|
||||
assert view.last_descriptor.quantity == "flow_magnitude"
|
||||
assert view.last_descriptor.quality == "approximate"
|
||||
drawn = _drawn(_render(widget, qt_app))
|
||||
assert drawn.size, "the live frame was not drawn"
|
||||
|
||||
|
||||
def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app):
|
||||
"""The measured budget: the acquisition thread's own cost, with a slow composer.
|
||||
|
||||
This is the stage-3 property asserted through the *application's* entry point, so
|
||||
it includes the adapter (the flow magnitude over the whole grid) and not just the
|
||||
mailbox. The composer is slowed to 50 ms, which is far below any real frame rate:
|
||||
frames must drop in the display and nowhere else.
|
||||
"""
|
||||
import time
|
||||
|
||||
view, _, assets = assembled_view
|
||||
coordinator = view.coordinator
|
||||
real_composer = coordinator._composer
|
||||
|
||||
class Slow:
|
||||
def compose(self, *args, **kwargs):
|
||||
time.sleep(0.05)
|
||||
return real_composer.compose(*args, **kwargs)
|
||||
|
||||
def rest_frame(self, *args, **kwargs):
|
||||
return real_composer.rest_frame(*args, **kwargs)
|
||||
|
||||
coordinator._composer = Slow()
|
||||
try:
|
||||
samples: list[float] = []
|
||||
for index in range(200):
|
||||
packet = _Packet(index, assets.shape_hw, value=0.5)
|
||||
started = time.perf_counter()
|
||||
view.submit_packet(packet)
|
||||
samples.append((time.perf_counter() - started) * 1000.0)
|
||||
view.tick()
|
||||
ordered = sorted(samples)
|
||||
p99 = ordered[int(len(ordered) * 0.99) - 1]
|
||||
assert p99 < 1.0, f"submit p99 was {p99:.2f} ms with a 50 ms composer"
|
||||
assert max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms"
|
||||
assert coordinator.superseded_frames > 0, (
|
||||
"a 50 ms composer produced no display drops: the frame rate limiter or the "
|
||||
"mailbox is not doing its job"
|
||||
)
|
||||
finally:
|
||||
coordinator._composer = real_composer
|
||||
# Drain the in-flight compose that the slow composer left behind.
|
||||
deadline = time.perf_counter() + 1.0
|
||||
while time.perf_counter() < deadline and coordinator._inflight is not None:
|
||||
coordinator.tick()
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
|
||||
"""A range taken from the data, and a fixed range that ignores it."""
|
||||
view, renderer, assets = assembled_view
|
||||
widget = view.gl_widget
|
||||
assets_frame = view.assets
|
||||
assert assets_frame is not None
|
||||
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
|
||||
|
||||
def frame_with(values: np.ndarray) -> RenderFrame:
|
||||
return RenderFrame(
|
||||
identity=FrameIdentity("poc-live", 1, 1, 0.0, generation=view.coordinator.generation),
|
||||
asset_hash=assets.asset_hash,
|
||||
geometry_id=assets.geometry_id,
|
||||
sensing_part_id=assets_frame.sensing_part().part_id,
|
||||
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
|
||||
normals_sensor=assets_frame.sensing_part().normals_sensor,
|
||||
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32),
|
||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
)
|
||||
|
||||
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
||||
auto = DisplaySettings(
|
||||
housing_visible=False,
|
||||
color_scale=ColorScale(
|
||||
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential", mode="automatic"
|
||||
),
|
||||
)
|
||||
fixed_wide = DisplaySettings(
|
||||
housing_visible=False,
|
||||
color_scale=ColorScale(
|
||||
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
|
||||
),
|
||||
)
|
||||
ramp = np.linspace(3.0, 7.0, count)
|
||||
|
||||
widget.set_settings(fixed_wide)
|
||||
widget.present(frame_with(ramp))
|
||||
assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0
|
||||
flat = len(np.unique(_drawn(_render(widget, qt_app)), axis=0))
|
||||
widget.set_settings(auto)
|
||||
widget.present(frame_with(ramp))
|
||||
spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0))
|
||||
assert spread > flat, (
|
||||
"an automatic range did not use the frame's own values "
|
||||
f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})"
|
||||
)
|
||||
|
||||
|
||||
def test_the_pane_releases_its_context_and_survives_a_second_close(qt_app):
|
||||
"""`closeEvent` calls this; a second close must be a no-op, not a crash."""
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
view = Scene3DView()
|
||||
if not view.available:
|
||||
pytest.skip("no GL view here")
|
||||
if not view.use_scene(synthetic.poc_scene(), source_id="poc-close"):
|
||||
pytest.skip("the synthetic scene was refused")
|
||||
view.resize(200, 160)
|
||||
view.show()
|
||||
view.set_enabled(True)
|
||||
renderer = view.gl_widget._renderer
|
||||
if not _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0):
|
||||
view.close()
|
||||
pytest.skip("no usable GL context here")
|
||||
view.close()
|
||||
assert not renderer.is_initialized
|
||||
assert not renderer.part_ids, "closing the pane left GPU buffers behind"
|
||||
view.close()
|
||||
view.close()
|
||||
qt_app.processEvents()
|
||||
|
||||
Reference in New Issue
Block a user