ZW3D 插件开发实战:从搜索面板到 LLM 远程建模

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.dllPartSearch.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。

排查花了点时间,最后定位到两件事:

  1. .ui 文件必须打包成 .zrc。ZW3D 不直接读 .ui,要用它自带的 zrc.exe 打包:
powershell 复制代码
& "C:\Program Files\ZWSOFT\ZW3D 2026\zrc.exe" "PartSearch\." -o "bin\PartSearch.zrc"

这一步最容易漏------你以为放了 .ui 文件就行,实际 ZW3D 找的是 .zrc

  1. 名字要用 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 万张存量图怎么解析入库。

相关推荐
张彦峰ZYF2 小时前
LangGraph 深入理解 ReAct:让 AI Agent 真正学会「边想边做」
人工智能·llm·agent·react·langgroup
想要成为糕糕手4 小时前
🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(六)—— 完结篇:消息渲染与流式收尾
react.js·typescript·llm
AndrewHZ7 小时前
【LLM技术全景】RAG 从原理到实战——检索增强生成完整指南
人工智能·深度学习·算法·llm·检索增强·生成式模型·rag
liulilittle11 小时前
DeepSeek Harness 自定义模型提供商配置
前端·javascript·人工智能·windows·llm·deepseek·harness
tachibana212 小时前
文件上传分布式限流如何做?
人工智能·ai·大模型·llm·prompt
浮生望1 天前
Harness 工程:用 Best of N Sampling + LLM as Judge 构建自动化代码生成流水线
llm
To_OC1 天前
别死磕 Prompt 了!我用 Harness 流水线,让大模型自动产出高质量代码
人工智能·llm·agent
qpsj1 天前
让 LLM 控制 AutoCAD/ZWCAD:COM 自动化 + MCP 封装
python·llm