ZW3D 插件开发实战:从搜索面板到 LLM 远程建模
上一篇讲 AutoCAD 的 COM,半天能通。这篇进 ZW3D------国产三维 CAD,没有 COM 接口,只能写 C++ 插件。这是我四条路线里花时间最多的一条,坑也最多:DLL 放错目录、Init 不调用、中文乱码、线程安全。这篇按我实际踩坑的顺序写,从"一个搜索面板"做起,最后讲到"给 LLM 远程建模"。
一、为什么必须写 C++
ZW3D 官方只给 C API + 插件机制,没有 COM。这意味着没有 Dispatch("ZW3D.Application") 这种好事,你要么用 C++ 写个 DLL 塞进它进程,要么别玩。
插件是什么?一句话:一个 C++ DLL,ZW3D 启动时扫描 apilibs 目录,加载 DLL,调用里面的 Init 函数,你在 Init 里注册命令和界面。用户输入 ~命令名 就唤起你的功能。
scss
ZW3D 启动
→ 扫描 apilibs 目录
→ 加载 PartSearch.dll
→ 调用 PartSearchInit() ← 你在这里注册命令/表单/回调
→ 用户输入 ~ShowPartSearch
→ 面板弹出
先分清三个概念,后面全要用:
| 概念 | 干嘛的 | 对应 API |
|---|---|---|
| 入口函数 | DLL 加载时 ZW3D 自动调用 | {名}Init() / {名}Exit() |
| 命令 | ~命令名 唤起 |
cvxCmdFunc("名", 函数, 许可码) |
| 表单 | 界面面板,.ui 文件定义 |
cvxFormFunc("名", 函数, 许可码) |
二、第一个坑:DLL 放错目录,Init 死活不被调用
我照着教程写完第一个插件------一个"零件搜索面板",编译出 PartSearch.dll 和 PartSearch.zrc。然后按直觉,把这两个文件复制到用户目录:
shell
%appdata%\ZWSOFT\ZW3D\ZW3D2026\custom\apilibs\
重启 ZW3D,输入 ~ShowPartSearch,没反应。
我以为命令注册写错了,改了半小时 Init 函数,还是没反应。最后想验证"Init 到底有没有被调用",就在 Init 里加了几行写文件的代码:
cpp
FILE* fp; fopen_s(&fp, "C:\\init_log.txt", "w");
fprintf(fp, "Init called!\n"); fclose(fp);
重启,C:\init_log.txt 根本没生成。也就是说,DLL 被加载进了进程(资源管理器能看到),但 Init 没被调用。
这个现象的反直觉之处在于:进程里明明有你的 DLL,命令就是没注册。排查方向很容易跑偏到"是不是 cvxCmdFunc 用错了",其实是目录问题------ZW3D 只从安装目录的 apilibs 加载并调用 Init:
makefile
C:\Program Files\ZWSOFT\ZW3D 2026\apilibs\
放用户目录,DLL 进了进程但 Init 不执行,等于白放。把文件挪到安装目录(要管理员权限),重启,面板出来了。
这个坑我记了一笔:遇到"插件在进程里但功能没生效",先验证 Init 有没有被调用,别先怀疑自己的注册代码。
三、第二个坑:.ui 要打包成 .zrc,名字还分两种
面板能弹了,但很快又卡在一个错误码上:cvxFormCreate 返回 -271。
排查花了点时间,最后定位到两件事:
.ui文件必须打包成.zrc。ZW3D 不直接读.ui,要用它自带的zrc.exe打包:
powershell
& "C:\Program Files\ZWSOFT\ZW3D 2026\zrc.exe" "PartSearch\." -o "bin\PartSearch.zrc"
这一步最容易漏------你以为放了 .ui 文件就行,实际 ZW3D 找的是 .zrc。
- 名字要用 functionName,不是 name。
.ui文件里,一个表单有两个名字属性:
xml
<widget class="ZsCc::Form" name="PartSearchForm"> <!-- 对象名 -->
<property name="functionName">
<string notr="true">PartSearch</string> <!-- 注册名,用这个! -->
</property>
</widget>
代码里 cvxFormFunc("PartSearch", ...) 和 cvxFormCreate("PartSearch", ...) 用的名字,必须是 functionName 的值,不是 name。用错就返回 -271。
我把这个错误码表记下来了,后面排错全靠它:
| 返回值 | 含义 | 排查方向 |
|---|---|---|
| 0 | 成功 | - |
| -271 | 创建失败 | 名字≠functionName 或没打包 .zrc |
| -272 | 显示失败 | 表单是否已创建 |
| -273 | 表单不存在 | cvxFormFunc 注册了没 |
四、第三个坑:中文乱码,得转 GBK
面板上的中文全变成了问号。
这个排查也绕了一下。最后定位到:ZW3D 中文版按 GBK(代码页 936)解析字符串。而我的 .cpp 和 .ui 都是 UTF-8 存的,编译时还加了 /utf-8 标志,两边编码一打架,中文全乱。
修法是把源码和 .ui 全转成 GBK,编译不加 /utf-8:
powershell
$gbk = [System.Text.Encoding]::GetEncoding(936)
$content = [System.IO.File]::ReadAllText("PartSearch.cpp", [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText("PartSearch.cpp", $content, $gbk)
英文界面没这问题,UTF-8 就行。但既然面向中文用户,这步躲不掉。
五、搜索面板的核心逻辑
坑填完了,把搜索面板的逻辑写出来。核心就三段:读输入框、操作表格、建实体。
cpp
char keyword[256] = {0};
cvxItemGet("PartSearch", 1, 1, keyword); // 读搜索框(控件 id=1)
// 清空表格(表格控件 id=4)
int n = cvxTableRowCnt("PartSearch", 4);
for (int i = n - 1; i >= 0; i--)
cvxTableRowRemove("PartSearch", 4, i);
// 加行填单元格
cvxTableRowInsert("PartSearch", 4, 0);
cvxTableCellTextSet("PartSearch", 4, 0, 0, "零件名");
cvxTableCellTextSet("PartSearch", 4, 0, 1, "代号");
// 建一个长方体
svxBoxData box;
cvxPartBoxInit(&box);
box.X = 100; box.Y = 50; box.Z = 20;
int shapeId = 0;
cvxPartBox(&box, &shapeId);
这个面板是"给人用"的:设计师点搜索、选零件、插入。但我的目标不是这个,是"给 LLM 用",所以下一步才是重头戏。
六、从"给人用"到"给 LLM 用":起一个 HTTP 服务
要让 LLM 远程控制 ZW3D,思路是在插件里起一个 HTTP 服务,外面用 HTTP 调。架构:
markdown
LLM / MCP 客户端
↓ HTTP POST
ZW3D C++ 插件 (port 8081)
↓ 任务队列 + ZwCommandPost 主线程调度
ZW3D C API
↓
ZW3D 当前图纸出现图形
然后就撞上了那道墙:线程安全。
先说这个 HTTP 服务本身。ZW3D 插件里起服务没有现成框架,我用 Winsock 手写------就一个 socket 监听 8081,来了请求起个线程处理,所以编译命令里要链 ws2_32.lib(Winsock 的库)。这个细节不重要,重点是:HTTP 服务天然多线程,每个请求一个线程,而 ZW3D 的 API 只能在主线程调。
为什么只能主线程?ZW3D 这类桌面软件的图形内核绑定在界面线程上,界面的消息循环跑在主线程里,所有画图、建模动作都得排进这个消息循环依次执行。你从别的线程直接调 API,等于绕过消息循环去动图形数据,轻则画不出来,重则崩。这不是 ZW3D 独有,所有 GUI 程序都这样------Android 里"不能在子线程更新 UI"说的就是同一件事。
所以核心矛盾是:请求在 HTTP 线程,执行必须在主线程。解决办法是把"执行"这件事投递回主线程,HTTP 线程在一边等结果。这套机制由三块拼成:
cpp
// 1. 任务结构体:主线程处理完,把结果写回这里
struct HttpTask {
TaskType type; // 要做什么(建块/出图/标注...)
std::shared_ptr<std::string> result; // 结果(JSON 字符串)
std::shared_ptr<std::atomic<bool>> done; // 完成标志
};
// 2. 全局队列 + 锁 + 条件变量
std::queue<HttpTask> g_taskQueue;
std::mutex g_taskMutex;
std::condition_variable g_taskCv;
// 3. 关键一步:把"处理队列"注册成 ZW3D 命令
// 这样 ZwCommandPost("~HttpProcessQueue") 才能把活投递到主线程
cvxCmdFunc("HttpProcessQueue", (void*)CmdProcessQueue, VX_CODE_GENERAL);
有了这三块,剩下就是两个函数:HTTP 线程负责"入队 + 等结果",主线程负责"取任务 + 调 API + 通知":
cpp
// HTTP 线程:入队并等待结果
static std::string EnqueueAndWait(TaskType type, ...) {
auto result = std::make_shared<std::string>();
auto done = std::make_shared<std::atomic<bool>>(false);
{
std::lock_guard<std::mutex> lock(g_taskMutex);
HttpTask task; task.type = type; task.result = result; task.done = done;
g_taskQueue.push(task);
}
ZwCommandPost("~HttpProcessQueue", ZW_COMMAND_POST_PRIORITY_HIGH); // 唤醒主线程
std::unique_lock<std::mutex> lock(g_taskMutex);
g_taskCv.wait_for(lock, std::chrono::milliseconds(30000),
[&] { return done->load(); }); // 阻塞等结果
return *result;
}
// 主线程:处理队列(这才是真正调 API 的地方)
static int CmdProcessQueue(void) {
HttpTask task = g_taskQueue.front();
g_taskQueue.pop();
switch (task.type) {
case TASK_CREATE_BLOCK: *task.result = ApiCreateBlock(...); break;
case TASK_GENERATE_DRAFTING: *task.result = ApiGenerateDrafting(); break;
}
task.done->store(true);
g_taskCv.notify_all();
return 0;
}
这套"队列 + 投递 + 等待"是整个 ZW3D 方案能不能成立的关键,跑通之后链路就完整了:创建零件 → 生成三视图 → 自动标注 PMI → 质量评估。
这里有三个容易忽略的设计细节,我单独拎出来:
为什么完成标志要传 std::atomic<bool> 而不是普通 bool? 因为 done 会被两个线程同时碰------主线程写、HTTP 线程读。普通 bool 在多线程下没有可见性保证,极端情况 HTTP 线程可能永远看不到主线程写进去的 true,wait_for 就白白等满 30 秒。用 atomic 保证跨线程可见。
为什么 wait_for 要设 30 秒超时? 兜底用的。万一主线程那条命令因为什么原因没执行(ZW3D 正忙、命令被吞了),HTTP 线程不能一直挂死,超时了要能带着"超时"这个结果返回,而不是让请求永远卡着。
ZwCommandPost 为什么能唤醒主线程? 它的作用是把一个命令投递到 ZW3D 的命令队列,由主线程的消息循环去执行。前面注册的 HttpProcessQueue 命令,处理函数就是 CmdProcessQueue。所以"HTTP 线程发 ZwCommandPost("~HttpProcessQueue")"翻译过来就是:把"处理任务队列"这件事,排进主线程的消息循环里去做。主线程做完,写 done、通知条件变量,HTTP 线程醒来拿结果,闭环。
这套东西看着几段代码,其实是把"跨线程调用 GUI 程序"这个老问题,用最朴素的队列 + 锁 + 条件变量解掉了。完整的 6569 行实现可以参考开源项目 zw3d-cad-automation,我这里只保留了骨架。
八、HTTP 之上再包一层 MCP 工具
LLM 不会直接发 HTTP 请求,它走 MCP 协议。把 HTTP 客户端每个端点封装成 MCP 工具:
python
import json
@mcp.tool()
def zw3d_create_part(length: float, width: float, height: float) -> str:
"""在 ZW3D 中创建六面体零件"""
result = client.create_block(length, width, height)
return json.dumps(result)
@mcp.tool()
def zw3d_evaluate_quality() -> str:
"""评估工程图质量(返回分数和建议)"""
result = client.evaluate_quality()
return json.dumps(result)
注意 zw3d_evaluate_quality 这个工具,它让 LLM 能"看到"自己的画图结果------这是下面闭环的关键。
九、画完自检:LLM 独有的能力
COM 那条路线的局限在于:画完了就画完了,得人眼检查。ZW3D 这边,我在 HTTP 之上叠了一个 Agent 循环,让 LLM 自动执行"画图 → 评估 → 修正 → 直到通过":
python
from ZW3D_Agent.http_client import ZW3DHttpClient
from ZW3D_Agent.zw3d_agent import ZW3DAgent, AgentConfig
client = ZW3DHttpClient(host="127.0.0.1", port=8081)
agent = ZW3DAgent(client, AgentConfig(max_iterations=5))
result = agent.run("path/to/part.Z3PRT")
评估是一套 13 条规则、满分 100:有没有视图(30 分)、内容在不在纸内(25 分)、主三视图结构(18 分)、视图不重叠(18 分)、有标注(14 分)......评估不过就自动修正重画,最多 5 次。
这套"画完自检"是 LLM 相比传统脚本最独特的点:脚本只会执行,LLM 能判断结果对不对。
十、一个版本坑:悟空 2027 返回 -31
最后说个版本坑。我一开始用的 ZW3D 2027(悟空版),结果 ZwDrawingViewStandardCreate 返回 -31,死活跑不通。换回 ZW3D 2026,通了。
后来查了下,两个版本差异不小:
| 维度 | ZW3D 2026 | ZW3D 2027(悟空版) |
|---|---|---|
| 插件加载 | 安装目录 apilibs | 用户目录 custom\apilibs + 注册表 |
| API 兼容 | 完整 | 部分 API 返回 -31 |
| 内部版本号 | 30.05/30.06 | 32.01 |
结论很简单:要上就锁 ZW3D 2026,别在悟空版上浪费时间。
下一篇讲图纸的正反两面:LLM 生成 DXF,以及 50 万张存量图怎么解析入库。