C++(cad_mcp_plugins):
- 新增 bvh.h:header-only 2D BVH(质心中位分割,叶<=4,SelfPairs 交叉对枚举)
- 新增 find_collisions.cpp:粗筛 AABB 相交实体对,附重叠盒 + 逐轴重叠比 ratio
(= 逐轴重叠长/较短长 之积,退化轴取 1.0),按 ratio 降序 + 分页;
narrow=true 只对返回页跑 intersectWith 细筛(附 exact)
- 容器过滤(关键):图框块/分区边界会与一切相交,用重叠【度】判定
deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 * 其他实体数),
与图幅/缩放无关;被剔除者在 excluded_containers 里如实报告
(初版用面积判据对多图框并排失效:3 框只占并集 27/13/7% 全部漏过,
全模型 163 对噪声;改度判据后图框 deg=35/25/23 vs 其余 deg<=3,分离干净)
- cmakelists.txt + 两个 mcp_config.json 注册(28 工具)
agent 侧(Python):
- cad_tools.READ_ONLY_TOOLS += find_collisions(受控子循环可调用)
- checks.check_collisions + run_deterministic_checks(受 CHECK_COLLISIONS 开关,默认关)
- config += CHECK_COLLISIONS=False / COLLISION_MIN_OVERLAP=0.5
- checklist.py / review_checklist.json += 「干涉/碰撞」类别(high)+ 提示词协议
(先 find_collisions 拿候选 → inspect_region 目视确认;AABB 相交 != 真干涉)
验证:
- verify_collisions.py 17/17 PASS:全模型候选 163→80(全部 ratio=0.000),
min_overlap=0.5 → 0 对;逐图框 0 对;narrow top-5 中 4 条 exact=true;
--inject 注入深重叠矩形报出 ratio=0.902 并清理 2/2
- pytest 355 passed(新增 12 项)
- 端到端:LLM 主动调用 find_collisions{min_overlap:0.2,narrow:true,window:<图框>},未误报
334 lines
10 KiB
C++
334 lines
10 KiB
C++
// ============================================================================
|
||
// find_collisions.cpp
|
||
// 图纸审查 - 干涉 / 碰撞**候选**检查
|
||
//
|
||
// find_collisions : 粗筛出「包围盒相交」的实体对,附重叠证据,供审查 agent 判断
|
||
//
|
||
// 设计要点:
|
||
// - **粗筛**:BVH(bvh.h)两两自检,只对相交的包围盒做后续处理;
|
||
// - **容器过滤**:图框块 / 分区边界会与「一切」相交,制造海量假候选。
|
||
// 用重叠**度**(与多少其他实体相交)识别:`deg >= max(deg_min, frac * 其他实体数)`
|
||
// → 判为容器,剔除其全部配对。**与图幅/缩放无关**,实测图框 deg=35/25/23,
|
||
// 其余实体全部 deg<=3,分离极干净。(另保留基于面积的 `exclude_oversized` 作快速预筛。)
|
||
// - **证据**:重叠盒 + 逐轴重叠比(`ratio`,见下)+ 类型 / 图层 / 是否同层同类型;
|
||
// - **排序**:按 `ratio` 降序 + 分页 → 最可疑的排在最前;
|
||
// - **只报候选、不下结论**:AABB 相交 ≠ 真干涉(L 形布局的两个实体包围盒会相交,
|
||
// 相邻管段在接口处也会相接)。真伪由审查 agent 依据证据判断,必要时用
|
||
// `inspect_region` 放大目视复核。
|
||
//
|
||
// `ratio` 定义:逐轴重叠比之积。
|
||
// 某轴重叠比 = 该轴重叠长度 / 两实体该轴较短长度;**至少一方在该轴退化为 0**
|
||
// (水平线的高、竖直线的宽)时该轴取 1.0(视为完全重叠)。
|
||
// 于是:共线相接的两段 → 0;横管穿过阀门 → ≈1;斜管交叉 → 较大。
|
||
//
|
||
// 注:本工具按 **2D(x/y)** 判定,与插件其余工具一致;z 不参与。
|
||
// ============================================================================
|
||
|
||
#include "cadsdk.h"
|
||
#include "dwg_review_common.h"
|
||
#include "mcp_plugin_api.h"
|
||
#include "bvh.h"
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <string>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
using namespace dwg_review;
|
||
|
||
namespace
|
||
{
|
||
|
||
struct EntInfo
|
||
{
|
||
AcDbObjectId id;
|
||
std::string handle;
|
||
std::string type;
|
||
std::string layer;
|
||
BBox box;
|
||
};
|
||
|
||
struct Pair
|
||
{
|
||
int a = 0;
|
||
int b = 0;
|
||
double ratio = 0.0;
|
||
BBox ov;
|
||
double ow = 0.0;
|
||
double oh = 0.0;
|
||
bool exact = false;
|
||
};
|
||
|
||
// ---------- 宽松参数解析(LLM 常把数字写成字符串、把 bool 写成 0/1) ----------
|
||
double numArg(const nlohmann::json& args, const char* key, double dflt)
|
||
{
|
||
auto it = args.find(key);
|
||
if (it == args.end() || it->is_null()) return dflt;
|
||
if (it->is_number()) return it->get<double>();
|
||
if (it->is_boolean()) return it->get<bool>() ? 1.0 : 0.0;
|
||
if (it->is_string())
|
||
{
|
||
try { return std::stod(it->get<std::string>()); }
|
||
catch (...) { return dflt; }
|
||
}
|
||
return dflt;
|
||
}
|
||
|
||
bool boolArg(const nlohmann::json& args, const char* key, bool dflt)
|
||
{
|
||
auto it = args.find(key);
|
||
if (it == args.end() || it->is_null()) return dflt;
|
||
if (it->is_boolean()) return it->get<bool>();
|
||
if (it->is_number()) return it->get<double>() != 0.0;
|
||
return dflt;
|
||
}
|
||
|
||
double clampd(double v, double lo, double hi)
|
||
{
|
||
return v < lo ? lo : (v > hi ? hi : v);
|
||
}
|
||
|
||
dwg_bvh::Box toBvhBox(const BBox& b)
|
||
{
|
||
dwg_bvh::Box r;
|
||
r.x1 = b.minX; r.y1 = b.minY; r.x2 = b.maxX; r.y2 = b.maxY;
|
||
return r;
|
||
}
|
||
|
||
// 单轴重叠比:至少一方在该轴退化为 0 → 视为完全重叠(1.0)
|
||
double axisFrac(double a1, double a2, double b1, double b2, double o1, double o2)
|
||
{
|
||
const double shorter = std::min(a2 - a1, b2 - b1);
|
||
if (shorter <= 0.0) return 1.0;
|
||
return (o2 - o1) / shorter;
|
||
}
|
||
|
||
// 可选细筛:几何是否真的相交。AEC 自定义实体未必实现 intersectWith,
|
||
// 失败 / 未实现时返回 false(调用方据此把 exact 置 false,但仍保留候选)。
|
||
bool geometryIntersects(const AcDbObjectId& idA, const AcDbObjectId& idB)
|
||
{
|
||
AcDbEntity* pa = nullptr;
|
||
if (acdbOpenObject(pa, idA, AcDb::kForRead) != Acad::eOk || !pa) return false;
|
||
|
||
AcDbEntity* pb = nullptr;
|
||
if (acdbOpenObject(pb, idB, AcDb::kForRead) != Acad::eOk || !pb)
|
||
{
|
||
pa->close();
|
||
return false;
|
||
}
|
||
|
||
bool hit = false;
|
||
try
|
||
{
|
||
AcGePoint3dArray pts;
|
||
hit = (pa->intersectWith(pb, AcDb::kOnBothOperands, pts) == Acad::eOk)
|
||
&& pts.length() > 0;
|
||
}
|
||
catch (...)
|
||
{
|
||
hit = false;
|
||
}
|
||
pb->close();
|
||
pa->close();
|
||
return hit;
|
||
}
|
||
|
||
nlohmann::json entJson(const EntInfo& e)
|
||
{
|
||
nlohmann::json j;
|
||
j["handle"] = e.handle;
|
||
j["type"] = e.type;
|
||
j["layer"] = e.layer;
|
||
j["bbox"] = bboxJson(e.box);
|
||
return j;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
class mcp_Tool_FindCollisions : public mcp_Tool
|
||
{
|
||
public:
|
||
nlohmann::json Execute(const nlohmann::json& args) override
|
||
{
|
||
CommonQuery q = parseCommonQuery(args);
|
||
|
||
const int limit = (q.limit > 0 && q.limit <= 1000) ? q.limit : 200;
|
||
const double minOverlap = clampd(numArg(args, "min_overlap", 0.0), 0.0, 1.0);
|
||
const bool sameLayer = boolArg(args, "include_same_layer", true);
|
||
const double maxAreaFrac = clampd(numArg(args, "exclude_oversized", 0.5), 0.0, 1.0);
|
||
const bool narrow = boolArg(args, "narrow", false);
|
||
// 容器过滤(图框块 / 分区边界):重叠度阈值
|
||
const int contDegMin = static_cast<int>(
|
||
clampd(numArg(args, "exclude_container_deg", 8.0), 1.0, 100000.0));
|
||
const double contDegFrac = clampd(numArg(args, "exclude_container_frac", 0.25), 0.0, 1.0);
|
||
|
||
// ---- 1) 收集实体(含包围盒) ----
|
||
std::vector<EntInfo> ents;
|
||
forEachEntity(q, [&](AcDbEntity* pEnt) -> bool
|
||
{
|
||
BBox b;
|
||
if (!entityBBox(pEnt, b) || !b.valid) return true;
|
||
|
||
EntInfo info;
|
||
info.id = pEnt->objectId();
|
||
info.handle = handleOf(pEnt);
|
||
info.type = entityTypeName(pEnt);
|
||
info.layer = layerOf(pEnt);
|
||
info.box = b;
|
||
ents.push_back(std::move(info));
|
||
return true;
|
||
});
|
||
|
||
if (ents.size() < 2)
|
||
{
|
||
nlohmann::json r = pageResult(0, 0, 0, nlohmann::json::array());
|
||
r["entity_count"] = static_cast<int>(ents.size());
|
||
r["excluded_containers"] = nlohmann::json::array();
|
||
return makeJsonResult(r);
|
||
}
|
||
|
||
// 被剔除的「容器」(图框块 / 分区边界),供审查 agent 知情
|
||
nlohmann::json containers = nlohmann::json::array();
|
||
|
||
// ---- 2) 面积预筛:剔除「过大」实体(图框块会与一切相交) ----
|
||
// 参考面积:有 window 用 window,否则用全部实体的并集
|
||
BBox ref = ents[0].box;
|
||
for (const EntInfo& e : ents)
|
||
{
|
||
if (e.box.minX < ref.minX) ref.minX = e.box.minX;
|
||
if (e.box.minY < ref.minY) ref.minY = e.box.minY;
|
||
if (e.box.maxX > ref.maxX) ref.maxX = e.box.maxX;
|
||
if (e.box.maxY > ref.maxY) ref.maxY = e.box.maxY;
|
||
}
|
||
const double refArea = (ref.maxX - ref.minX) * (ref.maxY - ref.minY);
|
||
|
||
std::vector<dwg_bvh::Primitive> prims;
|
||
std::vector<EntInfo> kept;
|
||
prims.reserve(ents.size());
|
||
kept.reserve(ents.size());
|
||
for (const EntInfo& e : ents)
|
||
{
|
||
if (refArea > 0.0)
|
||
{
|
||
const double a = (e.box.maxX - e.box.minX) * (e.box.maxY - e.box.minY);
|
||
if (a > refArea * maxAreaFrac)
|
||
{
|
||
nlohmann::json c;
|
||
c["handle"] = e.handle;
|
||
c["type"] = e.type;
|
||
c["layer"] = e.layer;
|
||
c["reason"] = "oversized";
|
||
containers.push_back(std::move(c));
|
||
continue;
|
||
}
|
||
}
|
||
prims.push_back({ toBvhBox(e.box), static_cast<int>(kept.size()) });
|
||
kept.push_back(e);
|
||
}
|
||
|
||
// ---- 3) 粗筛 ----
|
||
dwg_bvh::Tree tree;
|
||
tree.Build(prims);
|
||
std::vector<std::pair<int, int>> hits;
|
||
tree.SelfPairs(hits);
|
||
|
||
// ---- 3.5) 容器过滤:重叠度过高的实体判为图框块 / 分区边界,剔除其全部配对 ----
|
||
// 判据与图幅、缩放无关,只依赖「与多少其他实体相交」——实测分离极干净
|
||
// (图框 deg=35/25/23,其余实体全部 deg<=3)。
|
||
std::vector<int> deg(kept.size(), 0);
|
||
for (const auto& h : hits)
|
||
{
|
||
++deg[h.first];
|
||
++deg[h.second];
|
||
}
|
||
const int others = (kept.size() > 0) ? static_cast<int>(kept.size()) - 1 : 0;
|
||
const int degLimit = std::max(
|
||
contDegMin, static_cast<int>(std::ceil(contDegFrac * others)));
|
||
|
||
std::vector<char> isContainer(kept.size(), 0);
|
||
for (std::size_t i = 0; i < kept.size(); ++i)
|
||
{
|
||
if (deg[i] < degLimit) continue;
|
||
isContainer[i] = 1;
|
||
nlohmann::json c;
|
||
c["handle"] = kept[i].handle;
|
||
c["type"] = kept[i].type;
|
||
c["layer"] = kept[i].layer;
|
||
c["degree"] = deg[i];
|
||
c["reason"] = "container";
|
||
containers.push_back(std::move(c));
|
||
}
|
||
|
||
// ---- 4) 组装证据 / 过滤 / 排序 ----
|
||
std::vector<Pair> pairs;
|
||
pairs.reserve(hits.size());
|
||
for (const auto& h : hits)
|
||
{
|
||
if (isContainer[h.first] || isContainer[h.second]) continue;
|
||
const EntInfo& ea = kept[h.first];
|
||
const EntInfo& eb = kept[h.second];
|
||
if (!sameLayer && ea.layer == eb.layer) continue;
|
||
|
||
const BBox& A = ea.box;
|
||
const BBox& B = eb.box;
|
||
const double ox1 = std::max(A.minX, B.minX);
|
||
const double oy1 = std::max(A.minY, B.minY);
|
||
const double ox2 = std::min(A.maxX, B.maxX);
|
||
const double oy2 = std::min(A.maxY, B.maxY);
|
||
if (ox2 < ox1 || oy2 < oy1) continue;
|
||
|
||
Pair p;
|
||
p.a = h.first;
|
||
p.b = h.second;
|
||
p.ov.minX = ox1; p.ov.minY = oy1; p.ov.maxX = ox2; p.ov.maxY = oy2;
|
||
p.ov.valid = true;
|
||
p.ow = ox2 - ox1;
|
||
p.oh = oy2 - oy1;
|
||
p.ratio = axisFrac(A.minX, A.maxX, B.minX, B.maxX, ox1, ox2)
|
||
* axisFrac(A.minY, A.maxY, B.minY, B.maxY, oy1, oy2);
|
||
if (p.ratio < minOverlap) continue;
|
||
pairs.push_back(p);
|
||
}
|
||
|
||
std::sort(pairs.begin(), pairs.end(),
|
||
[](const Pair& x, const Pair& y) { return x.ratio > y.ratio; });
|
||
|
||
// ---- 5) 可选细筛(只对进入返回页的候选做,控制开销) ----
|
||
const int total = static_cast<int>(pairs.size());
|
||
const int from = (q.offset > 0) ? std::min(q.offset, total) : 0;
|
||
const int to = std::min(total, from + limit);
|
||
|
||
nlohmann::json items = nlohmann::json::array();
|
||
for (int i = from; i < to; ++i)
|
||
{
|
||
Pair& p = pairs[i];
|
||
nlohmann::json it;
|
||
it["a"] = entJson(kept[p.a]);
|
||
it["b"] = entJson(kept[p.b]);
|
||
it["overlap"] = {
|
||
{ "bbox", bboxJson(p.ov) },
|
||
{ "width", p.ow },
|
||
{ "height", p.oh },
|
||
{ "area", p.ow * p.oh },
|
||
{ "ratio", p.ratio }
|
||
};
|
||
it["same_layer"] = (kept[p.a].layer == kept[p.b].layer);
|
||
it["same_type"] = (kept[p.a].type == kept[p.b].type);
|
||
if (narrow)
|
||
{
|
||
p.exact = geometryIntersects(kept[p.a].id, kept[p.b].id);
|
||
it["exact"] = p.exact;
|
||
}
|
||
items.push_back(std::move(it));
|
||
}
|
||
|
||
nlohmann::json r = pageResult(total, to - from, from, items);
|
||
r["entity_count"] = static_cast<int>(kept.size());
|
||
r["excluded_containers"] = std::move(containers);
|
||
return makeJsonResult(r);
|
||
}
|
||
};
|
||
|
||
EXPORT_MCP_TOOL(mcp_Tool_FindCollisions, CreateFindCollisionsTool)
|