Files
cad-agent/src/cad_mcp_plugins/find_collisions.cpp
T
gjm d4a9442e30 feat(review): 几何干涉检查 find_collisions(BVH 粗筛 + 容器过滤)
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:<图框>},未误报
2026-10-16 19:46:44 +08:00

334 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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)