Files
orisys-app/apps/studio/tests
ykcheungab d06e116c15 fix(studio-3d): 取消 epoch —— 停止/隐藏/换会话之后,已在跑的后台合成不能再把旧测量放回屏幕
- What:
  1. `ThreeDCoordinator` 新增 **epoch**(显示声明代次)与 `_Inflight` 记录:`work()` 不再直接写
     `_composed` mailbox,只 `return` 合成好的帧;结果在 GUI 线程的 `_collect_inflight()` 里被接受,
     且必须同时满足 `item.epoch == self._epoch` 与 `_identity_is_current(identity)` 才会入队。
     `activate()`/`begin_session()`/`set_visible(False)`/`show_rest()`/`close()` 统一走新的
     `_invalidate()`:epoch+1、清空两块 mailbox、清 `_last_request_id`/`_last_composed_identity`/
     `_last_field_frame`/`_settings_dirty`,并**尝试**取消 future。
  2. **未成功取消的 future 仍占用唯一合成槽**:`_inflight` 只在 `future.done()` 时由 `_collect_inflight()`
     释放,所以反复隐藏/显示不会在线程池里排起第二个合成;`CancelledError` 单独吞掉(取消不是失败,
     不产生诊断),其它异常仍记成 `field_unavailable` 诊断(文案由 "compose raised" 回到 "compose failed")。
  3. **request_id 单调**:`submit()` 拒绝小于本 epoch 已见值的 request_id(>0 时)并计 `stale` + 给
     `stale_frame` 诊断;`_identity_is_current()` 同样比较,所以"晚到的旧结果"也进不了屏幕。
  4. 新增 `ThreeDCoordinator.epoch` 与 `last_presented()`(帧号 + 单调时刻,供后续面板帧计数用);
     构造参数新增关键字 `clock`(默认 `time.monotonic`)与 `stale_grace_s`(默认 1.0,本提交尚未使用)。
- Why: 审查报告 P1/R1 的第一条。`Future.cancel()` 无法中断已经开始运行的函数,而旧代码在
  `_cancel_inflight()` 里就把 `_inflight` 置空并只比较 generation —— 于是"停止显示"之后旧 work 结束仍然
  `offer` 进 `_composed`,下一次 tick 取到它并把状态改回 live(审查用可控后台任务复现:
  `valid_after_show_rest=false` → 放行旧 compose → `valid_after_old_worker_finishes=true`、`state=live`)。
  同一个洞也适用于"停止重算后定时器仍继续 tick"的场景。把接受点移到 GUI 线程后,"发布前校验"和
  "接收时校验"是同一处代码,epoch 让"这一刻的显示声明"成为可比较的事实。
- Verification:
  · `test_scene3d_coordinator.py` +7(24 通过):合成已进入后被 `show_rest`/`set_visible(False)`/
    `begin_session` 撤销 → 旧结果永不 present 且 state 不为 live、epoch 递增、槽位在 work 真正结束后释放;
    `ManualExecutor` 复现"提交后立即取消" → `CancelledError` 不产生诊断且槽位可复用;
    20 轮 begin_session→submit→tick→show_rest 循环里最后一个画面上永远没有有效测量、`max_in_flight==1`;
    request_id 逆序在入口被拒(`stale` + `stale_frame`)且画面停在较新的请求上,
    "较旧请求的结果晚到"也在接收侧被丢弃。
  · 审查复现脚本对照(`.repro/probe_stage6_r1.py` → `probe_stage6_r1.json`):
    `stopped_frame_returns` 由 `{valid_after_old_worker_finishes: true, state: "live"}` 变为
    `{false, "no_data", epoch_bumped: true, slot_free_again: true}`;
    `request_id_not_checked` 由 `{old_submit: "accepted", shown_request: 1}` 变为
    `{old_submit: "stale", shown_request: 2, diagnostic: "stale_frame"}`。
  · apps/studio 全量 pytest 退出码 0(含真 GL 冒烟用例)。
- Not in this change: `stale_grace_s`/`clock` 只落地参数与 `last_presented()` 的记录,**失效状态的迁移
  (`stale` 状态、宽限到期回参考模型)留给下一提交**;adapter 侧的字段级有效性、设备几何身份、
  资产加载请求号、二维后端统一均未动;审查报告里那句"用 `coordinator._inflight` 当 Future 用"的复现
  脚本无法再原样运行(槽位现在是 `_Inflight` 记录),改用上面那份等价探针。
