Files
orisys-app/docs/architecture/studio-architecture.md
T
ykcheungab b37c861254 文档:刷新架构报告的指标与结论(app_window 拆分已落地)
- What: `docs/architecture/studio-architecture.md` 的数字与结论按当前源码重扫后刷新:
  - 分层图/纯文本图:`app_window` 3044 → **2309 行 / fan-out 37**,并新增一层
    "②b Studio 控制器"(session_controller 437 / replay_controller 641 / view_layout 398 /
    display_prefs 227);领域服务与共享层的行数一并更新(replay 291、calibration_service 194、
    coordinator 619、camera 238、view 757、asset_source 256、gl_backend 870;
    shared scene3d 4565 / acquisition 2627 / record 3008 / force 3057 / 基础设施 1507)。
  - 边界规则:模块数 89(studio 32 + shared 57)→ **94(studio 37 + shared 57)**,环 0 个不变。
  - 指标表:新增 `replay_controller`(641) 与 `session_controller`(437) 两行,并写明
    "app_window 的 fan-out 仍是最高(37),因为它本来就是装配器——但规则已经不在它里面"。
  - 生产面/验证面:studio 生产 14 655 行、shared 14 764、测试 15 639(shared 7226/334 项 +
    studio 8413/310 项)、工具 4046(ui_check 3401);验证面 19.7k > 生产面 14.7k。
  - 评估第 1 条与"一句话结论"改写为:上帝对象已按职责拆开,并**诚实记账**留下的东西——
    约 40 个过渡转发(给 tests/`ui_check` 的桥,属过渡层)、仍留在窗口的预览与 2D 渲染
    (它们同时属于面板与相机),以及 fan-out 从 36 升到 37 的原因。
  - 建议表第 1 条标记为已完成(写清五个去处与新增 41 项控制器单测),新增第 1b 条
    "下次改动顺手删掉对应转发",并补第 7 条"真机与 ARM 验收"(本机无设备/板卡,是当前最大空白)。
- What: `studio-architecture.svg` / `.png` 由 `.repro/make_studio_svg.py` 重新生成:
  窗口框改为"2309 行 · 158 方法 · fan-out 37 · 装配 + 信号转发",右侧四个控制器小框,
  面板/服务/共享层/验证面的行数与测试项数同步;底部结论改为"已按职责拆开 + 剩下的胶水"。
  顺带修好 `svg_to_png.py` 里写死的 1560×1180(现在读 SVG 自己的尺寸),否则海报加高后会被裁掉。
- Why: 这份报告是"判断组织是否合理"的依据,数字或结论一旦过期就会误导决策;
  拆分改变最大的一处正是它第 1 条风险与第 1 条建议,必须同步。
- Verification: 图表脚本重跑无异常(SVG well-formed,PNG 1560×1210 已目视检查);
  同批源码的验证为 pytest 644 passed / 0 failed / 15 skipped(offscreen)、
  tools/ui_check.py 587 项 0 失败、.repro/locale_check.py 0 缺失。
- Not in this change: 不改代码;不改 `.repro` 下的扫描脚本口径(仍以扫描输出为准)。
2026-09-15 10:02:37 +08:00

