feat(annotate): 新增图纸批注工具集(云线/文字/多段线/引线等 8 个工具) #3

Merged
gjm merged 3 commits from home-lenovo-legion-r9000/home-ai-dev AGit into main 2026-10-09 22:34:46 +08:00
5 changed files with 327 additions and 58 deletions
Showing only changes of commit ec5f62670d - Show all commits

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@ python agent_loop.py # 完整 Agent 循环(多回合 截图/缩放/结
- **`lisp_inline`**:把 `script_template` 中的 `{占位符}` 替换为调用参数,`sendStringToExecute` 到命令行(异步)。布尔值转 `T`/`nil`。见 `mcp_tool.cpp:43`。
- **`lisp_file`**:先 `(load "文件" nil)` 再执行 `call_template`,`file_name` 相对于配置中的 `lisp_directory` 解析。见 `mcp_tool.cpp:61`。
- **`c++_dll`**:`LoadLibrary` 第三方 DLL,用 `acrxGetApiVersion` 校验 ARX 大版本一致(不匹配则拒绝加载防崩溃),取 `factory_function` 与 `Destroy_<factory>` 两个导出函数创建/销毁工具,用 `mcp_Tool_DllProxy` 托管生命周期(RAII)。跨 DLL 通过 `SetConfigAbiSafe` / `ExecuteAbiSafe` 传 C 字符串,JSON 序列化全部在 DLL 内部完成。见 `mcp_configParser.cpp:55`、`mcp_tool.cpp:105`。
- **`c++_dll`**:`LoadLibrary` 第三方 DLL。**同一 DLL 全进程只加载一次**(句柄由 `mcp_configParser.cpp` 的模块缓存 `g_loadedDllModules` 持有,`mcp_Tools::clear()` 时统一 `FreeLibrary`),仅**首次加载**时校验 ARX 大版本:用**编译期常量** `(ARX<<16)|SUB_ARX` 与 DLL 导出的 `acrxGetApiVersion` 比对,不匹配则拒绝加载防崩溃。注意运行时的 `acrxGetApiVersion`(`rxapi.lib` 的 `acrxGetApiVersionImpl`)是**有状态实现**,返回值会随调用次数变化,不能用作基座侧版本。取 `factory_function` 与 `Destroy_<factory>` 创建/销毁工具,用 `mcp_Tool_DllProxy` 托管工具对象生命周期(RAII,**不再 FreeLibrary 模块**)。跨 DLL 通过 `SetConfigAbiSafe` / `ExecuteAbiSafe` 传 C 字符串,JSON 序列化全部在 DLL 内部完成。见 `mcp_configParser.cpp`、`mcp_tool.cpp`。
### 插件 ABI(`inc/mcp_plugin_api.h`)
+104 -47
View File
@@ -5,12 +5,52 @@
#include "mcp_utils.h"
#include "ghc/filesystem.hpp"
#include "rxregsvc.h"
#include <fstream>
#include <cstdint>
#include <map>
#include <algorithm>
#include <cwctype>
namespace
{
// 已加载的插件 DLL 缓存:规范化路径 -> 模块句柄。
// 同一个 DLL 只 LoadLibrary 一次;后续工具直接复用句柄,并跳过版本校验。
std::map<std::wstring, HMODULE> g_loadedDllModules;
// 统一卸载缓存中的所有 DLL。必须在所有 mcp_Tool_DllProxy 销毁之后再调用,
// 否则 DLL 已卸载而代理仍持有其工具对象指针,析构时会崩溃。
void unloadAllDllModules()
{
for (auto &kv : g_loadedDllModules)
{
if (kv.second) { FreeLibrary(kv.second); }
}
g_loadedDllModules.clear();
}
// 诊断日志:仅记录版本校验时的两个数值(排查版本不一致问题)
// 每次重新加载工具时清空一次,便于只观察本次的结果。
void mcpLoadReset()
{
std::wstring lp = (mcp_utils::getModelDirPath() / L"mcp_load.log").wstring();
std::ofstream ofs(lp.c_str(), std::ios::trunc);
}
void mcpLoadLog(const std::string &msg)
{
std::wstring lp = (mcp_utils::getModelDirPath() / L"mcp_load.log").wstring();
std::ofstream ofs(lp.c_str(), std::ios::app);
if (ofs.is_open()) { ofs << msg << "\n"; }
}
}
#pragma region mcp_ConfigParser
bool mcp_ConfigParser::LoadAndRegister(const std::wstring &jsonFilePath, mcp_Tools &registry)
{
mcpLoadReset();
std::ifstream ifs(jsonFilePath);
if (!ifs.is_open()) return false;
if (!ifs.is_open()) { return false; }
nlohmann::json config;
try { ifs >> config; }
@@ -51,7 +91,6 @@ bool mcp_ConfigParser::LoadAndRegister(const std::wstring &jsonFilePath, mcp_Too
return true;
}
extern "C" int acrxGetApiVersion();
void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std::wstring &pluginDir, mcp_Tools &registry)
{
fs::path dllPath
@@ -65,48 +104,72 @@ void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std::
return;
}
HMODULE hMod = LoadLibrary(dllPath.c_str());
CString verStr;
verStr.Format(_T("Major ver:%d, Minor ver:%d"), ARX, SUB_ARX);
CString ver_errMsg;
ver_errMsg.Format(
CString(_T("[MCP Server]Error: Failed to load the DLL. "))
+ CString(_T("Please check whether the version of the ObjectARX SDK used by "))
+ CString("the DLL matches the current ARX version(%s).\n"), verStr );
ver_errMsg.Format(
CString(_T("[MCP Server]Error: Failed to load the DLL. "))
+ CString(_T("Please check whether the version of the ObjectARX SDK used by "))
+ CString("the DLL matches the current ARX version(%s).\n"), verStr);
if (!hMod)
// 规范化 key(绝对路径,统一小写),用于判断该 DLL 是否已经加载过
std::wstring key = dllPath.wstring();
std::transform(key.begin(), key.end(), key.begin(),
[](wchar_t c) { return (wchar_t)::towlower(c); });
HMODULE hMod = NULL;
bool firstLoad = false;
auto it = g_loadedDllModules.find(key);
if (it != g_loadedDllModules.end())
{
AfxMessageBox(ver_errMsg);
return;
// 该 DLL 已加载:直接复用句柄,不再重复 LoadLibrary,也不再做版本校验
hMod = it->second;
}
else
{
hMod = LoadLibrary(dllPath.c_str());
if (!hMod)
{
AfxMessageBox(ver_errMsg);
return;
}
g_loadedDllModules[key] = hMod;
firstLoad = true;
}
// 1. 定义版本校验函数指针
typedef int (*GetApiVersionFunc)();
GetApiVersionFunc getVerFn = (GetApiVersionFunc)GetProcAddress(hMod, "acrxGetApiVersion");
// 2. 核心拦截逻辑
if (!getVerFn)
// 版本校验:仅在该 DLL 首次加载时执行一次
if (firstLoad)
{
// 连这个函数都没导出,说明根本不是合法的 ObjectARX/MCP 插件
AfxMessageBox(CString(
CString(_T("The ObjectARX version of the DLL could not be determined."))
+ CString(_T("Please ensure that the acrxGetApiVersion function is exported."))
));
FreeLibrary(hMod);
return;
}
typedef int (*GetApiVersionFunc)();
GetApiVersionFunc getVerFn = (GetApiVersionFunc)GetProcAddress(hMod, "acrxGetApiVersion");
// 拿 DLL 编译时的 SDK 版本,和基座当前运行的 SDK 版本进行比对
int arx_version(acrxGetApiVersion());
int dll_version(getVerFn());
if (arx_version != dll_version)
{
// 平台大版本不匹配,强行加载必定闪崩,果断踢掉!
FreeLibrary(hMod);
AfxMessageBox(ver_errMsg);
return;
if (!getVerFn)
{
// 连这个函数都没导出,说明根本不是合法的 ObjectARX/MCP 插件
AfxMessageBox(CString(
CString(_T("The ObjectARX version of the DLL could not be determined."))
+ CString(_T("Please ensure that the acrxGetApiVersion function is exported."))
));
FreeLibrary(hMod);
g_loadedDllModules.erase(key);
return;
}
// 基座自身版本取「编译期常量」,它稳定可靠;
// 不能依赖运行时的 acrxGetApiVersion()——该函数由 rxapi.lib 中的有状态实现提供,
// 其返回值会随调用次数变化(首次为正确值,之后被进程内全局污染),无法用于比对。
int arx_version = (ARX << 16) | SUB_ARX;
int dll_version = getVerFn();
mcpLoadLog("arx_version=" + std::to_string(arx_version) + " dll_version=" + std::to_string(dll_version));
if (arx_version != dll_version)
{
// 平台大版本不匹配,强行加载必定闪崩,果断踢掉整个模块!
FreeLibrary(hMod);
g_loadedDllModules.erase(key);
AfxMessageBox(ver_errMsg);
return;
}
}
typedef mcp_Tool *(*CreateFunc)();
@@ -124,25 +187,18 @@ void mcp_ConfigParser::LoadDllTool(const nlohmann::json &toolConfig, const std::
// 给真正的 DLL 实例也注入配置,以防第三方需要在内部读取
std::string cfgStr = toolConfig.dump();
pRealTool->SetConfigAbiSafe(cfgStr.c_str());
//pRealTool->SetConfig(toolConfig);
// 实例化代理类,接管生命周期
auto pProxy = std::make_shared<mcp_Tool_DllProxy>(hMod, pRealTool, destroyFn);
// 实例化代理类,接管工具对象的生命周期(模块句柄由 g_loadedDllModules 统一管理)
auto pProxy = std::make_shared<mcp_Tool_DllProxy>(pRealTool, destroyFn);
// 代理类继承自基类,也需要配置来处理 GetName 和 ToLLM
pProxy->SetConfig(toolConfig);
registry.Register(pProxy);
}
else
{
FreeLibrary(hMod);
}
}
else
{
FreeLibrary(hMod); // 导出函数找不到,直接卸载
// 工厂返回空:只跳过本工具,不卸载模块(同一个 DLL 的其它工具可能仍然可用)
}
// 工厂/销毁函数缺失:同样只跳过本工具,不卸载模块
}
#pragma endregion mcp_ConfigParser
@@ -191,7 +247,8 @@ void mcp_Tools::print()
void mcp_Tools::clear()
{
m_tools.clear();
m_tools.clear(); // 先销毁所有代理(代理不再 FreeLibrary,仅释放 DLL 内部工具对象)
unloadAllDllModules(); // 所有代理销毁完毕后,再统一卸载缓存的 DLL 模块
}
#pragma endregion mcp_Tools
+17 -6
View File
@@ -89,27 +89,38 @@ nlohmann::json mcp_Tool_LispFile::Execute(const nlohmann::json &args)
#pragma endregion LispFile
#pragma region DllProxy
// RAII:基座销毁工具时,自动通知 DLL 释放内存,并卸载模块
// RAII:基座销毁工具时,通知 DLL 释放工具对象(模块句柄由模块缓存统一管理)
mcp_Tool_DllProxy::~mcp_Tool_DllProxy()
{
if (m_pRealTool && m_destroyFunc)
{
m_destroyFunc(m_pRealTool);
}
if (m_hModule)
{
FreeLibrary(m_hModule);
}
// DLL 模块由 mcp_ConfigParser 的模块缓存统一卸载(mcp_Tools::clear 中),此处不再 FreeLibrary
}
nlohmann::json mcp_Tool_DllProxy::Execute(const nlohmann::json &args)
{
if (m_pRealTool)
{
// DLL 工具通常会写数据库(创建/修改实体、图层等)。基座运行在 AutoCAD 主线程的
// 窗口消息处理中,此时处于「应用上下文」,直接对数据库做 kForWrite 打开会返回
// eLockViolation(表现为 openWriteSpace 拿不到模型空间)。因此统一在此加文档写锁,
// 工具执行完立即解锁。只读工具被锁也无副作用。
AcApDocument* pDoc = acDocManager ? acDocManager->curDocument() : nullptr;
const bool locked = (pDoc != nullptr &&
acDocManager->lockDocument(pDoc, AcAp::kWrite) == Acad::eOk);
std::string argStr = args.dump();
//return m_pRealTool->Execute(args); // 完美转发
const char* retStr = m_pRealTool->ExecuteAbiSafe(argStr.c_str());
return nlohmann::json::parse(retStr);
nlohmann::json result = nlohmann::json::parse(retStr);
if (locked)
{
acDocManager->unlockDocument(pDoc);
}
return result;
}
return mcp_Tool_Utility::make_error(-32603, L"Underlying DLL tool instance is invalid");
}
+5 -4
View File
@@ -24,17 +24,18 @@ public:
class mcp_Tool_DllProxy : public mcp_Tool
{
private:
HMODULE m_hModule;
mcp_Tool* m_pRealTool; // 真正由第三方 DLL 创建的工具对象
typedef void (*DestroyFunc)(mcp_Tool*);
DestroyFunc m_destroyFunc;
public:
mcp_Tool_DllProxy(HMODULE hMod, mcp_Tool* pReal, DestroyFunc pDestroy)
: m_hModule(hMod), m_pRealTool(pReal), m_destroyFunc(pDestroy) {}
mcp_Tool_DllProxy(mcp_Tool* pReal, DestroyFunc pDestroy)
: m_pRealTool(pReal), m_destroyFunc(pDestroy) {}
// RAII:基座销毁工具时,自动通知 DLL 释放内存,并卸载模块
// RAII:基座销毁工具时,通知 DLL 释放工具对象。
// 注意:DLL 模块句柄由 mcp_ConfigParser 的模块缓存统一管理(同一 DLL 只加载一次),
// 这里不再 FreeLibrary,否则多个工具会重复释放同一模块导致引用计数被打穿。
virtual ~mcp_Tool_DllProxy();
virtual nlohmann::json Execute(const nlohmann::json &args) override;
+200
View File
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
"""
verify_annotate.py
批注工具集运行时验证脚本。
前提:CAD 已打开并加载 cad_mcp_frame.arx(插件 DLL),127.0.0.1:8080 在监听。
用法:
python verify_annotate.py # 绘制 -> 校验 -> 清理(默认清理)
python verify_annotate.py --keep # 保留批注,便于肉眼观察
覆盖工具:
draw_revision_cloud / add_text / add_mtext / add_polyline / add_line /
add_leader_annotation / set_entity_properties / delete_entities
"""
import socket
import json
import sys
HOST, PORT = "127.0.0.1", 8080
ANNOT_LAYER = "AI_REVIEW"
# ---------------------------------------------------------------- MCP 通信
def rpc(method, params=None, timeout=20):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((HOST, PORT))
req = {"jsonrpc": "2.0", "id": 1, "method": method}
if params is not None:
req["params"] = params
s.sendall(json.dumps(req).encode("utf-8"))
data = b""
while True:
chunk = s.recv(8192)
if not chunk:
break
data += chunk
if len(chunk) < 8192:
break
s.close()
return json.loads(data.decode("utf-8"))
def call_tool(name, arguments):
return rpc("tools/call", {"name": name, "arguments": arguments})
def payload(resp):
"""从 tools/call 响应里取出数据类工具返回的内层 JSON。"""
if resp is None:
return False, "no response"
if "error" in resp:
return False, resp["error"]
try:
for item in resp["result"]["content"]:
if item.get("type") == "text":
return True, json.loads(item["text"])
except Exception as e: # noqa: BLE001
return False, "parse error: %s" % e
return False, "no text content"
# ---------------------------------------------------------------- 测试驱动
RESULTS = []
def record(name, ok, detail):
RESULTS.append((name, ok, detail))
print(" [%s] %-24s %s" % ("PASS" if ok else "FAIL", name, detail))
def expect_created(name, resp, want_type):
"""校验工具返回 ok 且 created[0].type == want_type,返回 handle 列表。"""
ok, obj = payload(resp)
if not ok:
record(name, False, "调用失败: %s" % obj)
return []
if not obj.get("ok"):
record(name, False, "返回非 ok: %s" % obj)
return []
created = obj.get("created", [])
handles = [c.get("handle", "") for c in created]
types = [c.get("type", "") for c in created]
layers = [c.get("layer", "") for c in created]
if want_type not in types:
record(name, False, "类型不符: 期望 %s 实际 %s" % (want_type, types))
return handles
bad_layer = [l for l in layers if l != ANNOT_LAYER]
if bad_layer:
record(name, False, "图层不符: %s" % layers)
return handles
record(name, True, "created=%s layer=%s" % (types, layers))
return handles
def main():
keep = "--keep" in sys.argv
print("=== 批注工具集运行时验证 ===")
print("目标: %s:%d\n" % (HOST, PORT))
# 0) 连接 & 工具清单
try:
tools_resp = rpc("tools/list")
except Exception as e: # noqa: BLE001
print("[错误] 无法连接 MCP 服务: %s" % e)
print("请确认 CAD 已打开、插件已加载、8080 端口在监听。")
return 2
names = [t["name"] for t in tools_resp.get("result", {}).get("tools", [])]
want = ["draw_revision_cloud", "add_text", "add_mtext", "add_polyline",
"add_line", "add_leader_annotation", "delete_entities",
"set_entity_properties"]
missing = [w for w in want if w not in names]
print("工具总数: %d" % len(names))
if missing:
print("[FAIL] 未注册的工具: %s" % missing)
return 1
print("[PASS] 8 个批注工具均已注册\n")
created_handles = []
# 1) 云线(矩形)
print("1) 绘制工具")
h = expect_created("draw_revision_cloud", call_tool("draw_revision_cloud", {
"shape": "rect", "min_x": 0, "min_y": 0, "max_x": 100, "max_y": 60,
}), "LWPOLYLINE")
created_handles += h
# 2) 单行文字
h = expect_created("add_text", call_tool("add_text", {
"text": "AI批注-单行", "position": [0, 80], "height": 5,
}), "TEXT")
created_handles += h
# 3) 多行文字
h = expect_created("add_mtext", call_tool("add_mtext", {
"text": "AI批注-多行\n第二行", "position": [0, 100], "height": 5,
}), "MTEXT")
created_handles += h
# 4) 多段线
h = expect_created("add_polyline", call_tool("add_polyline", {
"points": [[0, 130], [60, 130], [60, 170]], "closed": False,
}), "LWPOLYLINE")
created_handles += h
# 5) 直线
h = expect_created("add_line", call_tool("add_line", {
"start": [0, 190], "end": [100, 190],
}), "LINE")
created_handles += h
# 6) 引线批注(返回 MTEXT + LEADER 两个)
h = expect_created("add_leader_annotation", call_tool("add_leader_annotation", {
"text": "引线批注", "points": [[300, 200], [200, 150]], "height": 5,
}), "LEADER")
created_handles += h
# 7) 下钻校验:取第一个实体详情,确认图层
print("\n2) 校验")
if created_handles:
ok, obj = payload(call_tool("get_entity_detail", {"handle": created_handles[0]}))
if ok and obj.get("layer") == ANNOT_LAYER:
record("get_entity_detail", True, "handle=%s layer=%s" % (created_handles[0], obj.get("layer")))
else:
record("get_entity_detail", False, "%s" % obj)
else:
record("get_entity_detail", False, "无可用 handle")
# 8) 修改实体属性(把第一个实体改红)
if created_handles:
ok, obj = payload(call_tool("set_entity_properties", {
"handle": created_handles[0], "color": 1,
}))
record("set_entity_properties", bool(ok and obj.get("count") == 1), "%s" % obj)
# 9) 清理
print("\n3) 清理")
if keep:
record("delete_entities", True, "已跳过(--keep);保留 handle: %s" % created_handles)
elif created_handles:
ok, obj = payload(call_tool("delete_entities", {"handles": created_handles}))
good = ok and obj.get("count") == len(created_handles)
record("delete_entities", good, "删除 %s / 共 %s" % (obj.get("count"), len(created_handles)))
else:
record("delete_entities", False, "无 handle 可删")
# 汇总
print("\n=== 结果汇总 ===")
passed = sum(1 for _, ok, _ in RESULTS if ok)
for name, ok, detail in RESULTS:
print(" %-4s %-24s %s" % ("OK" if ok else "NG", name, detail))
print("\n通过 %d / %d" % (passed, len(RESULTS)))
return 0 if passed == len(RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())