2026-09-14 20:53:38 +08:00
..
2026-09-12 14:03:00 +08:00
2026-09-13 14:01:24 +08:00
阶段 1:平台 PoC —— 真实 GL 上跑通静态场景,并修掉它抓出的相机/上下文错误 - What: 按 docs/architecture/studio-3d-stages.md 阶段 1 落地图形栈。OrbitCamera 全部实现(target/distance/yaw/pitch 表示;fit 按包围球与较窄的那个视场轴定距离;orbit/pan/zoom 有界;set_preset 从 eye/target 反解角度使拖动可延续;matrices 输出只读 float32 view/projection;另加 depth_range_m() 便于断言)。gl_backend:GLDispatch.bind_current_context 真正绑定 PyOpenGL 并读 vendor/renderer/GL/GLSL/max texture、判软件渲染、profile 不达标时抛 ThreeDUnsupported 并带回能力;GLRenderer 实现 initialize(两个 program,desktop33/gles30 共用同一份着色器主体、只换头部)、upload_scene(每 part 一套 VAO/VBO/EBO,交错 layout position/normal/scalar/valid = 8 float)、update_surface(只对感知面 glBufferSubData,绝不重建索引)、draw(绑 Qt 的 defaultFramebufferObject、深度测试、按 role 选 program、housing 隐藏即跳过、scalar 经 LUT 纹理与 range uniform 上色,Python 不生成 RGB 数组)、release(幂等);纯函数 pack_part/pack_scene_part/estimate_gpu_bytes/rest_surface/shader_source/shader_header/lut_rgba/is_software_renderer/profile_from_renderable_type 都可在无 GPU 环境测试。Sensor3DWidget 实现 initializeGL/paintGL/resizeGL/release_resources/aboutToBeDestroyed/showEvent/hideEvent 与鼠标事件;加载场景时先采用资产自带的默认视角再 fit;一帧几何不匹配只"拒绝该帧"(计数 + 诊断 + 用 rest_surface 清掉上次测量)而不是把整个视图打成不可用。shared/scene3d/synthetic.py 提供 PoC 与测试共用的已知场景(20mm 感知板 + 外壳盒,米、sensor 坐标系、带默认三视角预设)。tools/scene_assets/probe_gl.py 真实现:真实窗口取 context 并输出 JSON 报告(平台/Qt/PyOpenGL/vendor/renderer/GL/GLSL/DPR/framebuffer/是否软件渲染),退出码即门禁(0 通过、3 软件渲染、4 无上下文);apps/studio/pyproject.toml 增加 [three-d] 可选依赖(仅 PyOpenGL;Open3D 不进运行时)。测试:apps/studio/tests/test_scene3d_stage1.py 24 项(相机数学、PoC 场景合规、打包布局、着色器两方言共用主体、LUT、软件渲染识别、renderableType 映射、GPU 预算、参考帧语义,全部无需 GPU)+ test_scene3d_gl_smoke.py 8 项(真实 context:能力、两个 part 都画出来、外壳可隐藏、参考帧中性 vs 色图有差异、动态更新不动 housing 缓冲、坏几何帧被拒且视图存活、resize 跟随、release 幂等且新 context 能重传场景、探针如实报告);offscreen 下后者自动 skip。docs/architecture/gl-report-windows-dev.json 为本机平台报告。 - Why: 用户要求"从骨架搭起→计划阶段→逐阶段实现与验收",并明确 ARM 暂缓。阶段 1 的产出本身就是一份能回答"这块板的图形栈能不能用"的报告,所以先把它做实、把 profile 冻结点定在真实 context 上,而不是先堆屏幕上的东西。 - Verification: 真实 context(QT_QPA_PLATFORM=windows)下 8 项 GL 验收全过;offscreen 下 24 项 CPU 项全过、GL 项按设计 skip;shared 全量 187 passed;studio 全量 116 passed(含新 32 项);ui_check 554 项 0 失败;probe_gl 输出 verdict=ok(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、非软件渲染)。过程中这套验收抓出并修掉了 7 个真实缺陷:Qt renderableType 映射反了(NVIDIA 兼容 ES 着色器所以本机看不出,Intel/ARM 会直接失败)、观察相机是左手基导致整模型镜像(det=-1)、近平面过近使 NDC z 全挤在 0.99(深度精度归零、外壳与感知面会 z-fighting)、手动 makeCurrent 早于 Qt 首次绘制会把 GPU 对象建在被丢弃的 context 里(glBindVertexArray 报 INVALID_OPERATION)、context 重建后不重传场景、一帧几何不匹配把整个视图打死、以及 grabFramebuffer 是 BGRA+stride 导致颜色断言全部读错通道。 - Not in this change: 未接入 app_window/main.py(三维对现有应用仍完全惰性,故"开/关三维 CSV 行序一致"这条验收随阶段 4 做);阶段 2 的资产导入/坐标/包格式;ARM 平台实测(probe 已可在板上直接运行)。
2026-09-14 14:55:45 +08:00