320 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Studio 软件架构(测量版)
本文不是"理想架构",而是**当前代码的真实依赖结构**:所有框图、数字与结论都由下面的脚本从源码里扫出来,
每条判断后面都跟着它的证据。配套的矢量图是 `studio-architecture.svg`(浏览器直接打开)。
生成方式(可复现,改完代码重跑即可):
```powershell
$env:PYTHONPATH = 'D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src'
& $py D:\orisys_ws\.repro\studio_arch_scan.py # 模块清单、包级依赖、环、Qt/GL/SDK 归属
& $py D:\orisys_ws\.repro\studio_arch_imports.py # 哪些模块在 import 时就拉进 Qt/GL/SDK,哪些是惰性
```
统计口径:行数含空行;`fan-in` = 有多少模块 import 它;`fan-out` = 它 import 了多少模块(同一模块多次
import 只算一条边)。
---
## 1. 分层视图(真实依赖方向)
```mermaid
graph TB
subgraph L0["① 入口"]
MAIN["main.py<br/>109 行 · QApplication + 主题 + 首选项"]
end
subgraph L1["② 窗口外壳(只有它能看见全部 UI)"]
WIN["app_window.py · StudioMainWindow<br/>2309 行 · 158 个方法 · fan-out 37"]
BAR["brand_bar · toolbar · record_status"]
end
subgraph L15["②b Studio 控制器(一个变更驱动一个文件)"]
SESS["session_controller.py<br/>会话与相位 · 437 行"]
RPLC["replay_controller.py<br/>回放策略 · 641 行"]
LAY["view_layout.py<br/>面板布局 · 398 行"]
DISP["display_prefs.py<br/>显示偏好 · 227 行"]
end
subgraph L2["③ 面板(各自的画布与控件)"]
ARROW["ui/arrow_panel<br/>2D 图像 · 519 行"]
MESH["ui/mesh3d_panel<br/>3D 宿主容器 · 405 行"]
PLOTS["ui/force_plots<br/>三轴曲线 · 1243 行"]
CAL["ui/cal_panel<br/>标定表 · 793 行"]
DLG["sensor_dialog · plot_settings_dialog · cal_point_dialog"]
end
subgraph L3["④ Studio 领域服务(无 Qt 的与有 Qt 的分开)"]
REC["recording.py<br/>记录协调 · 267 行"]
REP["replay.py<br/>回放引擎 · 291 行(QObject)"]
CALSVC["calibration_service.py<br/>标定文件与归属 · 194 行 · 无 Qt"]
COORD["scene3d/coordinator.py<br/>619 行 · **无 Qt**"]
CAM["scene3d/camera.py<br/>238 行 · **无 Qt**"]
VIEW["scene3d/view.py<br/>757 行 · 3D 唯一入口"]
ASSET["scene3d/asset_source.py<br/>256 行 · 场景加载线程"]
GLB["scene3d/gl_backend.py<br/>870 行 · **GL 只在运行时惰性 import**"]
end
subgraph L4["⑤ 共享领域层(采集/记录/力/三维契约,无 Qt 依赖)"]
AQ["acquisition<br/>2627 行 · worker / packet / sinks / 图像后端"]
RC["record<br/>3008 行 · 录像读写 / CSV / session.json"]
FORCE["force<br/>3057 行 · 力曲线源 / 标定 / 滤波"]
S3D["scene3d<br/>4565 行 · 契约 / 采样 / 合成 / 包格式 / 签名"]
MISC["camera · prefs · paths · i18n · theme · logging"]
end
MAIN --> WIN
WIN --> BAR
WIN --> SESS
WIN --> RPLC
WIN --> LAY
WIN --> DISP
WIN --> ARROW
WIN --> MESH
WIN --> PLOTS
WIN --> CAL
WIN --> DLG
MESH --> VIEW
VIEW --> COORD
VIEW --> ASSET
VIEW --> GLB
VIEW --> CAM
COORD --> S3D
VIEW --> S3D
ASSET --> S3D
WIN --> REC
WIN --> REP
WIN --> CALSVC
REC --> RC
REP --> RC
CALSVC --> FORCE
WIN --> AQ
WIN --> FORCE
WIN --> RC
PLOTS --> FORCE
CAL --> FORCE
ARROW --> AQ
MESH --> S3D
```
纯文本版(无 Mermaid 渲染器时看这个):
```
main.py
└─ app_window.py ──┬─ 外壳: brand_bar / toolbar / record_status
├─ 控制器: session_controller · replay_controller · view_layout · display_prefs
├─ 面板: arrow_panel(2D) · mesh3d_panel(3D) · force_plots · cal_panel · 对话框
├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys}
└─ 共享层(无 Qt): acquisition · record · force · scene3d · camera/prefs/paths/i18n/theme/logging
mesh3d_panel → scene3d/view (3D 的唯一入口)
view → { coordinator(无 Qt) , asset_source(线程) , gl_backend(惰性 GL) , camera(无 Qt) , widget }
coordinator → composer(共享) → contracts(共享)
```
## 2. 运行时与线程视图
```mermaid
graph LR
subgraph GUI["GUI 线程(Qt 事件循环)"]
W["StudioMainWindow"]
T1["运行定时器 16 ms<br/>_on_run_tick"]
T2["回放定时器 33 ms<br/>_on_play_tick"]
T3["预览定时器"]
PANELS["面板:2D / 3D / 曲线 / 标定"]
end
subgraph ACQ["采集线程(每会话一条)"]
FW["FingerAcquisitionWorker / BoxWorker"]
PKT["SdkPacket"]
end
subgraph RECW["记录写线程"]
EXP["ExperimentRecorder<br/>视频 + forces.csv + session.json"]
end
subgraph S3DW["三维合成线程(1 条)"]
CMP["SceneComposer.compose"]
end
subgraph S3DA["场景加载线程(1 条)"]
LOAD["SceneAssetLoader<br/>读包 + 验签 + 设备几何校验"]
end
FW --> PKT
PKT -->|记录 sink| EXP
PKT -->|UI sink<br/>submit_packet| W
W -->|ImageFrame→image_backend| PANELS
W -->|"submit(容量 1 mailbox,尝试不阻塞)"| CMP
CMP -->|结果在 GUI 线程校验 epoch| W
LOAD -->|loaded 信号| W
T1 --> W
T2 --> W
T3 --> W
W --> PANELS
```
关键性质(都有测试钉住):
- **采集线程永不等待显示**:`submit` 只做身份/形状的廉价校验,容量 1 的 mailbox 由后来的帧替换前者;
生产者还会在"已有一帧在等"时干脆不做那次 O(H×W) 的拷贝(`skipped_producer`)。
- **合成线程与加载线程各只有 1 条**,且同时最多一次合成在飞行;结果必须通过 `epoch` + 身份(source /
session / generation / request_id)校验才会被 GUI 线程接受。
- **记录通道与显示通道共用同一个 packet,但计数完全分开**:显示丢帧(`superseded`)永不进记录。
## 3. 二维 / 三维显示子系统(最近两轮重构后的样子)
```mermaid
graph TB
subgraph SRC["生产者(4 条)"]
LIVE["实时 packet.processed_frame + flow + depth"]
RECOMP["重算:同一采集链跑在录像上"]
REPLAY["回放:ReplayEngine.video_frame_bgr() 原始帧"]
PREV["预览:摄像头原始帧"]
end
IB["image_backend.py(共享)<br/>ImageFrame → plan_image() → render_image()"]
PANEL2D["ui/arrow_panel<br/>模式集/注解来自 plan"]
AP["packet → adapters → coordinator(mailbox+epoch)→ SceneComposer(线程)"]
VIEW3D["scene3d/view<br/>describe() / render_totals() / capabilities()"]
PANEL3D["scene3d/panel(参数+图例+色条)"]
GLW["scene3d/widget → gl_backend(GL 惰性)"]
LIVE --> IB
RECOMP --> IB
REPLAY --> IB
PREV --> IB
IB --> PANEL2D
LIVE --> AP
RECOMP --> AP
AP --> VIEW3D
VIEW3D --> GLW
VIEW3D --> PANEL3D
```
两条显示路径**互不干扰**:2D 是一条纯数据流水线(`ImageFrame → 一个渲染函数 → 一个 QImage`),
3D 是"契约 + 合成线程 + GL"的另一条。它们的共同上游只是同一个 `SdkPacket`。
## 4. 边界规则与证据
| 规则 | 是否成立 | 证据 |
|---|---|---|
| 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** |
| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PyQt5 的 21 个模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PyQt5 **画图标时**才 import(函数内 + try 保护) |
| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 |
| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PyQt5/OpenGL/SDK |
| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" |
| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 |
| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO |
| 契约集中在一处 | ✅ | `orisys_app_shared.scene3d.contracts`:1185 行、**fan-in 22**(全仓最高),fan-out 0(只依赖 numpy) |
## 5. 指标
**改动最贵的地方(行数 × 扇出)**
| 模块 | 行数 | fan-out | 说明 |
|---|---:|---:|---|
| `orisys_studio.app_window` | 2309 | **37** | 158 个方法,其中约 40 个是给测试/`ui_check` 的一层转发;装配 + 信号转发 |
| `orisys_studio.ui.force_plots` | 1242 | 8 | 曲线、量程、光标、概览、单位徽章都在一个类里 |
| `orisys_studio.scene3d.gl_backend` | 870 | 2 | 着色器、打包、纹理、统计;被 4 个模块依赖 |
| `orisys_studio.ui.cal_panel` | 792 | 7 | 标定表 + 采样 + 拟合入口 |
| `orisys_studio.scene3d.view` | 757 | 8 | 3D 的唯一入口(刻意的) |
| `orisys_studio.replay_controller` | 641 | 5 | 回放策略(本轮从 app_window 拆出) |
| `orisys_studio.scene3d.coordinator` | 619 | 3 | 三维调度(**无 Qt**) |
| `orisys_studio.session_controller` | 437 | 3 | 会话/相位/遥测(本轮从 app_window 拆出) |
`app_window` 的 **fan-out 仍然最高(37)**,因为它本来就是装配器:它 import 的模块多,
但**规则**已经不在它里面——单次功能改动落在 `session_controller`(会话)、
`replay_controller`(回放)、`view_layout`(布局)、`display_prefs`(显示偏好)或
`calibration_service`(标定)之一,而不是"改哪都要动同一个 2300 行文件"。
**稳定核心(被依赖最多)**:`scene3d.contracts`(22) > `i18n`(16) > `paths`(11) = `theme`(11) >
`record.sample`(8) > `acquisition.models`(5) = `force.filters`(5) = `force.session`(5) = `scene3d.digest`(5)…
**验证面**:共享库测试 22 文件 / 7226 行 / 334 项;应用测试 15 文件 / 8413 行 / 310 项;工具
`ui_check.py` 3401 行(587 项 UI 断言)、`bench_scene.py`、`soak_scene.py`、`validate_scene.py`、
`ui_snapshot.py`、`probe_gl.py`。
**生产面 vs 验证面**(同一口径:含空行):
| | 行数 | 其中三维 |
|---|---:|---:|
| `apps/studio/src`(生产,37 模块) | 14 655 | — |
| `shared`(生产,57 模块,无 Qt) | 14 764 | 4 565 |
| 测试(shared 7226 + studio 8413) | 15 639 | — |
| 工具(ui_check 3401 + scene_assets 工具 645) | 4 046 | — |
即**验证面(19.7k 行)比 studio 的生产代码(14.7k 行)还大**。共享层里记录 3008、力 3057、采集 2627 行,
三维 4565 行——三维不是附属功能,它和"记录/力"是同一量级的子系统。
## 6. 评估:合理与不合理的地方
### 已经合理、值得保住的
1. **依赖无环 + 分层单向**(扫描证据)。这不是小事:有环的项目才会出现"改 A 必须改 B 再改回来"的
连锁;这里没有。新增模块时只要不反向 import 就不会破坏。
2. **共享层与 UI 的物理隔离**:`shared` 里 `import` 时不拉 Qt/GL,采集线程、CI、离线工具都能在无 GUI
环境跑;`contracts` 甚至只依赖 numpy。
3. **三维子系统的接口是显式的**:`describe()`(我是什么)/`render_totals()`(显示了什么)/
`capabilities()`(数据源能给什么)/`set_plan()`(这一帧能画什么)。窗口只调这四个,不碰渲染细节——
这几轮重构主要就是在把隐式耦合换成这四条缝。
4. **降级路径是设计出来的,不是碰巧**:无 GL / 无资产 / 几何不符 / 字段失效 / 场景被平台锁拒绝,
每一种都只降级三维或某个面板,2D 与记录不受影响(有专门测试)。
5. **危险的知识点集中在有测试的地方**:单位与坐标系(`legacy.py` + Procrustes 残差)、包格式与签名
(`package.py`/`protect.py`)、采样无效语义(`sampling.py`)——这些最容易静默出错的部分都有
输入样例 + 失败样例。
### 不合理、会随时间变贵的地方(按严重程度)
1. **`app_window.py` 曾经是上帝对象(已拆)**:拆之前 3044 行 / 158 方法 / fan-out 36,
四个角色(视图装配、会话与 worker 生命周期、业务决策、持久化与诊断)都在一个文件里。
现在它 2309 行 / fan-out 37,规则分到四个控制器 + 标定服务:
`session_controller`(谁在跑、哪一相位、报了什么数)、`replay_controller`(回放策略与重算)、
`view_layout`(哪些面板、什么布局、哪个视图)、`display_prefs`(单位/量程/箭头/fps)、
`calibration_service`(标定文件与"这份标定是谁的")。
**仍然留在窗口里的**:装配与信号转发、菜单/对话框、状态栏与轨道、
预览(`_start_preview`/`_stop_preview`/`_on_preview_tick`)与 2D 渲染
(`_show_image_frame`/`_render_run_image`/`_depth_range`)——它们同时属于面板与相机,
目前没有第二个变更驱动,硬拆只会把耦合搬家。
**代价与风险(诚实记账)**:为了让 tests 与 `tools/ui_check`(它们通过窗口驱动 UI)
保持全绿,窗口保留了一层转发(约 40 个成员)。它们是**过渡层**,不是最终形态:
下一次改动落在某个控制器时,应顺手把对应的转发与测试调用点改成 `self.<controller>.*`。
另外窗口的 fan-out 反而从 36 升到 37(它多 import 了四个控制器)——这正是"装配器"
应有的样子:它知道所有人,但不再替所有人做决定。
2. **三个"panel"名字打架**:`ui/arrow_panel`(2D 面板)、`ui/mesh3d_panel`(3D 宿主容器)、
`scene3d/panel`(3D 参数控件)。新人第一次找"面板加个开关"要在三个文件里试。同类问题还有
`scene3d/replay.py`(studio 回放引擎) 与 `shared/record/playback.py`(录像读取)。
3. **两套 `scene3d` 包**:`orisys_app_shared.scene3d`(契约/采样/合成/包,无 Qt)与
`orisys_studio.scene3d`(coordinator/view/panel/widget/GL)。边界理由是"Qt/GL 不进共享层",
但 `coordinator` 其实无 Qt(已实测),放在 studio 只是因为"它服务 GUI 计时器"——这条理由不硬。
4. **共享层 `force` 硬依赖 SDK**:`calibration_io` 在 import 时 `import orisys`,于是"共享层可以脱离
SDK 做单元测试"这条**只对三维成立,对力不成立**(力相关的测试必须装 SDK)。这也解释了为什么
离线工具与力曲线测试总是整套跑。
5. **`ui_check.py` 3401 行单文件**:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它,
且它引用了不少产品私有属性/组件名。它同时是**最有价值**的回归门和**最脆**的资产。
6. **测试对私有成员的依赖**:`tests` 里直接读 `view._camera`、`widget._renderer`、`coordinator._inflight`、
`loader._pending`、`panel._descriptor` 等。接口一改就要改测试(这几轮已经通过
`attach_view` / `describe` / `render_totals` / `_snapshot_diagnostic` 之类的公开缝减少了一批)。
7. **验证面 > 生产面**:19.7k 行测试/工具 vs 14.7k 行 studio 生产代码。好处是回归门硬;代价是每次改接口
的成本被放大,尤其是 `ui_check` 里的字符串/布局断言(拆 `app_window` 时为保住这套门而留的转发,就是这个代价)。
### 一句话结论
**组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方)。
原来唯一的明确单点 `app_window.py` 已按职责拆成
`session_controller` / `replay_controller` / `view_layout` / `display_prefs` + 标定服务,
窗口退回装配与转发;每个新模块都有自己的公开接口单测(会话元数据 15 + 四个控制器 37 +
标定服务 4 = 56 项),且在不需要窗口的情况下可测。
下一步的性价比最高项因此换成了下面第 2 条(把"现在对"变成"以后不会错")与第 5 条
(最脆的资产 `ui_check.py` 现在是 3401 行单文件)。
## 7. 建议(按性价比排序)
| # | 建议 | 收益 | 成本/风险 | 回归门 |
|---|---|---|---|---|
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PyQt5/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
| 6 | **测试私有访问清单化**:把所有 `obj._x` 访问集中到 `tests/support/` 一层小适配器(拆 `app_window` 时保留的转发就是为了不一次性打碎它们) | 内部重构不再一次性打碎几十个测试 | 小-中 | 全套 |
| 7 | **补上真机与 ARM 的验收**(本机无设备、无板卡):清零/标定确认、`VRAW` 链失败后的状态、2 小时 GUI 长时、Jetson Orin Nano / QCS6490 报告 | 把"代码上成立"变成"设备上成立"——这是当前最大的空白 | 中(要设备与板卡) | 阶段 9 的验收清单 |
不建议做的事:**不要**为了"看起来分层漂亮"把 `scene3d` 从 studio 搬进 shared(第 3 条第 5 项理由不硬,
但现在搬动没有收益,反而打断三维子系统已经稳定的接口);也**不要**为了统一而重写 `ui_check`。
---
_本文与图由源码扫描生成;扫描脚本在 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py`
(图表脚本 `.repro/make_studio_svg.py` → `.repro/svg_to_png.py`),原始数据在 `.repro/studio_arch.json`、
`.repro/studio_arch_imports.json`。数字口径:行数含空行,`fan-out` = 该模块 import 了多少个模块。_
_生成时间:拆分 `app_window`(A1/A2 + B + C1 + C2 + 标定收尾 + 清零确认修复)之后。_