# 操作教程 —— 从零到顺手 > 面向第一次拿到这个包的人:**装上 → 连通 → 会用 → 会排错**,全程约 40 分钟(熟练后 5 分钟)。 > 前置条件(Blender GUI + `DSH Blender Bridge` addon + Node ≥ 20 + DSH)见 [README §2](../README.md#2-前置条件4-条缺一不可)。 > 配置项(工作目录 / blender.exe / 端口 / 租约)见 [配置参考.md](配置参考.md)。 --- ## 0. 心智模型:先理解这 4 件事 1. **两条线并行** - **GUI 直连通道**:TCP `127.0.0.1:9876` → addon;毫秒级、实时,但**要求 Blender 开着**,且所有命令串行占用它。 - **无头进程**:`blender.exe -b` 独立进程;用于重渲染 / 批量几何 / 数据校验,**不占 GUI 通道**,可与上面那条线同时跑。 2. **三种"看"**(都由 `blender_rt_see` 出图,但语义不同) - 视口帧 = **用户正在看的角度**(含视口着色与覆盖物); - 自定义视角 = **你指定的相机**(`from` + `look_at`,不建相机、不动用户视口); - `full=true` = **整个 Blender 窗口**(走 `screenshot_area`,看 UI / 面板 / 其他编辑器)。 3. **内环 vs 外环**:模型回合(几秒)贵,Blender 事件循环(160 tick/s)便宜 → **搜索放 Blender 侧(`blender_rt_loop`),决策与验收放模型侧**。详见 [操作教程.md](操作教程.md)。 4. **并发**:写操作受"租约"保护。默认无感;一旦有别的会话持有,你的写操作会拿到 `409 leased`(只读操作不受限)。 ### 什么时候用哪个工具 | 你要做的事 | 用哪个 | 为什么 | |---|---|---| | 看一眼现在长什么样 | `blender_rt_see` | 50–100 ms,最便宜 | | 换个角度看(不想动用户视口) | `blender_rt_see {from, look_at}` | 零场景改动,85–280 ms,可重复(md5 稳定) | | 改一步就想验证 | `blender_rt_do {see:true}` | 一个原子步 ≈105 ms | | 判断"动没动 / 对不对" | `blender_rt_watch` | 时间窗连续采样 + 逐帧 hash,一次最多 6 帧 | | 搜索 / 拟合参数(几千次迭代) | `blender_rt_loop` | 内环跑,不花模型回合 | | 多目标 + 约束的测量 | `blender_rt_loop` + `penalize` | 罚项把违规量并入单一 score | | 收敛后复核 / 复用 | 换一条通路复算 + `blender_rt_loop op=export` | 防"只优化了你写的目标函数" | | Cycles 成品渲染 / 大批量几何 / 校验 | `blender_rt_headless` | 独立进程:不占 GUI、不撞 CUDA context、超时可杀 | | 离线多角度批量出图 | `blender_rt_headless {preload:"view"}` | 无头进程里直接用 `K.dsh_view_api` | | 渲染慢要调参 | `blender_rt_perf` 先 status → analyze → apply | 先量后调;预设可 revert | | 场景对象太多导致每轮同步慢 | `blender_rt_opt` analyze → join | ≈1.4 ms/对象;几何零损失,务必先存回退点 | | 出错不知卡在哪 | `blender_viewport {op:"doctor"}` | 真跑一次 bpy 往返 + 三级判定 + 修法 | | 多个会话抢同一个 Blender | `blender_viewport {op:"who"}` → `op:"lease"` | 409 会明确告知持有者与剩余时间 | --- ## 1. 安装与连通 ### 1.1 Blender 侧:装 addon 并 Connect 1. 把 `DSH Blender Bridge` addon 放进 Blender 的 addon 目录(`Edit ▸ Preferences ▸ Add-ons ▸ Install...` 选 zip / 或放到 scripts/addons;模块名 `dsh_blender_bridge`)。 2. 启用它(勾选 checkbox)。 3. 打开一个 **3D 视图**,按 `N` 打开右侧栏 → 找到「DSH Blender Bridge」面板 → 点 **Connect**(默认端口 9876)。 4. 面板里可顺手打开需要的集成(PolyHaven / Hyper3D / Sketchfab / Poly Pizza / Hunyuan3D);**这些开关是 scene 属性** —— 改完记得存进你的 startup / 工程文件(重启 Blender 会丢未保存的)。 > ⚠️ **v2.0.0 改名迁移**:旧版本叫 `MCP for Blender`(模块名 `blender_mcp`)。改名后**模块名变了**,Blender 偏好设置里旧的"已启用"记录(`blender_mcp`)是**死引用** —— 需要**重新启用 `DSH Blender Bridge`**(旧条目可顺手移除)。完整迁移要点见 [../MIGRATIONS.md](../MIGRATIONS.md)。 > 判断 addon 是否在监听:在终端跑 `curl -sS http://127.0.0.1:9877/doctor`;或直接 `node runtime/_probe_tools.mjs` 看命令面。 ### 1.2 构建插件 ```bash cd dsh-blender-plugin DSH_CHECKOUT=/path/to/dsh-harness bash scripts/build.sh # 期望最后三行: Linking build dependencies → Compiling src → lib → Build complete ls lib/index.js # 构建产物 ``` > `scripts/build.sh` 会自动探测 `$HOME/dsh-harness`、`$HOME/dsh`、`$HOME/.dsh/dsh-harness`;都没有就显式给 `DSH_CHECKOUT`。 ### 1.3 装到 DSH(bundle 式) 本包自带 `cordis.patch.yml` 并声明了 `dsh.bundle`,按官方 bundle 方式装配即可(重启后加载): ```bash # ① 建 junction(profile 的 node_modules 里) ln -s /path/to/dsh-blender-plugin ~/.dsh/profiles//node_modules/@dsh-external/dsh-blender-plugin # ② profile 的 package.json:dependencies 加 "link:/path/to/dsh-blender-plugin", # dsh.profile.bundles 加 "@dsh-external/dsh-blender-plugin" # ③ 预演装配(不启动):dsh --profile --dump-config | grep -A3 dsh-blender-plugin # ④ 重启 DSH ``` 不要同时用"运行时注入"与"bundle 装配":两个 fiber 会重复注册同名工具。 ### 1.4 后端自检(三条命令) ```bash curl -sS http://127.0.0.1:9877/health # {"ok":true,...} 后端进程活着 curl -sS http://127.0.0.1:9877/doctor # ⭐ kind=ok 才算"Blender 也连上了" curl -sS http://127.0.0.1:9877/who # 租约 + 通道指标 + 当前生效配置 ``` `/doctor` 返回里的关键字段: | 字段 | 含义 | |---|---| | `kind` | `ok` / `blender-unreachable` / `main-thread-busy` / `addon-thread-stuck` | | `summary` `fix` | 一句结论 + 一句修法(直接照着做) | | `ping_ms` `roundTripMs` | `ping`(不碰 bpy)与 `status`(真跑 bpy)的耗时 | | `config` | **当前生效配置**:工作目录、blender.exe、端口、配置文件来源 —— 排错先看它 | ### 1.5 插件工具自检 ```text blender_viewport(op="doctor") # 同 /doctor,但在工具的返回里更可读 blender_rt_see(max_size=560) # 应返回一张图,50–100 ms blender_rt_commands() # 30 条 addon 命令 + 5 个集成状态 ``` ### 1.6 addon 兼容性探针 ```bash node runtime/_probe_tools.mjs # 逐条调 ping / get_scene_info / get_object_info(name) / get_viewport_screenshot / execute_code ... # 输出每个命令的 ok 与耗时;哪条失败就说明 addon 版本缺这条命令 ``` --- ## 2. 十五个工具逐个上手 > 约定:所有工具都要求**后端在跑**(插件自动拉起)且 Blender 的 addon 已 Connect。 > 参数名用工具 schema 里的原名(WSL / Windows 通用)。 ### ① `blender_rt_see` —— 看 | 参数 | 说明 | |---|---| | `max_size` | 最长边像素,默认 560(420 更快,900 更清晰;上限 1600) | | `full` + `area` | 整窗截图 / 第 N 个区域(看 UI 时用) | | `from` `look_at` | 自定义视角:相机位置 / 注视点,`"x,y,z"` 或数组 | | `lens` `ortho` `ortho_scale` | 焦距 mm(默认 50)/ 正交开关 / 正交尺度(贴长边) | | `view_size` | `"宽x高"`,如 `"1280x720"`;默认按 `max_size` 出 16:9 | | `shading` `overlays` | 临时切换着色(WIREFRAME/SOLID/MATERIAL/RENDERED)/ 覆盖物,出图后还原 | | `view_mode` | `viewport`(默认,零场景改动)/ `render`(临时相机 + Workbench 快渲) | ```text blender_rt_see(max_size=560) blender_rt_see(from="9,-9,6", look_at="0,0,1", lens=35) blender_rt_see(from="0,0,12", look_at="0,0,1", ortho=True, ortho_scale=12, shading="MATERIAL", view_size="1024x1024") blender_rt_see(full=True, area=0) # 整个 Blender 窗口 ``` 注意:`viewport` 模式渲染的是"视口观感",**不是** Cycles 成品图;要成品图用 `blender_rt_headless`。 ### ② `blender_rt_do` —— 做(+ 顺手看) ```text blender_rt_do(code="K.n = getattr(K, 'n', 0) + 1 bpy.data.objects['Cube'].rotation_euler.z += 0.4", see=True) ``` - 代码在 **Blender 主线程**执行,预置 `bpy / math / mathutils / Vector`; - **持久内核 `K`**:`execute_code` 每次是全新命名空间,跨调用状态必须挂 `K`; - `see=true`(默认)会顺带回一帧,整步 ≈105 ms。 ### ③ `blender_rt_watch` —— 连续观察 ```text blender_rt_watch(code="bpy.app.timers.register(tick)", seconds=2.5, fps=4, max_size=420) ``` 返回 ≤6 帧 + **逐帧 hash**:hash 全相同 = 画面没变(动画没跑起来 / 参数没生效)。`fps` 别开太高(每帧占主线程 ~55–100 ms,会卡人)。 ### ④ `blender_rt_loop` —— 内环搜索(最核心的一个) ```text blender_rt_loop(op="start", spec={ "setup": "import random\nK.ob = bpy.data.objects['Cube']", "step": "px = ns['i'] % 7; ns['params'] = {'sx': 1 + px * 0.1}", "measure":"ns['score'] = abs(bpy.data.objects['Cube'].dimensions.x - 2.0)", "iterations": 3000, "budget_ms": 20000, "interval": 0, "measure_every": 5, "minimize": True, "top_k": 20, "group_key": None }) blender_rt_loop(op="status", history=12) blender_rt_loop(op="board", limit=10) blender_rt_loop(op="export", path="/tmp/best_fit.py", top=3) blender_rt_loop(op="stop") # 急停 ``` - `setup/step/measure` 源码共享命名空间 `ns`;`measure` 必须写 `ns["score"]`,可写 `ns["params"] / ns["metrics"] / ns["violations"]`; - 预置:`bpy / K / math / random / numpy(np) / i / frac / penalize / anneal / record`; - **必须给 `iterations` 或 `budget_ms`**(安全阀);`interval=0` 最快;`redraw_every=0` 不重绘最快; - 多目标:`ns['score'] = penalize(errors_dict, violations, weights, lam)`;步长退火:`anneal(v0, v1, ns['frac'])`; - `top_k + group_key` = 候选表(跨对象批量时按对象分组各留最优); - `op=help` 打印完整契约。 ### ⑤ `blender_rt_cmd` —— 透传 addon 任意命令 ```text blender_rt_cmd(name="ping") blender_rt_cmd(name="get_object_info", params={"name": "Cube"}) blender_rt_cmd(name="get_scene_info") ``` ⚠️ **按 addon 真签名传参**:`get_scene_info()` 无参、`get_object_info(name)` 用 `name`。直连没有 schema 层替你改名,传错就是 `unexpected keyword argument`。 ### ⑥ `blender_rt_commands` —— 命令面与集成状态 返回:可用命令列表(常驻 + 受集成开关门控的)、5 个集成开关状态与消息。**调 `blender_rt_cmd` 之前先问它。** ### ⑦ `blender_rt_perf` —— 渲染性能 ```text blender_rt_perf(op="status") # 现状(引擎/设备/采样/降噪/persistent/对象数) blender_rt_perf(op="analyze") # 差分实测:每轮 CPU 侧同步 vs 每采样 GPU 成本(占主线程十几秒) blender_rt_perf(op="apply") # 预设:persistent_data + 降噪器自动判定 + auto_tile off + 采样上限 blender_rt_perf(op="revert") # 还原 apply 之前(自动存快照) ``` 经验值(作者机器,2318 对象):**每轮 CPU 同步 ≈4.2 s,persistent_data 让重复渲染 14.6 s → 0.79 s**;砍采样对 CPU 侧几乎无帮助。 ### ⑧ `blender_rt_opt` —— 对象精简 ```text blender_rt_opt(op="analyze") blender_rt_opt(op="join", args={dry_run=True}) # 先空跑看报告 blender_rt_opt(op="join", args={dry_run=False, save_before="/tmp/pre_join.blend"}) ``` 合并规则(安全):同集合 / 同材质 / 同父级 / 无修改器 / 无动画 / 无形态键 / 无自定义属性 / 无实例 / 非库链接 → **几何零损失**(面数顶点数不变)。务必先给 `save_before`。 ### ⑨ `blender_rt_headless` —— 无头进程 | 参数 | 说明 | |---|---| | `script` | Python 源码(默认注入持久内核 `K`) | | `file` | 打开的 .blend(WSL 或 Windows 路径都行,自动映射) | | `outdir` | 产物目录(跑完列出新文件;同时追加到脚本 `sys.argv`) | | `preload` | 预载 runtime 模块:`"view"` / `"perf"` / `"view,perf"` | | `timeout_ms` | 默认 180000,上限 1800000(超时 SIGKILL) | | `factory_startup` | 默认 true(干净、快、不与运行中的 addon 抢 9876);要用用户偏好/启动文件设 false | | `bootstrap` | 默认 true(注入 `K`);false 时脚本原样跑 | | `args` | 额外命令行参数(v0.8.9 起引号成对时不当分隔符:`--flag "a b"` → `["--flag","a b"]`) | 约定:脚本里 `print("HEADLESS {json}")` → 工具把最后一行解析成 `result`。 > **长活边界(v0.8.9 修正)**:单次调用上限 = `timeout_ms`(默认 180 s、上限 30 min)。 > v0.8.9 前,**超过 15 s 的调用会因流式回执解析失败而显示 `exit=undefined`**(子进程其实跑完了)—— 现在已修,回执正常。 > 仍建议:**超过 ~2 分钟的活直接 `as_job:true`**(立刻返回 jobId,可中途 `blender_rt_job(op="collect")` 看进展),批量/整晚任务用 `blender_rt_job`。 ```text blender_rt_headless(file="D:\\work\\scene.blend", outdir="D:\\work\\out", timeout_ms=600000, preload="view", script="import json,bpy;bpy.ops.render.render(write_still=True)" + "\nprint('HEADLESS ' + json.dumps({'objs': len(bpy.data.objects)}))") ``` ### ⑪ `blender_rt_plan` —— 契约层 + 规划器(v0.5.0) **契约层**(把「假设 / 区间 / 校验 / 证据 / 门控」变成机器可查): ```text blender_rt_plan(op="register_component", args={cid:"backrest", objects:["Backrest","Tenon"], tags:["backrest"]}) blender_rt_plan(op="register_connection", args={cid:"joint", a:"seat", b:"backrest", candidates:["attach","insert"], params:{dy:{range:[0,0.03]}, tenon_len:{range:[0.03,0.10]}}, forbidden:["boolean_union","weld"], confidence:"medium", evidence_required:["probe_dismount"]}) blender_rt_plan(op="register_envelope", args={eid:"env", mn:[-0.5,-0.5,0], mx:[0.5,0.6,1.5], scope:["Seat","Backrest"]}) blender_rt_plan(op="check_envelope", args={eid:"env"}) # 越界清单(只读) blender_rt_plan(op="check_interference", args={scope:["CH_"], use_bvh:true}) # AABB 扫描 + BVH 精查 blender_rt_plan(op="check_interface", args={conn_id:"joint"}) # 两组件间隙/侵入 blender_rt_plan(op="destructive_guard", args={op:"boolean_union", targets:["Backrest"]}) # 未判别 → 拦下 blender_rt_plan(op="evidence", args={label:"side", view:{...}}) # 出图 + md5 记账 blender_rt_plan(op="verify", args={conn_id:"joint", err:184, tolerance:220, identifiable:["dy"]}) # → unresolved blender_rt_plan(op="verify", args={conn_id:"joint", probe_err:0.005, probe_tolerance:0.01}) # → supported blender_rt_plan(op="report", path="D:\\DSH\\blender\\out\\plan_report.md") ``` **规划器**(对象图 → 诊断 → 编译): ```text blender_rt_plan(op="plan_load", args={plan:{...}}) # 载入即出诊断(UnresolvedConnection / Cycle / ParamOutOfRange…) blender_rt_plan(op="plan_order") # 拓扑:组件 → 连接 → 特征 blender_rt_plan(op="plan_build", args={dry_run:true}) # 先看计划(不改场景) blender_rt_plan(op="plan_build", args={dry_run:false}) # 编译成 PLAN_* 对象(按 hidden_when / 偏移 / 特征展开) blender_rt_plan(op="plan_graph", args={fmt:"mermaid"}) # 导出图文本 ``` 判据与流程(含"外部证据不足必须报 unresolved"与 7 条实测坑):`docs/假设驱动建模-cookbook.md`。 #### 多视角渲染 harness(v0.8.8,P2-1)—— 一条命令出 N 张 + jsonl + 预算判定 ```text blender_rt_plan(op="qc_render_views", args={ views: ["iso","front","right"], # 具名视角 / "az=35,el=20" / {name, from, look_at, lens, ortho} res: [512,512], samples: 64, # 采样同时写 EEVEE taa_render_samples 与 Cycles samples budget_s: 120, # 别名 thr;累计渲染超了立即停,已出的图保留 outdir: "D:/DSH/blender/out/qc_render_p21", ref_path: "D:/work/ref_front.png", ref_box: [9,38,444,545] # 可选:逐张走 qc_compare(默认固定对齐) }) ``` - **自动取景**:合并所有可见 mesh 的 AABB(`targets:[名字]` 可限定)→ 逐角解算相机距离 + `margin`(默认 1.12); 给了 `from`/`look_at` 就按显式机位,不再自动取景。 - **固定三点光**:SUN key 3.2 / fill 1.0 / rim 2.4,角度固定且相对相机方位角(+35°/45°、-50°/8°、+165°/30°); **只在本进程内临时建**,出图后删除并把 scene 渲染设置全部还原(`lights_mode:"only"` 可临时屏蔽场景里的灯,也会还原)。 - **产出**:`/_.png` + `/render_views.jsonl`,每行 = 一张图 (`{view, ms, bytes, hash, path, res, engine, samples, camera, frame{bbox, margin_px, coverage, pred_bbox_px, proj_err_px}}`)。 - **预算**:`within_budget=false` + `stopped_early` + `skipped_views`;已完成的行/图**不删**。 - **长活**:`args.asJob=true` 转作业层(无头进程天然隔离场景),用 `blender_rt_job(op="collect", id=...)` 取结果; 也可以在无头里直接跑:`blender_rt_headless(file=..., preload="qc,qc_render", script="print('HEADLESS ' + K.dsh_qc_render_api['render_views']({...}))")`。 - **不改场景**:GUI 进程里默认拒绝打开别的 `.blend`(要开就传 `allow_open_file=true`);临时相机/灯用完即删。 - 自检:`blender_rt_plan(op="qc_render_help")` 出参数速查;`K.dsh_qc_render_api["selftest"]()` 跑取景/投影自检。 实测(350 可见 mesh / 512×512 / 64 采样 / EEVEE+RT):3 视角 2124 / 798 / 565 ms、共 3487 ms、jsonl 3 行、 512 画幅四边余量 36 px(预测 bbox 与实出 alpha bbox 差 ≤2 px);预算 1.0 s 的对照跑只出 1 张即停且图保留。 #### 网格体检 / 拼图 / 玻璃光学(v0.8.10) ```text blender_rt_plan(op="audit_scene", args={envelope:[-10,-5,0,10,5,3]}) # 全场景体检:最脏对象 + 越界件 blender_rt_plan(op="audit_mesh", args={objects:["P01_Fuselage"]}) # 单件深挖(自交/边界边/退化面/法线) blender_rt_plan(op="audit_duplicates") # 同名分叉的材质/网格(先到先得覆盖隐患) blender_rt_plan(op="audit_purge_orphans") # 清 users==0 的灯/相机/网格/材质 blender_rt_preset(op="glass", args={targets:"OBJ:Canopy", use_alpha:true, alpha:0.3}) ``` - **硬规则**:体检对象里 0 个 mesh ⇒ `ok=false`("build 空跑"必须失败,别再靠眼睛发现)。 - 自交用 BVHTree,报相交**面对数**与示例坐标;`normals_outward` 只对闭合网格有意义(`closed=true` 时才给)。 - 拼图:`K.dsh_montage_api["montage"](paths, cols, tile, out, labels, grid, scale_m)` —— 默认 tile 420、 总宽 ≤1700 px(超过会被 harness 二次缩放、小格细节先糊),带网格/标尺/编号/每格指标。 - 读图纪律(L0–L3、每轮 ≤6 张、读图前写断言)见 `docs/证据分级与读图纪律.md`。 - `qc_render_views` 新参数:`warmup`(编译帧不计时,默认开)、`ladder`+`per_view_budget_s`(降质阶梯)、 `aabb`/`focus`(世界坐标框选)、`sheet=N`(宫格)、`chunk=[i,n]`(分片)。 - 无头新参数:`expect={"minNewObjects":1}`(空跑即失败)、`workdir=…`、`engine="none"`(纯 python 免前导)。 ### ⑫ `blender_rt_worker` —— 热无头会话(v0.6.0) 常驻的 `blender -b` 进程:**反复迭代**时省掉每次冷启动与重复导入/重建。 ```text blender_rt_worker(op="start") # 拉起(自带 GPU 前导),回传 pid/port/gpu blender_rt_worker(op="exec", code="K.i = getattr(K, 'i', 0) + 1\nprint('HEADLESS {"i": K.i}')") # K 跨调用保留 blender_rt_worker(op="status") # uptime / calls / objects / 已加载的 K.*_api blender_rt_worker(op="stop") # 干净退出 ``` 限制:**串行**(长代码会占住 worker);**无窗口**(依赖 GUI 上下文的 `bpy.ops` 可能失败,用 `temp_override` 或改走 GUI 通道);与 GUI 通道互不干扰(独立进程,不占 9876/9877)。 配套语义(v0.6.0): - `blender_rt_headless` 默认注入 **GPU 前导**(`gpu:"auto"`)并回传 `gpu` 字段 —— 无头进程读不到用户偏好,Cycles 会静默回落 CPU(实测 15.4×); - 失败时给**结构化**信息:`logs`(全量日志路径)、`lastException`、`traceback`、`reason`; - `outdir` 产物默认**过滤** `__pycache__ / *.pyc / *.blend1|2 / tmp*`; - **客户端超时 ≠ 任务失败**:子进程继续跑完、产物仍落盘(>5 分钟请用本工具的热会话); - **streamJson 回执(v0.8.9)**:`/headless` `/worker` `/txn` `/preset` 每 15 s 一行心跳,客户端按行取末条 —— 修掉了"超过 15 s 显示 exit=undefined"的老毛病。 ### ⑬ `blender_rt_txn` —— 事务 / 回滚(v0.7.0) ```text blender_rt_txn(op="snapshot", label="before_join") # 文件级:存 /snapshots/before_join.blend blender_rt_txn(op="mark", label="iter7", objects="Cube,Wheel_L") # 对象级:记 transform/材质/可见性 … 改动 … blender_rt_txn(op="revert", label="iter7") # 就地回滚(拓扑变过的对象会跳过并报告) blender_rt_txn(op="list") # 快照 + mark 清单 ``` 边界:对象级**不含拓扑/UV/顶点级改动**(Boolean/合并/删面之后回不去)→ 用文件级 `snapshot/restore`(会丢掉当前未保存状态,但 `restore` 默认先替你备份一份)。 `restore(label, into="auto", backup=True)` 的 filepath 口径:默认 `into="auto"` 载入快照后**把会话放回快照的出处文件**(保存落回自己的工程,不再写进快照目录);`into="snapshot"` 非破坏(只看一眼,不写原文件);`into="original"` 要求必须能回写,否则 `ok:false`。`backup=True` 先留 `_pre_restore_*` 快照,会话脏且要覆盖原文件时另留磁盘副本 `_pre_restore_file_*`。 同版本还带来:异常回传 partial stdout/traceback、`K.win_path/wsl_path/blend_path`、`rt_do(file=...)`、以及 **QC**(`blender_rt_plan(op="qc_compare")`:IoU/Dice/缺面积/多面积/边界距离 + 对照图)。 ### ⑩ `blender_viewport` —— 通道运维 | op | 作用 | |---|---| | `status` | 后端健康 / 视口区域 / 计数 | | `doctor` | 真跑一次 bpy 往返 + 三级判定 + 生效配置 | | `who` | 租约持有者 + 通道指标(多会话先看它) | | `lease` `release` | 拿 / 抢 / 释放写权限租约(`force=true` 抢占,`ttl_ms` 有效期) | | `start` `stop` `restart` | 管后端进程(`stop` 会暂停 15 s 看护) | --- ## 3. 六个实战配方 ### 配方 1:改一步看一眼(最常用) ```text blender_rt_do(code="ob = bpy.data.objects['Truck']; ob.location.z += 0.05", see=True) ``` 节奏建议:连续做 10–30 步再停下来看整体;每步都取帧会把主线程占满(每帧 55–100 ms)。 ### 配方 2:多角度验收(不打搅用户) ```text blender_rt_see(from="9,-9,6", look_at="0,0,1", view_size="900x506") # 右前上 blender_rt_see(from="-9,-9,5", look_at="0,0,1") # 左前上 blender_rt_see(from="0,0,14", look_at="0,0,1", ortho=True, ortho_scale=14) # 正交顶视 ``` 同一组参数**重复出图 md5 相同**(作者机器实测 hash `3a7bee2a`)→ 可以当"验收指纹":模型说改完了,重出同样角度,hash 不变就是真没变。 ### 配方 3:内环拟合(让搜索跑在 Blender 侧) 目标:把 6 个尺寸参数调到目标包围盒(示意)。 ```text blender_rt_loop(op="start", spec={ "setup": "TARGET=(3.2,1.4,1.1)\nK.target=TARGET", "step": "import random\nns['params']={'w':round(random.uniform(2.6,3.8),3),'h':round(random.uniform(0.9,1.9),3)}", "measure": "bpy.data.objects['Cube'].dimensions=ns['params']['w'],ns['params']['h'],1.0" "\nerr=[abs(bpy.data.objects['Cube'].dimensions.x-K.target[0]),abs(bpy.data.objects['Cube'].dimensions.y-K.target[1])]" "\nns['metrics']={'ex':err[0],'ey':err[1]}" "\nns['violations']={'ratio':max(0.0, 1.6-bpy.data.objects['Cube'].dimensions.x/max(1e-6,bpy.data.objects['Cube'].dimensions.y))}" "\nns['score']=penalize({'ex':err[0],'ey':err[1]},{'ratio':ns['violations']['ratio']},lam=5.0)", "iterations": 5000, "budget_ms": 30000, "measure_every": 1, "top_k": 20, "interval": 0 }) blender_rt_loop(op="status") # 看 best / tps / history blender_rt_loop(op="board") # 候选表 blender_rt_loop(op="export", path="/tmp/fit_best.py") # 落成可复用脚本 ``` **更省事的路(声明式)**:同一件事也能一次调用交给 `blender_rt_plan(op="shape_search", …)` —— `objective`(`silhouette_iou` / `profile_err` / `aabb_err`)+ `params` 区间 + `apply`(每轮把 `ns["params"]` 落到场景的代码,改完记得 `view_layer.update()`)。它自动选 grid / random、托管 start → 轮询 → board → export,并把场景落到 best(内环自己会停在"最后一次迭代");先 `dry_run=true` 可只校验并回 spec(不启动内环、不需要 Blender 连着)。要 `penalize` / `anneal` / 多目标 / `group_key` 分批,仍回到上面手写 spec。**它同样只在 GUI 会话有效**(无头下 timers 不触发)。 **收敛后必须换一条通路复核**(例如用 `blender_rt_do` 重新量一遍包围盒/体积/渲染),因为内环只优化你写的 `score`。 ### 配方 4:渲染性能流水线 ```text blender_rt_perf(op="status") # 记录基线:engine / device / samples / persistent / 对象数 blender_rt_perf(op="analyze") # 拿到"每轮同步 X s、每采样 GPU Y s" blender_rt_perf(op="apply") # 上预设 blender_rt_perf(op="status") # 对比 # 不满意就 revert;对象太多再走配方 5 ``` ### 配方 5:对象精简 ```text blender_rt_opt(op="analyze") # 看候选组与预计节省 blender_rt_opt(op="join", args={dry_run=True}) # 空跑报告 blender_rt_opt(op="join", args={dry_run=False, save_before="/tmp/pre_join.blend"}) blender_rt_do(code="print('faces', sum(len(o.data.polygons) for o in bpy.data.objects if o.type=='MESH'))") # 复核几何零损失 ``` ### 配方 6:无头成品渲染 / 批量出图 ```text blender_rt_headless(file="D:\\work\\scene.blend", outdir="D:\\work\\out", timeout_ms=900000, preload="view,perf", factory_startup=False, # 用用户启动文件(含 Cycles 预设) script="...多机位循环 K.dsh_view_api['capture'](...) / 或 bpy.ops.render.render(write_still=True)...") ``` 无头也支持自定义视角(Blender 5.x 的 `gpu.init()`):`preload="view"` 之后直接用 `K.dsh_view_api["capture"](json.dumps({...}))`。 ### 配方 7:多视角 QC 出图(一次出 N 张 + 计时 + 预算) ```text blender_rt_plan(op="qc_render_views", args={views:["iso","front","right","top"], res:[768,768], samples:64, budget_s:180, outdir:"D:/work/qc", tag:"hero"}) ``` 出图后:`render_views.jsonl` 每行的 `hash`(md5)就是证据;要对照参考图就给 `ref_path`(+ `ref_box`, 可按视角给字典 `{"front":[..],"iso":[..]}`),harness 会逐张调 `qc_compare` 并把 IoU/剖面差写进同一行。 长活(>5 min)加 `asJob:true`,用 `blender_rt_job(op="collect", id="job-…")` 取。 --- ## 3.5 v0.9.0 新增能力速查(Procedura 融合) > 评估表:`docs/Procedura-融合分析.md` · 版本说明:`CHANGELOG.md` v0.9.0。 > 一句话:**装配是否成形**、**改了多少**、**判定会不会过期** 这三件事以前只能看图,现在有判据了。 ### 3.5.1 网格体检与装配门(`blender_rt_plan`,op 前缀 `audit_`) ```python blender_rt_plan(op="audit_scene") # 全场景:哪几个对象最脏(老能力,v0.9.0 之前根本没接通) blender_rt_plan(op="audit_connectivity") # 连通分量:这堆对象连成几块?谁跟谁不挨着?(只读) blender_rt_plan(op="audit_connectivity", args={"micro_gap_mm": 2.0}) # 口径:带设计间隙的装配件按工艺给 blender_rt_plan(op="audit_gate") # 出厂门:连通 + 包络 + 三态(未确认 → degraded,不给通过) blender_rt_plan(op="audit_drift", args={"a": "objA", "b": "objB"}) # 形状漂移(对称 Chamfer,BVHTree 点到曲面) blender_rt_plan(op="audit_measure", args={"scope": "Cockpit", "k": 4}) # 测量包:bbox + 逐轴间隙/重叠 mm/% + 邻居 blender_rt_plan(op="audit_snap_floaters", args={"scope": "Cockpit"}) # 浮块贴到最近邻(默认 dry_run,只报告) blender_rt_plan(op="montage", args={"paths": [], "cols": 4}) # 拼图(证据分级 L2,v0.9.0 接通) ``` 判据(`audit_help` 里也有): - **可见浮块** = 分量最长 bbox 边 ≥ 全模型最长边 **1%**(严重度只看它,不看体积)。 - **相接口径** `micro_gap_mm`:默认 **0.3 mm**(单一实体 / 3D 打印口径);带设计间隙的装配件按工艺给(1–2 mm 常见)。 口径与间隙分布随返回回传(`gate_caliber` / `gap_histogram` / `floater_gap_range_mm`)。 - 判据**不再是**「最大分量=主体、其余都是浮块」(两个等大零件会随机挑一个当浮体),而是**每个分量都问「你有没有跟别的分量相接」**。 - bbox 间隙在多对象装配里恒为 0(普遍重叠)→ micro 判定要过**网格级复核**(BVH + 面上采样); **默认不设面数上限(全跑,验收不随规模退化)**;只有显式设 `DSH_*` env 或 `max_tris` 参数才启用上限 —— 那时超限会跳过复核 → `attached=null` → **门降级(ok=null)**,未确认绝不当成已通过。 - `audit_drift` 量的是**形状漂移**:归一化会消除平移与整体缩放,所以位移不看它 → 看 `bbox_delta.moved_mm`。 0.05 ≈ 最长边 2.5%;同网格读 0。 - 实测参考(COCKPIT_DELIVERY,14 对象 / 97,438 面):0.3 mm 口径 → 178 浮块(与最近邻相距 0.33–1.14 mm)+ 970 相接;2.0 mm → 63 浮块。 ### 3.5.2 契约层新增(`blender_rt_plan`) ```python blender_rt_plan(op="fingerprint", args={"scope": "Cockpit"}) # 几何指纹(对象/面数/点数/bbox + digest) blender_rt_plan(op="ledger") # 证据账本:源变过的条目自动标 stale=true blender_rt_plan(op="mate_check", args={"conn_id": "pin_fit", "fit": "location"}) # 配合门:接触面积/单边间隙/侵入 blender_rt_plan(op="fit_help", args={"feature": "socket"}) # 装配特征库(9 特征 + 四档 + ISO 273 + 共享标称纪律) blender_rt_plan(op="interference_report", args={"scope": ["IX", "IY"]}) # 逐对深度/体积占比/严重度 + 计划内豁免 ``` - 判定会**随几何失效**:`verify` 记录 bbox 快照,之后中心漂移 > 10% 对角线或尺寸 > 20% → 自动降级 `unresolved`(`verdict.demoted` 留理由)。 - `mate_check` 的 `nominal` 语义 = **期望单边间隙 mm**(可负);只看不写状态,要落状态再调 `verify` / `advance`。 ### 3.5.3 编辑事务与交付(`blender_rt_txn` / `blender_rt_plan`) ```python blender_rt_txn(op="edit_begin", args={"label": "tenon_12mm", "targets": ["Backrest"]}) blender_rt_txn(op="edit_check", args={"kind": "audit", "result": {...}}) blender_rt_txn(op="edit_accept", args={"verified": "audit 干净 + 目标到位"}) # 空 verified / 没复核 → 拒 blender_rt_txn(op="edit_revert", args={"why": "头短 4mm,先撤回重算"}) # 免预算但有上限 + 防抖 blender_rt_plan(op="deliver_export", args={"dir": "D:/Blender/house", "scope": "House", "name": "house"}) blender_rt_plan(op="deliver_verify", args={"path": "D:/Blender/house"}) # 改一字节/加一面都会 FAIL ``` ### 3.5.4 渲染 harness 三件(`blender_rt_plan(op="qc_render_views")`) ```python blender_rt_plan(op="qc_render_views", args={"mode": "parts_color", "views": ["iso", "front", "top"]}) blender_rt_plan(op="qc_render_catalog") # 22 视角 / 别名 / ortho 语义(未知名会回传,不再静默当 iso) blender_rt_plan(op="qc_render_device_info") # 当前设备实测(只读,不渲染) ``` - 每个 jsonl 行都有**实测 `device`**(Cycles 静默回落 CPU 是 ≈7× 退化且无症状,这条专治它;拿不到就 `null` + 说明,不猜)。 - `parts_color` 出图**完整还原**(含共享 mesh 的隔离副本),图例在 `/parts_color_meta.txt`。 ### 3.5.5 机构与生成器(新模块) ```python blender_rt_plan(op="motion_joint", args={"jid": "hinge", "parent": "Base", "child": "Door"}) # 不给 axis → 实测推断 blender_rt_plan(op="motion_measure", args={"jid": "hinge", "phases": 8}) # 扫掠验证:零干涉且确实位移 → supported blender_rt_plan(op="motion_export_urdf", args={"dir": "D:/Blender/mech", "meters_per_unit": "scene"}) blender_rt_plan(op="motion_help") ``` > ⚠️ `meters_per_unit` 默认 **0.001**(上游 stage 约定);**米制场景(Blender 默认 `scale_length=1.0`)请传 `"scene"`**, > 否则质量/惯量会差若干数量级(模块会报「单位可疑」警告 + `units.scene_units_suspect`,但不会替你改)。 > 扫掠是**运动学+BVH** 验证(零干涉 + 确实位移),不是物理仿真;无旁观者对象时判 `unresolved` 而不是通过。 > 三态纪律同样适用:薄平板绕铰链轴没有旋转对称 → `unresolved`(阈值 0.55 不放宽);方形板两条证据矛盾 → 两条轴都报出来,不替用户选。 ```python blender_rt_plan(op="generator_save", args={"name": "daybed", "code": "", "params": {}}) blender_rt_plan(op="generator_run", args={"name": "daybed", "args": {"h": 0.4}, "expect": {"objects": 12, "names": ["leg_01"]}}) # 全新进程复现 + 对拍 blender_rt_plan(op="generator_diff", args={"name": "daybed"}) # 源码变没变(变了 → 上次回执过期) ``` ### 3.5.6 轨迹(旁路,不用调) 每次顶层调用写一行到 `<工作目录>/trajectory/blender_rt-<日期>.jsonl`:`route / op / ms / ok / 参数摘要 + hash`。 `DSH_TRAJ=0` 关闭、`DSH_TRAJ_FULL=1` 记更多参数;路径也在 `blender_viewport(op="status")` → `trajectory.dir`。 --- ## 4. 安全与并发(务必读一遍) 1. **写操作前先留回退点**:`bpy.ops.wm.save_as_mainfile(filepath="...")` 或 `opt join` 的 `save_before`。 2. **内环必须有安全阀**:`iterations` / `budget_ms`;跑飞了用 `blender_rt_loop(op="stop")` 急停(它会在下一个 tick 退出)。 3. **不要连续调 `render.render()`**(GUI 会话里容易撞 CUDA context,且全程占主线程)→ 改 `blender_rt_headless`。 4. **GPU / 驱动相关操作必须在主线程**:addon socket 线程里调 `cycles.preferences.get_devices()` 会直接崩 Blender(作者机器崩过一次);需要排线程就用 `bpy.app.timers`。 5. **租约语义**:写路由 = `/act /cmd /loop /perf(写 op) /opt(join) /headless`;只读 op 豁免。 - 单 agent:插件自动带 `holder`,无感; - 多 agent:收到 `409 leased {holder, expiresInMs}` → 要么等,要么 `blender_viewport {op:"lease", force:true}` 接管; - `/who` 看当前持有者与剩余时间。 6. **`execute_code` 是万能通道**(有意保留):它等于在 Blender 进程里执行任意 Python。 - 通道只监听 `127.0.0.1`(别改成 `0.0.0.0` 暴露到公网); - 别把 API key / 私有数据写进会被日志记录的字符串; - 只在可信的 Blender 工程与可信的模型侧使用。 --- ## 5. 排错速查 ### 5.1 三级诊断(`/doctor` 的 `kind`) | kind | 含义 | 处置 | |---|---|---| | `ok` | TCP + ping + bpy 往返都通 | 无需处理 | | `blender-unreachable` | `127.0.0.1:9876` 没人监听 | **一键:`blender_viewport op=launch`(v0.9.4,§8)**;人工:启动 Blender → N 面板 →「DSH Blender Bridge」→ Connect | | 写操作 409 `leased` 但对方会话其实已经关了 | `blender_viewport op=who` 看 `holderAlive` | v0.9.4 起**会自动回收死会话的租约**(`holderAlive:false`);`null` = 判不了存活 → 用 `op=lease force=true` | | `main-thread-busy` | addon 活着(ping 通),但 bpy 命令超时 | Blender 在渲染 / 模态操作 / 跑重活 → 等它空下来;长渲染改 `blender_rt_headless` | | `addon-thread-stuck` | 端口通但 ping 也不回 | 上一条长命令还在 addon 线程里;不要连发,等结束;必要时重启 Blender | ### 5.2 高频坑(按代价排序) | 坑 | 现象 | 做法 | |---|---|---| | 帧 / 脚本路径两端不互通 | 出图报"文件不存在" / 工具读不到 | 用 `doctor` 的 `config.workDir` 核对;改成两端都能读写的目录 | | 往 UNC(`\\\\wsl.localhost\\...`)写文件 | "返回成功但没有文件" | 写盘一律用 Windows 盘符路径(`D:\...`) | | `execute_code` 无状态 | 上次的变量读不到 | 跨调用状态挂持久内核 `K` | | addon 面板重启后没 Connect | 命令全超时 | 重新 Connect;把需要的开关存进 startup | | 参数名按 MCP 文档写 | `unexpected keyword argument` | 按 addon 真签名(`get_object_info(name)`) | | 后端起不来 / 端口被占 | 工具报"后端不可用" | `blender_viewport op=restart`;或改 `httpPort` | | 找不到 blender.exe | `blender_rt_headless` 报 `blender-exe-missing` | 三种配置方式见 [配置参考.md](配置参考.md) §4 | ### 5.3 一条命令拿全上下文 ```bash curl -sS http://127.0.0.1:9877/who | python3 -m json.tool | head -40 # 含:lease(谁在写)、metrics(calls/errors/timeouts/inflight/lastCmd)、stats、config(生效配置) ``` --- ## 6. 验收自检(可复跑,5 组) 细节与期望值见 [../tests/README.md](../tests/README.md): 1. **view.py 自检**(矩阵对拍 + 离屏 + PNG 字节):`blender_rt_headless {preload:"view", script:"print('HEADLESS ' + K.dsh_view_api['selftest']())"}` → `ok:true`,三个 delta ≈1e-7,`first_px=[25,153,51,255]`。 2. **只算矩阵**:`K.dsh_view_api["matrices"]('{"from":[7,-7,5],"look_at":[0,0,1],"width":640,"height":360}')` → 投影矩阵首行 `2.777778`、次行 `4.938272`。 3. **PNG 字节反查**:`python3 tests/png_decode.py ` → `px [25, 153, 51, 255]`(证明没被二次 gamma、没上下颠倒)。 4. **租约 7 步**:见 tests/README.md §4(拿 → 别人 409 → 不抢 → force 抢 → who → 只读豁免 → release)。 5. **通道三连**:`/health` + `/doctor`(`kind=ok`)+ `/who`。 全部通过 = 你的环境与作者机器的行为基线一致。 ## 附:渲染引擎(v0.8.0 起默认 EEVEE + 光追) | 引擎 | 何时用 | 怎么指定 | 要点 | |---|---|---|---| | EEVEE + 光追(默认) | 迭代 / QC / 够用的成品图 | 不传参数,或 engine="eevee" | 纯 GPU(图形后端,本机 OPENGL + RTX 4060);不依赖设备偏好 → 无头不会静默回落 CPU;首帧有着色器编译(冷 ~16 s),迭代请配 blender_rt_worker 热会话(之后每帧约 1.35 s @360 对象) | | Cycles(可选) | 需要真实 GI / 焦散 / 最高保真 | engine="cycles" | 自动配 OptiX + 设备 + cycles.device=GPU;有 persistent_data / OptiX 降噪 / auto_tile 预设(blender_rt_perf) | | 保持现状 | 你自己管引擎 | engine="keep"(旧 gpu:"false" 等价) | 什么都不改 | QC 回执里带 engine_at_qc —— 判据只在同一引擎内可比。 > EEVEE 相关的硬限制、材质管线分工与渐变组踩坑见 docs/EEVEE-工作要点.md。 ### ⑭ `blender_rt_preset` —— 配方库(v0.8.2) 把「参数组合」变成可保存、可套用、可分发的资产(与领域无关:材质 / 对象 / 场景 / 自定义 JSON)。 ```text blender_rt_preset(op="save", name="硬表面金属", kind="material", tags="metal,hard", \ data='{"inputs.Metallic": 1.0, "inputs.Roughness": 0.25}') blender_rt_preset(op="list") # 清单(可按 kind/tag 过滤) blender_rt_preset(op="apply", name="硬表面金属", targets="MAT:钢_外壳,MAT:螺栓") # 实际写入 blender_rt_preset(op="apply", name="硬表面金属") # 不给 targets → 只预览(dry_run) blender_rt_preset(op="export") # 单个 bundle 便于分享给别的机器 blender_rt_preset(op="import", path="D:/DSH/blender/tmp/presets/presets_bundle_x.json") ``` 要点:data 用**点路径**;`targets` 支持 `MAT:名` / `OBJ:名` / `SCENE`;回执给 `applied / skipped / errors`(路径不存在只报错不静默)。 实测:存 `{"render.resolution_x":640}` → `apply targets="SCENE"` → 场景分辨率 720 → **640** 真实生效;dry_run 预览不写盘。 > 工具分工速记:blender_rt_headless 的 file 是 .blend 工程;执行 .py 脚本请用 blender_rt_do(file="//x.py")(内部 K.run,会回收 stdout)或 headless(script=...)。 > 判据口径(v0.8.3):`qc_compare` 的 `iou` 现在是**固定对齐**(诚实值),搜索对齐另给 `iou_search`;**尺度漂移 >5%** 与 **搜索吸收 >0.05** 都会告警。`ref_box` 可省略(默认整图)。auto 掩膜在前景占比 >80%/<2% 时会自动回落阈值口径并写明原因。 --- ## 7. 路径选择:headless 优先(v0.9.3) > 路线决定与证据:[`docs/headless优先-路线决定.md`](headless优先-路线决定.md)。 > 一句话:**默认 headless;只有「要看一眼 / 要改用户手上那个活场景 / 要吃 GUI 上下文」时才 Connect。** ### 7.1 三十秒判定 | 你的情况 | 走哪条 | 第一句怎么写 | |---|---|---| | 几何自检 / 批量校验 / 数据探针(2–5 s) | **headless** 内联脚本 | `blender_rt_headless(preload="audit", script="…")` | | 多视角 QC / 成品渲染(分钟级) | **headless + `shots` + `as_job`** | `blender_rt_headless(file=…, shots=[…], as_job=True)` | | 参考图对拍 / 交付导出 / 生成器复现 | **headless + `preload`** | 见 §7.3 骨架 C | | 改一步看一眼 / 判断「动没动」 | GUI 通道 | `blender_rt_do(code=…, see=True)` / `rt_watch` | | 改**用户手上那个没保存的场景** | GUI 通道(先 `doctor` 拿 `kind=ok`) | `blender_rt_do(code=…)` | | 依赖视口 / 屏幕 / 模态的 `bpy.ops` | GUI 通道 | `rt_cmd` / `gui_frame` / `gui_shading` / `gui_open` | **三问**:① 中间要不要人看着画面做判断?② 产物是不是 live 场景本身?③ 要不要 GUI 上下文? **三问全否 → headless,连 `doctor` 都不用跑**(本轮 7/7 协作者就是这么干的,且是对的)。 ### 7.2 新参数速查(v0.9.3 冻结契约) #### `blender_rt_headless` 新增参数 | 参数 | 类型 | 说明 | |---|---|---| | `shots` | JSON 数组 | **多视角一体化**:走内置 harness(`qc_render.py` 的 `qc_render_views`)—— 省掉每个建模方 50~60 行相机 / 三点光 / AgX 样板 | | `as_job` | bool | `true` = **立刻后台化**,当场回 `{kind:"promoted", jobId}`(或直接 `jobId`),不等 | | `wait_s` | number | 后台化后**最多等几秒**;到点还没完 → `{kind:"promoted", jobId}` | | `outdir` / `out_json` | string | **接受 WSL 路径**(§7.4),回执给真实写入路径 | `shots` 的元素字段(与 `qc_render_views` 的 `views` 元素同形): | 字段 | 必填 | 说明 | |---|---|---| | `name` | ✔ | 视角名(进文件名与回执) | | `from` | ✔ | 相机位置 `[x,y,z]`(世界坐标) | | `look_at` | ✔ | 注视点 `[x,y,z]` | | `lens` | | 焦距 mm(默认 50) | | `res` | | `[w,h]`;给**数字** = 正方形(`res: 1024` ≡ `[1024,1024]`) | | `samples` | | 采样数 | | `ortho` / `ortho_scale` | | 正交投影 / 正交尺度 | | `margin` | | 自动取景余量(同 `qc_render_views`,默认 1.12) | **回执**:`res.shots = [{name, path, ms, bytes, md5, coverage_estimate, warning?}]`; **主体占画面 < 5% → `warning.code = "subject_too_small"`**(F2 取景自诊断:18.1 m 的件让 1.92 m 的引擎环只剩 ~140 px 这类事故,现在出图当场告警)。 ```python blender_rt_headless( file="/mnt/d/work/aurora/Aurora.blend", outdir="/mnt/d/work/out/shots", # WSL 路径可以(§7.4) as_job=True, # 3 视角 1024×576 必然超过等待窗口 → 直接后台化 shots=[ {"name": "iso", "from": [9, -9, 6], "look_at": [0, 0, 1], "lens": 35, "res": [1024, 576], "samples": 64}, {"name": "front", "from": [0, -14, 2], "look_at": [0, 0, 1], "lens": 50, "res": [1024, 576], "samples": 64}, {"name": "top", "from": [0, 0, 16], "look_at": [0, 0, 1], "ortho": True, "ortho_scale": 16, "res": 1024}, ]) ``` #### 默认行为变化(D1):等待窗口到点**不再丢结果** | 项 | 值 | |---|---| | 客户端等待窗口 | 默认 **100 s**,可用 `DSH_HEADLESS_WAIT_MS` 调([配置参考.md](配置参考.md) §10) | | 窗口到点还没跑完 | 回 `{kind:"promoted", jobId:"run-…"}`(**不再**是 deadline 报错 + 无句柄) | | 怎么收结果 | `blender_rt_job(op="collect", id="run-…")` | | id 空间 | **`run-…` 与 `job-…` 同一个 id 空间**:`status / collect / kill / wait` 都收两种前缀 | ```python # 预期 > 100 s:别赌,直接 as_job r = blender_rt_headless(script_file="/mnt/d/work/aurora/tools/preview.py", args="--part hull_aft", timeout_ms=900000, as_job=True) # r 的信封里 kind="promoted"、jobId="run-…"(或已完成的 status="finished") blender_rt_job(op="collect", id=r.jobId) ``` > 内层 `timeout_ms`(默认 180 s、上限 30 min)与外层 `run_code` 的 deadline 仍是**两套预算**: > 现在到点不会丢结果,但仍建议**外层 deadline > 内层 `timeout_ms`**,少一次 `collect` 往返。 #### `blender_rt_job` 新增(D6) | 项 | v0.9.3 | |---|---| | `op="start"` 参数 | 与 headless **同形**:`script_file` / `args` / `env` / `factory_startup` / `bootstrap` / `workdir` / `include_noise`(不再需要「JSON 参数文件 + wrapper 脚本」绕) | | `op="wait"` | **新**:阻塞到完成或超时,返回结构化回执(不用再循环 `op=status` 撞 repeated-call 检测) | | `op="status"` | 增 `stage` / `stageAt` / `lastOutputAt` / `idleMs` / `lines` / `pidAlive`;**后端重启后的 stale running 会被收敛成 `stale` 并说明**(不再谎报 running) | | `op="kill"` | 对未知 id **不再报错**,返回「已结束 / 不存在」(幂等,调用方流程不中断) | ```python blender_rt_job(op="start", script_file="/mnt/d/work/aurora/tools/preview.py", args="--part hull_aft", env={"AURORA_STAGE": "qc"}, outdir="/mnt/d/work/out/hull_aft", timeout_ms=1200000) blender_rt_job(op="status", id="run-abc123") # stage / idleMs / pidAlive / lines blender_rt_job(op="wait", id="run-abc123", timeout_ms=600000) # 阻塞到完成或超时 blender_rt_job(op="kill", id="job-不存在") # 幂等:回「已结束 / 不存在」 ``` #### 回执结构化(D2):两个 text block | block | 内容 | |---|---| | 第 1 个 | **单行 JSON 信封**(可直接 `JSON.parse`):`status` / `kind` / `runId` / `jobId` / `resultJson` / `resultPath` / `resultTruncated` / `stdoutTail` / `stderrTail` / `artifacts` / `inputFile{path,size,mtime,md5}` / `stage` / … | | 第 2 个 | 人读摘要(前言 + 日志尾 + 修法建议) | **不要再写 `result: (\{.*?\})` 这类正则**(本轮 5/7 协作者的摩擦就来自它)。取信封的稳健写法: ```ts // run_code 里:找第一个能 parse 成对象的 text block function envelope(res: any) { for (const b of res?.content ?? []) { if (b?.type !== "text") continue; try { const j = JSON.parse(b.text); if (j && typeof j === "object") return j; } catch {} } return null; } const env = envelope(await tools.blender_rt_headless({ script: "print('HEADLESS {\"a\": 1}')", as_job: true })); const done = env.kind === "promoted" ? envelope(await tools.blender_rt_job({ op: "collect", id: env.jobId })) : env; console.log(done.resultJson.a); // 1 —— 解析好的 HEADLESS JSON,无需正则 console.log(done.resultTruncated, done.resultPath); // 大结果落盘路径(截断时必有) ``` #### stage 心跳(D5):20 分钟的任务不再是黑盒 ```python # 脚本里打阶段心跳(bootstrap 注入;K.progress 等价) dsh_stage("loading") dsh_stage("building", part="hull_aft") # 可带自定义键 for i in range(total): ... dsh_stage("rendering", done=i + 1, total=total) ``` - 输出形态:单行 `DSH_STAGE {"name":"building",...}`;插件侧在 `op=status` 里解析**最后一条**; - 三处 spawn 已注入 **`PYTHONUNBUFFERED=1`** → `/stdout.log` 在**运行中**就有增量 (v0.9.2 前 Blender 的 Python stdout 被重定向时是块缓冲,日志全程 0 字节,只有进程退出才落盘)。 ```python blender_rt_job(op="status", id="run-abc123") # → stage="rendering" / idleMs=1200 / pidAlive=true / lines=42 ``` ### 7.3 三个可抄骨架 **骨架 A · 一次性自检(2–5 s,最常用)** ```python blender_rt_headless( file="/mnt/d/work/aurora/parts/hull_aft.blend", preload="audit", script=""" import json print('HEADLESS ' + json.dumps( K.dsh_audit_api('connectivity', {'scope': 'Hull_Aft', 'micro_gap_mm': 2.0}), ensure_ascii=False)) """) ``` **骨架 B · 多视角长渲染(分钟级)** ```python blender_rt_headless( file="/mnt/d/work/aurora/Aurora.blend", outdir="/mnt/d/work/out/shots", as_job=True, wait_s=15, # 先等 15 s;没完就转作业层 shots=[{"name": "iso", "from": [9, -9, 6], "look_at": [0, 0, 1], "res": [1920, 1080], "samples": 256}, {"name": "front", "from": [0, -14, 2], "look_at": [0, 0, 1], "res": [1920, 1080], "samples": 256}]) # 回执 res.shots 每张都有 path / ms / md5 / coverage_estimate ``` **骨架 C · 无头直调 QC / 交付(`preload` 对照表)** | 要做的事 | preload | 调用 | |---|---|---| | 剪影对拍(IoU / Dice / 剖面差 + 对照图) | `qc` | `K.dsh_qc_api["compare"](ref, ref_box, render, label=…, out_dir=…)` | | 多视角渲染 harness | `qc,qc_render` | `K.dsh_qc_render_api["render_views"]({views:[…], res:[…], outdir:…})` | | 拼图(L2 证据) | `montage` | `K.dsh_montage_api["montage"]({paths:[…], cols:4, tile:420})` | | 交付导出 / 核对 | `deliver` | `K.dsh_deliver_api['export']({dir:…, scope:…, name:…})` / `['verify'](dir)` | | 网格体检 | `audit` | `K.dsh_audit_api('mesh', {objects:[…]})` | | 机构 / 生成器 / 契约 / 规划器 | `motion` / `generator` / `contract` / `planner` | `K.dsh__api(...)` | > `preload` 的名字 = `runtime/` 下的模块名;多个模块用逗号分隔且**各占独立命名空间**(`preload="qc,qc_render"`)。 > `K.dsh__api` 两种写法都行:`api["fn"](...)`(返回字符串)/ `api("fn", {...})`(返回已解析 dict)。 ### 7.4 路径空间:`outdir` / `out_json` 现在吃 WSL 路径(D4) | 参数 | v0.9.2 行为 | v0.9.3 行为 | |---|---|---| | `outdir` / `out_json` / `file` | 只吃 Windows 路径;传 `/home/…` 被 Windows Blender 解释成 `C:\home\…`,**静默**写飞 | **接受 WSL 路径**,内部映射;回执给**真实写入路径**(Windows 形态 + WSL 可见形态) | | 脚本里把 WSL 路径交给 Windows API | `os.path.exists()` 返回 false,日志反斜杠被吞 | 检测到「Windows API 收到 `/home/…` 或 `/mnt/…`」→ **显式报错**,不再静默改写 | ```python blender_rt_headless(outdir="/home/sixtyseven67/DSH/测试/建模/aurora/out", # ✅ v0.9.3 直接用 script="import os, json; open(os.path.join(K.out_dir, 'probe.txt'), 'w').write('ok'); " "print('HEADLESS ' + json.dumps({'out': K.out_dir}))") # 回执里给出真实写入路径;bash 里 ls 该 WSL 路径应当看得见文件 ``` > 仍要注意:`/mnt/c|d|e|f` 是**只读挂载**(写不进去),且产物落在只读挂载时 `read_image` 打不开 → > 临时产物一律落**工作区**(§7.5),成品再按归档约定拷走。 ### 7.5 陷阱 E3:bash 沙箱的 `/tmp` 与 fs 工具**不共享**(每次 bash 调用即焚) **现象**(本机 web profile 实测复现): ```bash # 同一次 bash 调用里:看得见 echo hello > /tmp/dsh_probe.txt && ls -la /tmp/dsh_probe.txt # ✅ 有文件 ``` ```bash # 紧接着的**下一次** bash 调用:没了 ls -la /tmp/dsh_probe.txt # ❌ No such file or directory ``` 而 `read` / `read_image` 等 fs 工具**从来就看不到** bash 的 `/tmp`(`not found`)。 本轮 engine / hull_aft / gear-arms 各为这个坑浪费 1~2 轮(engine 原话:「同一 bash 调用里 `ls` 看得见, 紧接着 `read_image` 报 not found,另起一次 `ls` 是空目录」)。 **规则:临时文件必须落工作区**(或任务约定的产物目录),不要落 `/tmp`、`/var/tmp`、`$TMPDIR`。 ```bash # ✅ 正确:工作区下的临时目录,跨 bash 调用与 fs 工具都可见 mkdir -p /home/sixtyseven67/DSH/.tmp/ && python3 probe.py > /home/sixtyseven67/DSH/.tmp//probe.json ``` ```python # ✅ Blender 侧同理:产物目录给两端都能读写的路径(不要给只读挂载,也不要给 bash 的 /tmp) blender_rt_headless(outdir="/home/sixtyseven67/DSH/.tmp/hull_aft", script="import os, json; print('HEADLESS ' + json.dumps({'dir': os.getcwd()}))") ``` | ❌ 别这么写 | ✅ 改成 | |---|---| | `bash: … > /tmp/x.json` 然后 `read` 它 | 写 `<工作区>/.tmp//x.json` | | `blender_rt_headless(outdir="/tmp/out")` | `outdir="<工作区>/.tmp//out"` | | 产物落 `/mnt/d/...` 再 `read_image` | 落工作区;归档阶段再按约定拷到 `D:\Blender\<项目>\` | | 手拼 `\\wsl.localhost\Ubuntu\…` UNC | 直接给 WSL 路径(v0.9.3,§7.4) | ### 7.6 常见误用(照这张表自查) | 误用 | 后果 | 正确做法 | |---|---|---| | 外层 `run_code` 不传 `timeoutMs`,跑 >100 s 的 headless | 以前丢结果;现在回 promoted,但白等 100 s | 预期 >100 s 直接 `as_job=True` | | 循环 `op=status` 轮询 | 撞 harness 的 repeated-call 检测 | `op="wait"` 一次拿结果 | | 手写正则抠 `result:` | 双层转义 + 截断时抛异常 | 解析第 1 个 text block(§7.2) | | 大输出靠 stdout 回传 | 中段被省略(`…[中间省略 N 字符]…`) | 给 `out_json=`;截断时看 `resultTruncated` + `resultPath` | | 用 `scene.ray_cast` 批量逐像素射线 | 逐像素带 depsgraph → 撞 120 s / 600 s 上限 | `BVHTree.FromPolygons + bvh.ray_cast`([证据分级与读图纪律.md](证据分级与读图纪律.md) §6) | | `image.pixels` 直接和 PIL/cv2 对拍 | 上下颠倒 → IoU 假低(0.463 vs 0.955) | 先 `[::-1]`(同上 §6) | | 每个建模方自写相机 / 灯光 / AgX 样板 | 50~60 行 × N 人 | `shots=[…]`(§7.2) | | 并行跑 EEVEE 各造一把渲染锁 | 抢 GPU,有人等锁 48 s | 用内置 `render_lock`(`qc_render_views` 自动 acquire) | --- ## 8. 一键启动 Blender(v0.9.4):`blender_viewport(op="launch")` > 背景(外部反馈《M1A1 分件建模》):当时的文档写的是"按 N →「MCP for Blender」(现名 `DSH Blender Bridge`)→ Connect", > **但 agent 没有人手**;当时是 agent 自己拼 PowerShell `Start-Process` + `--python-expr` 才把 GUI 点亮。 > v0.9.4 把那套流程固化成一个调用。 ### 8.1 一条命令 %%%json { "op": "launch" } // 最简形态(等 90s,自动探测 addon) { "op": "launch", "file": "/mnt/d/Dsh/…/tank.blend" } // 顺手打开一个工程 { "op": "launch", "wait_ms": 180000 } // 冷启动慢(机械盘 / 首次加载插件)就调大 { "op": "launch", "dry_run": true } // 只看将要执行的命令,不启动 %%% 它内部做的事:写 boot 脚本(GUI 里 enable addon → 起 socket server)→ `detached spawn blender.exe` → **轮询 9776…9876 端口** → 端口开了顺手跑一次 `doctor` 回诊断。 判据是**端口真的开了**,不是"进程起来了" —— 后者正是手工流程最容易停下的半成品状态。 ### 8.2 幂等与并发 - **已在监听** → 回 `already: true`,**不会**再起一个 Blender(多 agent 反复调用安全)。 - 多个 agent 同时调用:后到的看到 already(或短暂竞争后同样 already)。 - 想重启:先关掉那个 Blender(或 `op=restart` 只重启**后端**,不是 Blender;两者别搞混)。 ### 8.3 参数 | 参数 | 默认 | 说明 | |---|---|---| | `wait_ms` | 90000 | 等 addon 端口的上限(Blender 冷启动 + 加载插件要几十秒) | | `file` | — | 启动时要打开的 .blend(WSL / Windows 路径都收) | | `exe` | 配置链 | 显式指定 blender.exe(覆盖 `DSH_BLENDER_EXE` / `dsh-blender.config.json` / 自动探测) | | `addon_module` | 自动 | 要 enable 的 addon 模块名(逗号分隔);默认扫"模块名里带 mcp"的 | | `addon_file` | — | 直接按文件 import 的 addon .py 绝对路径(Blender 5.x extension 布局下 `addon_utils` 扫不到老 `scripts/addons` 时的兜底) | | `dry_run` | false | 只回报将要执行的命令 | ### 8.4 回执怎么读 %%% LAUNCH ok · 已 spawn pid=12345 · addon 端口 9876 · 等待 8123ms blender.exe:/mnt/d/SteamLibrary/steamapps/common/Blender/blender.exe(来源 config) 提示:GUI Blender 已起来且 addon 已 Connect(端口 9876)—— 现在可以用 rt_do / rt_see 了(写操作记得 blender_viewport op=lease 拿租约) · 已 spawn …(detached,与后端进程解耦) · addon 端口 9876 已监听(8123ms) boot 结论(GUI 侧自己写的 launch-status.json):loaded_by=addon_utils:dsh_blender_bridge · server_running=True · enabled=["dsh_blender_bridge"] 启动后体检:通道健康:TCP + ping + bpy 往返都通 boot 脚本:\\wsl.localhost\Ubuntu\…\dsh_headless_xxx.py(想手工排查就直接跑它) %%% - `statusFile` 指向 `/launch-status.json`:**GUI 进程的 stdout 我们拿不到**,所以让 boot 脚本 自己把结论(enabled / loaded_by / errors / addon_modules_seen / env)写盘。 - 失败时看 `boot.errors`:常见是"addon 没被扫到"(→ 试 `addon_file`)或"background 模式" (addon 明确拒绝在 `-b` 下起 server,要用 GUI)。 ### 8.5 与 headless 的关系(别混) - `op=launch` 管的是 **GUI Blender**(要点亮 9876,为了 `rt_do` / `rt_see` 的交互式回路)。 - `blender_rt_headless` / `blender_rt_job` **不需要** GUI,也不占 9876(独立进程 `blender -b`)。 **多 agent 并行批处理请走 headless**;GUI 只有"改一步看一眼 / 改用户手上那个活场景"才需要。 ### 8.6 回执报 `value is not lossless JSON` 怎么办 那是 v0.9.3 的已知 bug(回执里有无 `undefined` 值 → 宿主门拒收;headless / job 全线失败)。 **升级到 v0.9.4 并重启 DSH**;期间可以照旧用 `blender_rt_job(op=start)` 提交 + 读 `jobs//stdout.log` 里的 `HEADLESS {…}` 兜底(结果本身一直是好的,只是回执被拒)。 --- ## 9. 宿主集成 > 本节讲**视口面板 · 点击进消息 · `@` 提及**这三件事:它们是**用户侧**能力(你点视口 → 进你自己的消息),**不是模型工具**。 > 它走独立 HTTP 路由 `/host/*`,**不进** op 目录、**不加**模型工具 ⇒ 15 个工具 / 186 个 op 的能力面**零变化**。 > 冻结契约:[宿主集成-接口契约.md](宿主集成-接口契约.md)(字段名以此为准,本节只讲怎么用)。 ### 9.1 一分钟理解它是什么 | | 模型侧(原有) | 宿主集成(新增) | |---|---|---| | 谁在用 | 模型调 15 个工具 | **你**在 DSH 界面里点 | | 通道 | `POST /plan`、`/act`、`/headless`… | `GET/POST /host/*`(浏览器 `fetch` 跨源) | | 能干什么 | 看/做/批处理/回退 | 看**最新一帧视口** · 点物体**引用它** · 把图+对象名**加进下一条消息** · `@` 提场景对象 | | 会不会动场景 | 会(受写租约保护) | **不会**:捕获与点击都是只读,**不碰写租约** | 三条设计纪律(①② 已由自检覆盖;③ 只到"后端具备",**客户端未落地**): 1. **面板轮询只读后端 store,绝不因为轮询去碰 Blender** —— 面板开着**不占** Blender 主线程(否则你一边建模一边开面板就会互相卡)。 2. **错配必须拒绝**:截图所属的 `.blend` / 场景与 Blender 当前不符时回 `file_mismatch` / `scene_mismatch`,**绝不**把点击投到当前场景(宁可让你重拍一次)。 3. **静默窗口 0.8 s**(契约 §3)是**后端**语义:`blender_quiet = 在飞命令为 0 且距上次命令 ≥ 0.8 s`,用来判断"现在适不适合重拍"。 > ⚠ 但**当前面板只有手动重拍按钮**,代码里没有"静默后自动重拍"的定时器(见 §9.3 与 §9.9 第 8 条)。 ### 9.2 前置条件(缺一条就用不起来) | # | 条件 | 怎么确认 | |---|---|---| | 1 | 后端在跑(`127.0.0.1:9877`) | `curl -sS http://127.0.0.1:9877/health` | | 2 | Blender 开着且 addon 已 Connect(`127.0.0.1:9876`) | `curl -sS http://127.0.0.1:9877/doctor` → `kind=ok` | | 3 | 包声明了 `dsh.client` 且 `lib/client.js` 已构建 | `node tests/client_bundle_selftest.mjs` | | 4 | **DSH 已重启**(见 9.6) | 会话视图里出现「Blender 视口」tab | 端口一览(三个都只监听本机,**不要**对外暴露):addon `9876` · 后端 `9877` · DSH GUI `3080`。 ### 9.3 视口面板怎么用 位置:注册在 **`conversation.view` 槽位**(契约 §2.3 冻结的座位),面板标题「Blender 视口」。 > ⚠ **未证实(client-ui 自述)**:该槽位类型是 kind=`list`、"rendered one at a time"(`slots.d.ts:183`) > ⇒ 面板**大概率是对话区的一个 View 页签**(要和「对话 / 轨迹」并排切换),**不是常驻侧栏**;**没在真 GUI 里看过**。 > 所以"重启后到**对话区的页签**里找",别只盯着侧栏。若你要的是常驻面板,得换座位(`main` / `sidebar.right`)并改契约 §2.3。 - 面板每 **1500 ms** 轮询一次 `GET /host/view/latest`;当帧**陈旧**(`stale:true`,即截图之后场景被改过)时改成 **600 ms**,让你改完很快看到新状态。 - 图按 `seq` 取:``。 - 面板底部会显示:谁触发的这一帧(`user`/`auto`/`model`)、所属 `.blend` 与场景名、像素尺寸、`stale`。 - 若回执里 **`pickable:false`**(该帧没有相机矩阵),图仍然能看,但**点选会退化**成"只能附整图",面板会提示「刷新后可按物体点选」—— 这是**如实告知**,不是坏掉。 - **重拍是手动的**:面板上只有一个"重拍"按钮(`POST /host/view/capture {auto:false}` ⇒ `source:"user"`)。 > ⚠ **未落地于客户端**:面板代码里**没有**"静默 0.8 s 后自动重拍"的定时器,`{auto:true}` 这条后端能力目前**没有客户端在用**。 > 别指望"改完场景就自动出新图"——现在要手动点重拍。 ### 9.4 点击 → 怎么进你的消息 **点击不自动发送。** 流程是「点选 → 看标记 → 按按钮 → 图+文本进输入框 → 你自己决定发不发」。 1. **点画面里的物体** → 面板把归一化坐标(左上角为原点,`y` 向下)发给 `POST /host/view/pick` → 命中就在图上打标记。 2. **已选中的物体再点一次 = 取消**;**点空处 = 清空已选**(只附整图)。 3. 按面板底部的 **「加入消息」** 按钮,它做两件事: - 取当前帧 → 作为附件加进**草稿**(文件名 `blender-viewport.png`); - 往输入框插入一句话(规则见下表),然后提示「已加入消息」。 4. **发送与否由你决定**(比"自动挂到下一次消息"更保守)。 | 你选了什么 | 插进输入框的文本 | |---|---| | 有选中对象 | `Blender 视口点击:()。用户说"这个/这些"时指它们;对象名在 bpy.data.objects 里可直接用。` | | 没选(只附整图) | `附上当前 Blender 视口截图。` | > 这条文本就是"机器化的读图纪律":模型不用再猜对象名,`name` 是**真的**存在于 `bpy.data.objects`。 ### 9.5 `@` 提及怎么用 在输入框敲 **`@`** → 出现候选,分三组:**对象 / 材质 / 集合**(来自 `GET /host/scene-items`,大小写不敏感子串过滤)。 - 选中一个 → 输入框插入一条**引用**(看起来是一行 `@Cube` 的样子)。 - 引用是**带前缀**的:对象 `object:` · 材质 `material:` · 集合 `collection:`(Lead 2026-10-16 裁决)。 - 发出去时 `codec` 会**拆掉前缀**、序列化成模型能读的文本: | 引用(ref) | 剪贴板/显示 | 发给模型的文本 | |---|---|---| | `object:Cube` | `@Cube` | `Cube` | | `material:Mat` | `@Mat` | `Mat` | | `collection:Col` | `@Col` | `Col` | - 数据源不可达时**静默返回空候选**(不会报错、不会污染你的输入框)—— 所以"`@` 没反应"多半是后端/Blender 不在,按 §9.7 查。 - 候选**没有缩略图**(DSH 的候选类型没有图片字段),用 `section` 区分类型。 > ⚠ **未证实**:`section` 是否真被渲染成"对象 / 材质 / 集合"的**分组标题**,未实测(候选顺序仍按数组顺序); > `codec.serialize` 的返回值是否**原样**进模型文本,也**未实测**(类型只说"失败会阻断发送")。 ### 9.6 ⚠ 必须重启 DSH 才生效 **客户端半边(`lib/client.js`)是 DSH 启动时加载的模块。** 改完/升级完,**必须重启 DSH**,面板才会出现: - ✅ 重启 DSH:新增面板、改面板代码、改 `package.json` 的 `dsh.client` / `exports["./client"]`。 - 🔁 只重启后端(`blender_viewport(op="restart")`):只改了 `runtime/**`(如 CORS、`/host` 路由)时够用。 - ⚠ `dsh-client-hmr` 只在**同时**跑着 `pnpm run dev:web` 时才免刷新热更新;否则改完要**重建 bundle + 刷新页面**(重建客户端半边:`node scripts/build-client.mjs`)。 **CORS / 端口要求**(面板为什么能跨源调 `9877`): - 面板从 DSH GUI 的源(`http://127.0.0.1:3080`)跨源 `fetch` 后端,所以后端必须回 CORS 头。 - 契约只对 **loopback** 放行:`Origin` 匹配 `^http://(127\.0\.0\.1|localhost)(:\d+)?$` 才回显 `Access-Control-Allow-Origin`; **其它一律不回显**(包括 `https://127.0.0.1:3080`、`http://127.0.0.1.evil.com` 这类伪装)。 - `OPTIONS /host/*` 预检回 **204**,带 `Allow-Methods: GET, POST, OPTIONS` / `Allow-Headers: content-type` / `Max-Age: 600` / `Vary: Origin`。 - 所以:**别把 DSH GUI 换成 `https://` 或非 loopback 主机名**,否则面板会因 CORS 拿不到数据(这是有意的安全设计,不是 bug)。 ### 9.7 故障对照表 | 现象 | 多半是 | 处置 | |---|---|---| | 会话视图里**没有**「Blender 视口」tab | 没重启 DSH;或 `package.json` 缺 `dsh.client` / `exports["./client"]`;或 `lib/client.js` 没构建 | **重启 DSH**;跑 `node tests/client_bundle_selftest.mjs`(它红了就是 bundle 的问题) | | 重启 DSH 后插件整体加载失败 | bundle 里混进了 `import`/`export` 顶层语法或 Node 内置模块 | `node tests/client_bundle_selftest.mjs` 看具体哪条 | | 面板一直"还没有帧"(`seq=0`) | 从没捕获过 | 面板上点刷新;或 `curl -X POST -H 'content-type: application/json' -d '{}' http://127.0.0.1:9877/host/view/capture` | | 图能看,但提示"刷新后可按物体点选" | 该帧 `pickable:false`(没有相机矩阵) | 重新捕获一帧;确认 addon 版本够新 | | 点击没反应 / 回 `unknown_seq` | 帧被新捕获顶掉了(`seq` 已变) | 等面板刷新出新图再点(`seq` 落后是**故意**拒绝的,避免投错帧) | | 点击回 `scene_mismatch` / `file_mismatch` | 截图所属场景 / `.blend` 与当前不符(你切了文件或改了场景名) | **这是纪律不是 bug**;重新捕获再点 | | 浏览器控制台报 CORS | 后端版本旧(没有 `/host` 的 CORS 头);或 GUI 源不是 loopback | 升级后端 → 重启 DSH;跑 `node tests/host_routes_selftest.mjs` | | `@` 出不来候选 | 后端不可达 / `/host/scene-items` 502 | `curl /doctor`;候选失败是静默的 | | 点「加入消息」没反应 | 拿不到会话作用域的 `inputActions` | 看控制台报错;契约 §2.4 有兜底路径 | | 面板开着,Blender 变卡 | 不该发生(面板只读 store) | 报 bug;顺带跑 `curl -sS http://127.0.0.1:9877/who` 看租约与指标 | ### 9.8 两条自检(可复跑) ```bash # ① 客户端 bundle:假 window.__ModuleLoader__ 下执行 lib/client.js # 断言 id / factory→apply+inject / 无 import·export·Node 内置模块;文件没生成时 SKIP(exit 0) node tests/client_bundle_selftest.mjs # ② /host 路由:打契约 §4 判据 1–8(CORS 预检 / 非 loopback 不回显 / capture→PNG 魔数 / # 陈旧 seq 409 / pick 命中·空处·错配 / scene-items 过滤与 limit) # 后端不可达 → SKIP(exit 0);Blender 不在 → 判据 1/2 照跑,3–8 标 SKIP node tests/host_routes_selftest.mjs ``` > 判据 7(错配)需要**临时改一次场景名**才能构造,跑完会在 `finally` 里还原并核验; > 不想让它动场景就 `DSH_HOST_TEST_NO_MUTATE=1 node tests/host_routes_selftest.mjs`(该判据转 SKIP)。 > 写通道被别的会话持有时(`409 leased`)它**不夺租约**,同样转 SKIP。 ### 9.9 未证实项(照实登记 —— 别当已证实的能力用) > 来源:client-ui 2026-10-16 自述(原样转发,见 [契约 §6.4](宿主集成-接口契约.md))。 > 这些**不是"实现了但没测"**,而是"**没有证据**"——请勿在对外说明里写成能力承诺。 | # | 未证实项 | 现状 | 你会看到什么 | |---|---|---|---| | 1 | `conversation.view` 的实际渲染形态 | 类型说 kind=list、"rendered one at a time" ⇒ **大概率是对话区的一个 View 页签**,不是常驻侧栏;未在真 GUI 看过 | 重启后**去对话区的页签里找**面板 | | 2 | `label` thunk 是否真被投影成 `ViewTab.label` | 未证实(沿用评估文档 §6) | 页签名可能显示成兜底值 | | 3 | 候选的 `section` 是否真渲染成**分组标题** | 类型有该字段,未实测 | `@` 候选可能没有"对象/材质/集合"标题 | | 4 | `codec.serialize` 的返回值是否**原样**进模型文本 | 类型说失败会阻断发送,未实测 | 模型侧看到的引用文本形态未经验证 | | 5 | `inject: (sessionId) => …` 业务面与标准 props 的**运行时合并** | 类型上成立;client-ui 用**假 ctx** 验过写法,未在真 ctx 跑过 | 面板可能拿不到 session 作用域的 `inputActions`(§2.4 有兜底) | | 6 | **CORS 是集成期第一风险** | CORS 未到位时面板**静默降级成一行"后端不可达"**(不崩,但全功能不可用) | **重启 DSH 后第一个要看**的;用 §9.8 的 ② 兜住 | | 7 | 真 GUI 验证**必须重启 DSH** | 插件是启动时加载的模块;client-ui 不能自行重启(会杀会话) | "看不看得见面板"只能由人重启后确认 | | 8 | **"静默 0.8 s 自动重拍"未落地于客户端**(本仓测试期新发现) | 面板只有手动重拍按钮(`auto:false`);没有自动重拍的定时器,`{auto:true}` 无客户端在用 | 改完场景**不会**自动出新图,要手动点重拍 | --- ## 10. 右侧栏视口面板 + 模型请求点选(v1.2) > 本节讲 v1.2 的两件新事:① 右侧栏的视口面板(**通道可用才出现**);② **模型请求你点选**(阻塞式)。 > 冻结契约:[宿主集成-接口契约.md §7](宿主集成-接口契约.md)。§9 讲的是 v1.0 的对话区面板与 `@` 提及,**两者并存**。 ### 10.1 和 §9 的面板有什么不一样 | | §9 的对话区面板(v1.0) | §10 的右侧栏面板(v1.2) | |---|---|---| | 座位 | `conversation.view`(对话区页签) | `sidebarRightTabs.register({kind:'blender-viewport'})` + `sidebar.right.pane.tab` | | 何时在 | **常开**(不管 Blender 在不在) | **只在通道可用时出现**(`/host/ready` 门控) | | Blender 关掉后 | 还在,图是旧的 | **窗口消失**(注销) | | 旧宿主兜底 | — | 没有右侧栏 API 时**退回** `conversation.view`,并提示"右侧栏不可用,已退回对话页签" | ### 10.2 面板什么时候出现 / 什么时候消失 - 客户端每 **2000 ms** 轮询一次 `GET /host/ready` —— 这个端点**很便宜**(只读后端已缓存的通道状态,**不触发 bpy 往返**,实测 ~1–3 ms,而 `/doctor` 要 ~200 ms),所以常开着也没负担。 - `ready === true` → 注册右侧栏 tab + 面板 body,并**自动开一次**(**每个会话只自动开一次**;你手动关掉后不会反复弹)。 - `ready === false` → 注销(`dispose()`),**窗口随之消失**;若当时开着,会一并关闭。 - 判断依据就是 `/host/ready` 的 `ready` 字段(= 后端存活 **且** addon 握手健康);`reason` 会给 `addon-unreachable` / `handshake-stale`。 ```bash # 面板该不该出现,看这一条(不要用 /doctor 判,它贵) curl -sS http://127.0.0.1:9877/host/ready # {"ok":true,"ready":true,"addon":true,"protocol":11,"backend":"1.2.0","reason":null} ``` ### 10.3 怎么点选(没有模型请求时) 和 §9.4 一样:点画面里的物体 → 打标记 → 按「加入消息」把**截图 + 对象名文本**放进草稿(**不自动发送**)。 已选中的再点一次 = 取消;点空处 = 清空已选。 ### 10.4 模型请求你点选时,怎么应答 / 怎么取消 模型(`blender_rt_pick`)发起请求后,面板**顶部出现横幅**: ```text ┌──────────────────────────────────────────────┐ │ 模型请求你点选: [取消] │ └──────────────────────────────────────────────┘ ``` - **此时你的下一次点击走"应答"路径**(`POST /host/pick/answer`),**不再**走"加入消息"。 - 命中 → 横幅变「已应答:``(已返回给模型)」,**2 s 后消失**。 - 若回 `pending` + `scene_mismatch` / `unknown_seq` → 横幅显示「**刷新后再点**(``)」,**保持等待** —— 这条是刻意的:**错配时请求不结束**,让你刷新视口后重试,而不是把点击投到错的场景。 - 点「取消」→ `POST /host/pick/cancel`(`reason:"user"`)。 - 面板**不可见时不轮询**(省资源);但模型请求期间就算你没打开面板,也不影响 `@` 与草稿路径 —— 模型侧会自己超时。 ### 10.5 `blender_rt_pick` 怎么用(模型侧,第 16 个工具) ```text blender_rt_pick(prompt="请点一下你要改的物体", timeout_s=120) ``` | 参数 | 默认 | 说明 | |---|---|---| | `prompt` | `"请点一下你要改的物体"` | 显示在横幅上的话;**最长 200 字符**,超出截断 | | `timeout_s` | `120` | 超时秒数,**夹到 `[5, 600]`** | 行为与返回: 1. `POST /host/pick/request` 建请求 → 每 **400 ms** 轮询 `GET /host/pick/result?requestId=…`; 2. 直到 `answered` / `cancelled` / `expired` / `unknown` 为止; 3. 命中 → 返回 `name` / `type` / `detail` / `location`;点空处 → "用户点了空处(表示没有选中任何物体)";超时或取消 → 可读原因。 4. **全局同时只有一个请求**(`isConcurrencySafe: false`);新请求会把旧的置 `cancelled`(`reason:"superseded"`)。 5. 宿主取消(abort)时,工具会 `POST /host/pick/cancel`(`reason:"aborted"`)后退出。 ### 10.6 ⚠ 必须重启 DSH 才生效 与 §9.6 同因,且 v1.2 多一处:**第 16 个工具 `blender_rt_pick` 与右侧栏门控都是 DSH 启动时加载的**。 - ✅ **重启 DSH**:新增/改动右侧栏面板、`blender_rt_pick`、`package.json` 的 `dsh.client`。 - 🔁 只重启后端(`blender_viewport(op="restart")`):只改了 `runtime/**`(`/host/ready`、点选请求路由、CORS)时够用。 - ⚠ 客户端半边改动要**重建 bundle**:`node scripts/build-client.mjs`(然后刷新页面)。 ### 10.7 故障对照表(v1.2) | 现象 | 多半是 | 处置 | |---|---|---| | **右侧栏面板不出现** | `ready` 不是 true(addon 没 Connect / 握手过期);或 CORS 没到位;或没重启 DSH | **先 `curl -sS http://127.0.0.1:9877/host/ready`** 看 `ready`/`reason`;再查 CORS(§9.6);最后确认重启过 DSH | | `/host/ready` 404 | 后端版本旧(没有 v1.2 路由) | 升级后端 → 重启 DSH | | `/host/ready` 很慢(>50 ms) | 实现退化成了"每次都问 Blender" | 报 bug:它必须只读缓存(判据 11 就是测这个) | | 面板在 Blender 关掉后**还在** | 门控没生效(该 dispose 没 dispose) | 报 bug;先确认 `/host/ready` 确实回了 `ready:false` | | 有横幅,但点下去没反应 | 点的是空处(会回 `answered` + `object:null`,正常);或帧被新捕获顶掉(`unknown_seq`) | 看横幅文案;刷新后再点 | | 横幅一直"刷新后再点(scene_mismatch)" | 截图所属场景与当前不符(你改了场景名 / 换了 `.blend`) | **这是纪律不是 bug**:刷新视口(重拍)后再点 | | 模型说超时了 | `timeout_s` 到了 → `expired` | 让它重新发起;或把 `timeout_s` 调大(上限 600) | | 模型的请求被顶掉 | 又来了一个新请求(全局只有一个) | 正常语义:旧的置 `cancelled`(`superseded`) | ### 10.8 未证实项(v1.2,照录不升级) > 与 §9.9 同样口径:**没有证据**,不是"实现了但没测"。请勿写成能力承诺。 | # | 未证实项 | 现状 | 你会看到什么 | |---|---|---|---| | 1 | **右侧栏的真实渲染形态** | `sidebarRightTabs.register({id,kind,title,guide})` 的类型成立,但"`kind` 是否真变成一个右侧栏 tab""`guide` 是否真渲染成引导项"**未在真 GUI 看过** | 重启后**右侧栏**里找「Blender 视口」;找不到就按 §10.7 查 | | 2 | `useTabInfo()` 的真实返回形态 | §7.3 说面板 body 用它读 `{sidebar, panel, tab}`;**未实测** | 面板若报错,先怀疑这里 | | 3 | `dispose()` 是否真把窗口移除 / `openTab` 在未注册时是否真 throw | 未实测(契约 §7.6 判据 16 正是用"会 throw"来**反证**门控,属未证实的假设) | "Blender 关掉后面板消失"这条**没被真机验证**过 | | 4 | "面板不可见时不轮询"是否真的省资源 | 未实测 | — | ### 10.9 两条自检(v1.2 增补) ```bash # ① /host 路由:判据 11–15(/host/ready 计时 + 不碰 Blender / 点选请求 pending→answered / # 错配后仍 pending / timeout_s=5 → expired / 新请求 supersede 旧的) # v1.2 路由未装载 → 整节 SKIP(exit 0);Blender 不在 → 判据 11 照跑(ready:false 分支),12–15 SKIP node tests/host_routes_selftest.mjs # ② 客户端 bundle:含 §7.3/§7.4 的静态断言(ready 轮询 2000 / sidebarRightTabs.register 的 id·kind / # guide 条目 / sidebar.right.pane.tab 的 key / pick 四条路径字面量) # bundle 里还没有 /host/ready → 该块 SKIP(exit 0) node tests/client_bundle_selftest.mjs ``` > 判据 13(错配后仍 pending)需要**临时改一次场景名**,跑完在 `finally` 里还原并核验; > `DSH_HOST_TEST_NO_MUTATE=1` 可关掉这次改动。写通道被别人持有时(`409 leased`)不夺租约、该判据转 SKIP。 > 测试**不抢占真人的 pending 请求**:跑之前若已有 pending,判据 12–15 整组 SKIP。 **三个环境开关**(默认都最保守): | 环境变量 | 作用 | |---|---| | `DSH_HOST_TEST_NO_MUTATE=1` | 完全不改场景名 ⇒ 判据 7/13 转 SKIP(只读环境用) | | `DSH_HOST_TEST_FORCE_LEASE=1` | **抢占**写租约(`force:true`)⇒ 别人正持有租约时也能把判据 7/13 跑起来。**只在确认没有别的会话在驱动这个 Blender 时用** | | `DSH_BLENDER_BACKEND=` | 换后端地址(默认 `http://127.0.0.1:9877`) | > 视口里**没有物体**时,判据 12/13 **不会整条跳过** —— 它们会走"点空处 → `answered` + `object:null`"这条同样要验的状态机路径,只有"命中对象名"那一条标 SKIP。 > 共享 Blender 时若别的会话也在改场景名,判据 7/13 会**如实 SKIP**(并发护栏),而不是误报失败。