Files
envi-code/AecDbSlab-review.md
T
company-desktop 60d0b51d6c 合并 AecDbSlab 缺陷与运行效率审查文档
将 AecDbSlab-analysis.md 与 AecDbSlab-performance.md 合并为 AecDbSlab-review.md:第 1~6 章为内存泄漏/潜在 Bug/运行逻辑,第 7~12 章为运行效率(调用链、E1~E11、改造顺序、验证方法),并补充 GetRotation(Z) 恒返回 0 等跨章发现。
2026-10-26 13:38:15 +08:00

40 KiB
Raw Blame History

AecDbXSlab(AecDbSlab)审查:内存泄漏 / 潜在 Bug / 运行逻辑 / 运行效率

范围:SourceCode/Code2026/CoreLibrary/AecDbSlab.cpp(5115 行)、SourceCode/Code2026/inc/AecDbSlab.h(172 行)。 注意:文件名是 AecDbSlab,类名是 AecDbXSlab(ACRX_DXF_DEFINE_MEMBERS(AecDbXSlab, AecDbBDPipeBase, ...)),全局搜索时两种名字都要试。 继承链:AecDbXSlab → AecDbBDPipeBase → AecDbEwPipeBase → AecDbCurveBase → AecDbEntityBase。 分析基线:main @ d1bdf28。下文行号均为分析时的 AecDbSlab.cpp。 合并说明:本文由原 AecDbSlab-analysis.md(第 16 章)与 AecDbSlab-performance.md(第 712 章)合并而成。

目录: 1 数据模型 · 2 内存泄漏 · 3 潜在 Bug · 4 运行逻辑问题 · 5 已核对确认不是问题 · 6 缺陷修复顺序 7 效率结论速览 · 8 一帧调用链 · 9 逐项分析 E1~E11 · 10 效率改造顺序 · 11 顺带发现 · 12 验证方法

1. 数据模型(读代码前先建立的三个前提)

一个板实体同时持有四份"几何真相",任何一处不同步就会表现为画错/夹点错/属性错:

成员 作用 关键约定
m_pBaseCurve(基类,mutable AcDbCurve*) 拉伸路径 / 类墙体板的定位线 必须是真实位置;长度即板厚(类墙体板则是截面高度)
m_pRegion(基类,AcDbRegion*) 截面 一般在原点(SetSection(pReg, ptCen) 只记中心,不做平移)
m_mapSpecXData / GetSpecialtyData() 参数(KEY_SHAPE/KEY_ORGPT/KEY_BOMTHICK/KEY_POINTS…) GetSpecialtyData() 返回的就是 &m_mapSpecXData,两者等价
m_arX3DPartPtrs(AecDbBDPipeBase) 附属 3D 体(水池堰墙) 裸指针数组,析构时 delete;AddX3dPart 接管所有权

ESection 决定后续所有分支:eRect / eCircle / eEllispe / eUserDef / eXShape(截面拉伸)。 板还有两种"身份"由 CEntityProperty::getSlabType() 区分:eDB(水池底板)、eLB(楼板)、eGB(盖板)、eXDB(斜底板),属性面板与夹点逻辑都按它分叉。

调用面

  • 创建:SetRectSlab / SetCircleSlab / SetSectorSlab / SetRegionSlab / SetRectBasin / SetCircleBasin / SetEllipseBasin / SetFenceBasin / SetExtrudeBasin;
  • 交互:subGetGripPoints / subMoveGripPointsAt / subGetStretchPoints / subMoveStretchPointsAt;
  • 显示:Draw2d / Draw3d / DrawHatch / DrawXShape2d / PreprocessForHlr;
  • 交换:dwgIn/OutFields、subDeepClone / subWblockClone、ToJson/ByJson、GetProperties/SetProperties。

2. 内存泄漏

L1 严重·高频:GetOffsetCurve 失败路径整批泄漏(:147-206)

第一个重载在 arCurOff1.length()==1 && arCurOff2.length()==1 不成立时直接 return NULL;,两个 AcDbVoidPtrArray 里的 offset 曲线(getOffsetCurves 产出、调用方拥有)全部泄漏。第二个重载(:209-257)在同样失败时调了 FreeBuffer,但成功分支 return pCurve1/pCurve2 时另一个数组元素已 delete,逻辑正确——两个重载行为不一致,说明第一个是漏写的。

修法:第一个重载失败分支补 FreeBuffer(0,arCurOff1); FreeBuffer(0,arCurOff2);。

L2 严重·每次拆墙:CreatePipeRegion 成功路径漏内环 region(:259-299)

AcDbRegion* pRegIns = (AcDbRegion*)arRegPtrs.first();
if (Acad::eOk == pRegOut->booleanOper(AcDb::kBoolSubtract,pRegIns))
    return pRegOut;      // pRegIns 从未释放
return NULL;             // 失败时 pRegOut 也漏

booleanOper 不消耗第二个操作数(本仓库 AecDbEwPipeBase.cpp、AecDbEwFitting.cpp 都遵循"成功后手动 delete 第二个操作数"的惯例)。椭圆水池每次 SetEllipseBasin 建墙都漏一个 region。

L3 严重·每次命中判断:IsPointInSide 漏 polyline(:1760-1785)

polyline 分支算完 bIn 后直接 return bIn>0,pShapeCurve(GetBasinShapeCurve 新建的 polyline)没有 delete,代码里还留着 //delete pPl; 的注释。圆/椭圆分支各自 delete 了,只有 polyline 分支漏——异形板的点选/包含判断每次漏一条曲线。

L4 严重·每次重绘:Draw2d 圆板漏 circle(:2354-2390)

AcDbCircle* pCir = AcDbCircle::cast(pShape);   // :2362
if (pCir != NULL) { ... dc.DrawEntity(pPl); Add2DBuffer(pPl); }   // pShape 既不进 buffer 也不 delete

Add2DBuffer 接管所有权(Clean2DBuffer 会 delete),但这条分支只把新建的 pPl 加进 buffer,原始 pShape(circle)被丢弃。带出图宽度(KEY_PLOTW>1e-3)的圆板每次重绘漏一个 AcDbCircle。:2293 那个"无 KEY_ORGPT"分支同理:非 polyline 的 pShape 不进 buffer 也没释放。

L5 中·每次重设异形板:SetRegionSlab(LPCTSTR) explode 碎片全漏(:922-946)

pPl->explode(arCurPtrs);              // 产出 N 个 line/arc
AcDbRegion::createFromCurves(arCurPtrs,arRegPtrs);
...
delete pPl;                           // 只删了 polyline,碎片既不 close 也不 delete

每次重设异形板漏 N 个曲线实体(N = 顶点数)。

L6 中:SetEllipseBasin 两处泄漏(:1273、:1330)

  • pEllipse->getOffsetCurves(dWallWidth,arCurPtrs) 产出的 offset 曲线只用于建 region,之后从不释放;
  • arRegPtrs 多于 1 个时只取 first(),其余泄漏;
  • 叠加 L1 的 GetOffsetCurve 泄漏(pOutSide 只删了成功返回的那个)。

L7 中:PeelBasinWall 拆墙后碎片泄漏(:1405-1550)

pBaseCrv 为 polyline 时 explode(arPtrs) 得到碎片,逐个 pWall->SetBaseCurve(pBaseCrv)(内部 clone)后 碎片全部不释放;多个墙体 part 时 pBaseCrv 被覆盖,前一个曲线也漏。拆一次墙漏 N 个实体。

L8 轻:SetProperties eXDB 分支漏 pXYPoly(:5095-5101)

AcDbPolyline *pXYPoly = CoreTools::StringToPolyline(sProfile);
if (pXYPoly) { ... SetSection(pXYPoly, ptCen); }   // SetSection(AcDbPolyline*) 不接管所有权

对比 SetSection(AcDbRegion*) 内部会 clone()——polyline 版本只 createFromCurves,指针用完就该删。

L9 轻:DrawHatch 早退漏 clone 组(:4640-4660)

pEnt->isA()->create() + CopyObject 造的一组 clone 交给 ConvertBoundCurve,正常路径由 FreeBuffer(0,arRelt) 释放;但 if (!pCurve) return; 在 FreeBuffer 之前,此时 arEntClnPtrs 里的 clone 全部泄漏。

3. 潜在 Bug(崩溃 / 死循环 / 未定义行为)

B1 严重:FreeBuffer 遇到 NULL 元素死循环(:56-70)

while(arEntPtrs.length() > n)
{
    pEnt = (AcDbEntity*)arEntPtrs.last();
    if (pEnt != NULL) { pEnt->erase(); pEnt->close(); arEntPtrs.removeLast(); delete pEnt; }
}

removeLast() 在 if 内部——只要数组里有一个 NULL,length() 永远不减,CAD 直接卡死。同一文件里 DrawHatch、Disperse、GetOffsetCurve、DrawHatch 的 arRelt 都走它。

另外 erase()/close() 对 非数据库常驻实体(explode/offset 产出的)没有意义,本仓库 AecDbBDPipeBase.cpp:48 的同名 FreeBuffer 就只 DELETE_PTR——同名不同义,容易误改。

修法:removeLast() 提到 if 外;删掉 erase()/close(),与基类版本保持一致。

B2 严重:ClosestPointToCen 无判空解引用 m_pRegion(:1868、:1906)

两个分支都是 m_pRegion->clone(),没有 if (!m_pRegion)。而 dwgInFields(:2499)在 m_pRegion == NULL 时的重建逻辑里写了 if (shape == eCircle) break;——圆板 region 丢失时直接放弃重建,之后任何一次 ClosestPointToCen 都会崩。这个兜底注释写的是"除了圆板,其他都重建",恰恰把最容易丢的圆板排除了。

修法:m_pRegion 为空时按 KEY_SHAPE 现场重建(圆板用 KEY_RAD 造 AcDbCircle → region),或至少让 ClosestPointToCen 返回 false。

B3 严重:GetBasinShapeCurve eUserDef 分支空指针(:2118-2124)

AcDbPolyline* pPl = PointsScrToPolyline(sPoint2dsTop);
if (pPl != NULL) { pShapeCrv = pPl; }
pPl->setClosed(Adesk::kTrue);       // ← 判空之后仍然无条件解引用
pPl->setConstantWidth(dPlotW);

KEY_POINTS 为空/格式损坏(旧图、被外部程序改过)时必崩,且这条路径被 IsPointInSide、subGetStretchPoints、subMoveStretchPointsAt、Draw2d 全都调用。

B4 中:SetRegionSlab(AcGePoint2dArray&) 未判空数组(:858)

arPts.last() / arPts.first() 直接用,调用方传空数组即崩。

B5 中:subGetGripPoints 三处未校验(:2594-2680)

  • gripPoints.at(0)(bitflags>=1394 分支)没判空,ImpGetGripPoints 返回空时越界;
  • m_pGripAppData[i] 以 i < gripPoints.length() 索引,数组容量 GRIP_LENGTH=256(EarThwormDef.h:162)——异形板顶点 > 250 时越界写,memset(...,sizeof(AecGripData)) 一次踩上百字节;
  • _tcscpy(m_pGripAppData[i].szID, m_sGUID) 与 szTip 无长度校验(GUID 定长,暂时安全,但 szTip 是拼接出来的中文提示)。

B6 中:PreprocessForHlr clone 未判空(:4491-4493)

AcDbHatch *pNewH = (AcDbHatch*)pHatch->clone(); pNewH->setElevation(...) —— clone 失败即空指针解引用。

B7 中:SetCircleSlab(..., FALSE) 把基曲线留在了原点(:4062)

CircleImpMoveGripPointsAt 拖半径后调 SetCircleSlab(dR, dBomThick, ptOrg.z, ptOrg, FALSE)。bCenptAsMove=FALSE 时 SetCircleSlab 不会把 ptCenPos 加到基曲线上,基曲线停在 (0,0,dBomEl)。这违反头文件里"板的拉伸线必须是真实位置"的约定——后果是 3D 拉伸体跑到原点、2D/3D 错位,且 GetElBom() 仍然对(读的是 z),很难一眼发现。

B8 中:SetProperties 把"堰墙壁厚"写进了 KEY_HEIGHT(:4921-4922)

properties.findAndSet<double>(_T("堰墙高度"), pData, KEY_HEIGHT);
properties.findAndSet<double>(_T("堰墙壁厚"), pData, KEY_HEIGHT);   // 应为 KEY_THICK

面板上改"堰墙壁厚"会覆盖"堰墙高度",壁厚本身不变——属于静默数据损坏。

B9 中:SetCircleBasin / SetRectBasin / SetFenceBasin 只在有墙时清旧墙(:1013、:1106、:1235)

RemoveAllX3dPart() 写在 if (dWallHeight>1e-3 && dWallWidth>1e-3) 内部,而 SetEllipseBasin 根本不调用。后果:

  • 先建"带堰墙"的池,再以 0 壁厚重建 → 旧墙体 part 残留,属性显示无墙但 3D 有墙;
  • 椭圆池每次改长轴/短轴/壁厚都 new AecDbX3dPart 并 AddX3dPart,墙体 part 不断累积(拖动长轴夹点几下就能叠出十几个)。

B10 轻:PeelBasinWall 标高在循环内累加(:1444、:1461、:1485)

三个分支都写 ptCenPos.z += dBomThick;,而 ptCenPos 是循环外变量。多个墙体 part 时第 2 个起标高就多抬一个板厚。

4. 运行逻辑问题

S1 半径/尺寸口径三处不一致(圆池)

位置 用的半径 结果
SetCircleBasin(:1184) SetSection((dR+0.5w)*2),KEY_RAD=dR 截面外径 = 2dR+w
GetBasinShapeCurve(:2094) dR += dWallWidth 2D 轮廓半径 = dR+w(比截面大 0.5w)
PeelBasinWall(:1457) dR = KEY_RAD-0.5w,之后 KEY_RAD += 0.5w 墙中心线 = dR-0.5w

三处各自自洽、合起来不自洽:2D 轮廓永远比 3D 截面大半个壁厚,IsPointInSide 的容差也随之偏大;KEY_RAD 的语义(外径?内径?)在读写之间来回切换,任何一次属性面板改写都会让三者漂移。建议:固定 KEY_RAD = 外半径、截面 2*KEY_RAD、墙中心线 KEY_RAD-0.5w,GetBasinShapeCurve 不再加壁厚。

S2 矩形池与圆形池的"尺寸含义"不同(:1046 vs :1184)

SetRectBasin 的截面是 dL×dW(不含墙),墙沿 ±dL/2 向外长;SetCircleBasin 的截面已经把半个壁厚算进去。同一套 UI 里输入的"长/宽/直径"含义不一致。

S3 类墙体板:SetRectSlab 把 dBomEl 硬编码成 0(:388-397)

SetBaseCurve(&ln);            // z = dBomEl
SetAsWallData(dW,dThick,0.0); // ← dBomEl 传 0

SetAsWallData 里 m_mapSpecXData.SetAt(PIPE_EL, dBomEl) → PIPE_EL 恒为 0;GetAsWallData().dBomEl 永远是 0,只有基曲线 z 是对的。另外 SetAsWallData 内部 ptGe[0].y += (dBomEl + 0.5*dH); 之后又 += 0.5*dH;,等价于 y = dBomEl + dH——一旦有人传非 0 的 dBomEl,截面会整体偏一个 dH。

S4 类墙体板忽略 SetAsWallData 的失败返回(:388-397)

SetAsWallData 在 dThick<1e-3 || dH<1e-3 时返回 false,SetRectSlab 不检查、照样 return true。此时实体处于"有基曲线、无截面、无 KEY_*"的半初始化状态,后续 GetThick 走 KEY_SIZE2 兜底返回 0,Draw2d 走"无 KEY_ORGPT"分支。

S5 扇形板被记成圆板(:807)

SetSectorSlab 里 SetAt(KEY_SHAPE, eCircle)。之后 GetBasinShapeCurve 返回整圆(半径 dR),夹点、面积、体积、IsPointInSide 全按整圆算——扇形板的 1/4 或 1/2 面积凭空变大。应新增 eSector 或在 KEY_* 里存起止角并让 GetBasinShapeCurve 生成 AcDbArc + 两条半径。

S6 每次 MOVE/ROTATE 都让 KEY_POINTS 多一个顶点(:633-666 + CoreTools.cpp:3235)

subTransformBy 把 KEY_POINTS 解析成 polyline → setClosed(kTrue) → PolylineToString 回写。而 PolylineToString 对已闭合的 polyline 会再补一次首点,于是每变换一次顶点数 +1(首点重复)。板被反复移动后 KEY_POINTS 不断膨胀,region 重建(UserDefImpMoveGripPointsAt、subMoveStretchPointsAt)越来越慢,重复点还可能让 createFromCurves 失败。

修法:回写前 pPl->setClosed(kFalse)(或改用不追加首点的重载),只在写入时保持"末点=首点"。

S7 subTransformBy 在基类之前早退(:637)

if (!m_pRegion) return Acad::eOk;     // 基类 subTransformBy 还没调用
AecDbBDPipeBase::subTransformBy(xform);

截面丢失的板 MOVE/ROTATE 完全无效(基曲线、KEY_ORGPT、PIPE_EL 都不动),且没有任何提示。应先调基类,再按需处理 region。

S8 夹点"改厚度"方向判断依赖 Y 分量为 0(:3530、:3719)

AcGeVector3d vtZOffset = vtOffset.orthoProject(AcGeVector3d::kXAxis);  // 得到的是 YZ 分量,不是 Z 分量
...
if (vtZOffset.isCodirectionalTo(AcGeVector3d::kZAxis)) dThick += dLen; else dThick -= dLen;

orthoProject(kZAxis) 才是"垂直于 Z",所以上面这行本意是取 Z 分量却传了 kXAxis。只要拖动向量带 Y 分量(3D 视图、非正交拖动),方向判断恒为"减薄",改标高夹点也会斜着跑。修法:vtZOffset = vtOffset.dotProduct(AcGeVector3d::kZAxis) * AcGeVector3d::kZAxis;。

S9 多夹点拉伸被静默丢弃(:3415)

if (gripAppData.length() > 1) return Acad::eOk;——框选多个夹点一起拉,板一动不动,也不报错。要么支持批量,要么至少 acutPrintf 提示。

S10 subMoveGripPointsAt 形状夹点处理完不 return(:3465-3500)

if (pAppData->nIndex > 3) { switch(eShape){...} } 之后没有 return,继续落入下面的 switch(nGripIndex)(case 0/1/2/3 + default 空)。目前靠 default 兜住,一旦有人给 default 加逻辑,形状夹点就会被二次变换。

S11 SubAddDimPoints 闭合段标注是死代码(:2736、:2883)

for (ii = 0; ii < pPl->numVerts()-1; ii++) 内部又判断 if (ii == pPl->numVerts()-1)——恒为 false,最后一条边的尺寸永远不标注。

S12 两种厚度来源并存(:361、:1585、:2925)

GetThick():有 KEY_ORGPT 时读基曲线长度,否则读 KEY_SIZE2(类墙体板的截面高度); SetElBom():用 ptEnd.distanceTo(ptSta) 当"长度"来反推顶面; KEY_BOMTHICK:属性面板与 GetBimInfoTip 用的厚度。 三者不一致时(例如基曲线被夹点改过、KEY_BOMTHICK 未同步),提示框、属性面板、3D 实体会各说各话。

S13 Draw2d 圆板出图宽度的 polyline 几何不对(:2362-2385)

只建 3 个顶点(右→左→右),前两点带 tan(π/4) bulge,第三点回起点,实际画出来是"半圆+直线"而不是圆环线宽表达;且这条 pPl 进了 2D buffer,后续 DrawHatch 会把它当边界参与 ConvertBoundCurve。

S14 Draw2d 拖动时仍走完整重建(:2325)

if (dc.IsDragging()) { //return Acad::eOk; } 是空实现,拖动预览每一帧都重建 2D 缓存并 Add2DBuffer,大板拖拽时明显掉帧。

S15 ComparedTo 漏比形状与长短轴比(:4404)

只比 m_dBomThick / m_dBomEl / m_dData[0..2](L、W、R),不比 m_nShape 与 m_dData[3](radiusRatio)。长宽相同的矩形板和椭圆板会被判为"相同",EQUAL 类命令可能误合并。

S16 DXF 往返丢数据(:2584-2592)

dxfInFields / dxfOutFields 都是 return Acad::eOk; 空实现。存成 DXF 再打开,板的所有私有数据(含 region、KEY_*)全丢,只剩基类能恢复的部分。

S17 dwgInFields v1 分支不校验长度(:2479-2488)

pFiler->readUInt32(&nLen) 后直接 for (i<nLen) 读软指针,坏文件/恶意文件可让循环跑几十亿次。

S18 调试残留会污染图纸(:247-252)

GetOffsetCurve 失败时 acutPrintf("\nOffset error:%d") + 克隆一条红色曲线 AddToModelSpace。这是开发期排查手段,生产环境表现为"用户图里莫名多一条红曲线"。

S19 文案与键名错误(多处)

  • 材质硬编码 "钢筋混土"(应为"钢筋混凝土")出现在 8 处,属性面板与材料统计都会显示错别字;
  • 墙体 part 的键名 _T("POIINTS")(双 I)与 KEY_POINTS 不是同一个键:写端/读端(PeelBasinWall)一致所以能工作,但拆墙后无法复用 KEY_POINTS,走 eRect 分支重建时轮廓来源会变;
  • subList(:2217)acutPrintf(_T("\n体积: 立方"), GetVolume()/1.e9) 缺 %f,体积永远不打印。

5. 已核对、确认不是问题的点

避免后续重复排查:

  1. SetProperties 里 AcDbLine* pLn = AcDbLine::cast(GetBaseCurve()); ... SetBaseCurve(pLn); delete pLn; —— 不是 double free。AecDbEwPipeBase::GetBaseCurve()(AecDbEwPipeBase.cpp:2140)返回 m_pBaseCurve->clone(),SetBaseCurve(const AcDbCurve*)(:4842)内部再 clone 一次并删旧指针,删的是克隆,正确。
  2. SetSection(pReg, ptCen) 后紧跟 delete pReg —— 正确,SetSection(AcDbRegion*)(AecDbEwPipeBase.cpp:5174)内部 pReg->clone()。
  3. 板内 subTransformBy 变换 m_pRegion —— 不是重复变换,基类 AecDbEwPipeBase::subTransformBy(:1789)只动基曲线和 KEY_ORGPT/PIPE_EL。
  4. GetSpecialtyData() 与 m_mapSpecXData 是同一对象(AecDbEwPipeBase.cpp:6297 返回 &m_mapSpecXData),混用只是风格问题。
  5. m_pRegion 由基类序列化(SectionRegionDwgInFields/DwgOutFields),正常存盘的圆板 region 不为空;B2 只在数据损坏/旧版本图纸时触发。
  6. SetRegionSlab 写入的 [标高;法向] 后缀,CoreTools::StringToPolyline(CoreTools.cpp:3420-3437)确实会解析回 elevation/normal,PolylineToString(:3255)也会写回,不丢。
  7. m_pGripAppData 容量 256,常规矩形(8 个夹点)、圆形(5 个)远低于上限。

6. 缺陷修复顺序

优先级 项 理由
P0 B1(FreeBuffer 死循环)、B2(m_pRegion 判空)、B3(eUserDef 空指针)、B8(KEY_HEIGHT 覆盖)、S7(早退)、S6(KEY_POINTS 膨胀) 卡死/崩溃/静默数据损坏,改动小、可独立验证
P1 L1~L7(高频泄漏)、B7(基曲线回原点)、B9(旧墙残留/累积) 连续布板、反复拖夹点的场景下累积明显
P2 S1~S5、S8、S9(口径与交互一致性) 需要与产品确认尺寸语义,改动面较大
P3 L8、L9、B10、S10~S19(清理与文案) 低风险,可随手带上

验证方式:本仓库无单测,改完按 CODEBUDDY.md 的约定编 Debug + R190(cmake --preset R190 && cmake --build --preset R190 --config Debug --target CoreLibrary_BAL_REMOTE),在 CAD 里加载后依次走:建矩形板/圆板/异形板/水池底板 → 拖各类夹点 → MOVE/ROTATE → 复制/镜像 → 存盘重开 → 拆堰墙 → 出图(HATCH)→ 消隐剖面。效率类改动的度量方法与基准图见第 12 章。


以下 6 章(第 712 章)为运行效率部分:先给热点结论,再拆一帧内的调用链,然后逐项分析 E1E11,最后给效率改造顺序与验证方法。行号同样基于 main @ d1bdf28,缺陷编号(L/B/S)沿用第 2~4 章。

7. 效率结论速览

# 热点 触发频率 单次代价 影响面
E1 AssertWriteEnabled 每次写使能清空 2D/3D 缓存 每个 setter、拖动每帧 释放全部缓存实体 + 下一帧全量重建 全局,最大
E2 DrawHatch 每帧克隆全部 2D 实体 + 布尔求边界 + evaluateHatch + 逐个打开洞口对象 每次重绘 O(顶点数) 堆分配 + ACIS 布尔 + N 次 DB 打开 重绘/拖动
E3 拖半径/长轴夹点时 SetCircleBasin/SetEllipseBasin 全量重建 region 与 3D 墙体 拖动每帧 1 次 createFromCurves + 1 次 AcDb3dSolid::Create + 1 次 kBoolUnite 交互手感
E4 GetBasinShapeCurve 每次 new 曲线 + 连做 3 次 transformBy 重绘、夹点查询、点选、拉伸 1 次堆分配 + 3×O(n) 顶点变换 全局
E5 KEY_POINTS 字符串 ⇄ polyline 反复解析;gripPoints.find O(n²) 夹点查询 / 拖动 / 变换 O(n) 字符串切分,异形板 O(n²) 去重 异形板
E6 IsPointInSide 里 Disperse(*pPl,50,...) + 模型空间逐实体调用 每次点选/拾取 explode + 每弧段 50 份 + 2 个 float 数组 大图拾取
E7 夹点与动态标注数量随顶点线性增长(dim grip ≈ 顶点数) 每次选中/悬停 每夹点 new AcDbGripData + 256B memset 大异形板
E8 GetBimInfoTip 经 GetArea()/GetVolume() 触发 3D 重建 每次悬停提示 一次完整 3D 建模 悬停
E9 热路径上几十次 GetAt(_T("...")):临时 CString + std::map 查找 每帧 每次 1 次小堆分配 + O(log n) 宽字符比较 全局
E10 Add2DBuffer 线性去重 每次加入缓存 O(缓存长度) 指针比较 轻微

一句话:这个类的性能问题不在算法,而在"每次写使能就把所有缓存扔掉"这条基类约定,叠加"重绘时现算填充、拖动时现建 ACIS 实体"。

8. 一帧到底跑了什么

8.1 拖动半径夹点(最坏路径)

subMoveGripPointsAt(3410)
 ├─ AssertWriteEnabled()                       → AecDbEwPipeBase.cpp:4755
 │    ├─ Clean2DBuffer()                       → 释放上一帧全部 2D 实体
 │    ├─ Clean3DBuffer()                       → 释放上一帧全部 3D 实体(含 3D solid)
 │    └─ FreeBuffer(0, m_ar2DDrableBufPtrs2)
 └─ CircleImpMoveGripPointsAt(4013)
      └─ SetCircleBasin(1184)
           ├─ AssertWriteEnabled()              → 再清一次缓存
           ├─ SetSection(2R, 0)                 → AecDbEwPipeBase.cpp:4924,createFromCurves(ACIS)
           ├─ SetBaseCurve(&ln)                 → AecDbEwPipeBase.cpp:4842,clone + 再清缓存
           ├─ SetAt(KEY_ORGPT/KEY_RAD/...) ×7   → map 写入
           ├─ RemoveAllX3dPart()
           ├─ new AecDbX3dPart + new AcDbX3dSolid
           └─ Create(kPipe) + AddX3dSolid(kBoolUnite)   → ACIS 建模 + 布尔

下一帧重绘又要把上面清掉的 2D/3D 全部重建一遍。拖一次半径 = 每帧一次 ACIS 建模 + 一次 region 布尔 + 一次 3D 实体布尔。

8.2 静止重绘

Draw2d(2251)
 ├─ DrawDTYG2D(dc)                    → AecDbBDPipeBase.cpp:278,有缓存则直接画
 ├─ Has2DBuffer()(2336) 为假时:
 │    ├─ GetBasinShapeCurve(TRUE)(1954)   → new 曲线 + 3 次 transformBy
 │    ├─ dc.DrawEntity + Add2DBuffer
 │    └─ DrawHatch(4621)
 │         ├─ for 每个 2D 实体: isA()->create() + CopyObject() + SetZeroEl
 │         ├─ ConvertBoundCurve(...)      → 内部 region 布尔
 │         ├─ ::DrawHatch(dc, sHatch, pPl) → 新建 hatch
 │         ├─ for 每个关联洞口: AcDbObjectPointer 打开 + 解析 Points 字符串 + appendLoop
 │         └─ evaluateHatch()             → 图案求交
 └─ 有缓存时:只遍历缓存 DrawEntity

静止场景靠缓存还能撑住;只要发生任何写操作(拖夹点、改属性、MOVE),缓存全废,下一帧走完整路径。

8.3 选中 / 悬停

subGetGripPoints(2594)
 ├─ ImpGetGripPoints(3109)      → 异形板:解析 KEY_POINTS + getGeomExtents + 每顶点 find 去重
 ├─ 每个夹点:memset(256B) + new AcDbGripData + 3 个回调赋值
 └─ SubAddDimPoints(2681)       → 异形板:**再解析一次** KEY_POINTS + 每段 getFirstDeriv + 每段一个 dim grip

8.4 点选(外部驱动)

Pool3dCmd/pool3dcmdCmd.cpp:1678 在模型空间迭代里对每个可见 AecDbXSlab 调 IsPointInSide:

IsPointInSide(1712)
 └─ GetBasinShapeCurve(true)          → 又一次 new + 3 次 transformBy
      └─ 折线分支:Disperse(*pPl,50,...)(1757)
           ├─ poly.explode(arCurSegs)  → 每个段一个实体(矩形板也要 explode)
           ├─ 每段弧按 50 份采样
           └─ 两个 float 数组 + 逐点 getPointAt

9. 逐项分析(E1~E11)

E1(最高优先级)写使能即清缓存

AecDbEwPipeBase::AssertWriteEnabled(CoreLibrary/AecDbEwPipeBase.cpp:4755)无条件执行:

Clean2DBuffer();
Clean3DBuffer();
FreeBuffer(0, m_ar2DDrableBufPtrs2);
AecDbCurveBase::AssertWriteEnabled(autoUndo, recordModified);

而 slab 的每个 setter、每次夹点移动都会走到它。后果:

  • 拖动时 2D 缓存每帧失效,Draw2d 里 Has2DBuffer() 的早退分支形同虚设(AecDbSlab.cpp:2336);
  • 3D 缓存同样每帧失效,GetArea()(AecRxBase/AecDbCurveBase.cpp:1156,缓存空时先 Draw3d)、GetVolume()(AecDbEwPipeBase.cpp:6369)以及 HLR 预处理都要重建;
  • PreprocessForHlr(4461)依赖 2D 缓存取 hatch,缓存一空就现场 Draw2d,等于消隐时再跑一遍 1.2 的全流程。

改法:把清缓存从 AssertWriteEnabled 移到真正改几何的入口(SetBaseCurve、SetSection、subTransformBy、各 SetXxxSlab/SetXxxBasin),或者加一个 m_bBatchEdit 在拖动会话期间抑制清理、松开时清一次。 风险:这是基类行为,AecDbEwPipe、AecDbEwFitting、AecDbECFitting 等 20+ 处依赖它(仓库里有 20 余处显式 Clean2DBuffer() 调用,说明本来就有"显式清理"的先例)。改动需要逐类回归,建议先只给 slab 加抑制开关,不动基类。

E2 DrawHatch 每帧全量重建

AecDbSlab.cpp:4621。每次重绘都:

  1. 遍历 2D 缓存,对每个实体 pEnt->isA()->create() + CopyObject 造克隆(4633-4647);
  2. KTCADUtilityEx::ConvertBoundCurve 求外边界(内部是 region 布尔);
  3. ::DrawHatch 新建 AcDbHatch;
  4. 遍历 m_arAssocPartEntIds,每个洞口 AcDbObjectPointer<AecDbX3dPartEntity> 打开一次数据库对象,再解析 Points 字符串、appendLoop;
  5. evaluateHatch()。

一个带 3 个洞口的水池底板,重绘一次 = 3 次 DB 打开 + 3 次字符串解析 + 1 次布尔 + 1 次图案求交。

改法:

  • 把 AcDbHatch* 结果缓存成成员,几何 dirty 才重建(配合 E1,dirty 标志天然可用);
  • 拖动期间(dc.IsDragging())直接跳过洞口 loop 与 evaluateHatch,只画外轮廓;
  • 洞口 AcDbObjectId 与解析后的 AcDbPolyline 缓存在实体上,避免每帧打开对象。

顺带:DELETE_PTR(pCurve) 在 4660 与 4732 出现两次(宏会置空,不崩,但说明收尾逻辑没理顺);pPl 与 pCurve 是同一指针,4660 之后 pPl 已悬空,目前恰好没再用到。

E3 拖动时全量重建 region 与 3D 墙体

CircleImpMoveGripPointsAt(4013)与 EllispeImpMoveGripPointsAt(4068)在每次鼠标移动时直接调 SetCircleBasin / SetEllipseBasin:

  • SetCircleBasin(1184):SetSection(2R,0) → createFromCurves;SetBaseCurve → clone;带墙时 RemoveAllX3dPart + new AcDbX3dPart + new AcDbX3dSolid + Create(kPipe) + kBoolUnite;
  • SetEllipseBasin(1262):额外 getOffsetCurves + region 布尔(并有泄漏,见 2 章 L6)。

拖 60 帧 = 60 次 ACIS 建模 + 60 次布尔。这是"拖半径时明显卡顿、内存上涨"的直接原因。

改法:拆成两条路径——

  • 轻量:只改 KEY_RAD/KEY_RRATIO + 基曲线 + 对已有 region 施加缩放矩阵(圆/椭圆 region 支持 transformBy),不重建;
  • 重量:松开夹点(或 bitflags 表明非拖动)时才 SetCircleBasin 全量重建。 墙体 AecDbX3dPart 复用已有 part,只改其参数,不 new。

E4 GetBasinShapeCurve 的分配与三次变换

AecDbSlab.cpp:1954。每次调用:

  • new AcDbPolyline / new AcDbCircle / new AcDbEllipse(调用方负责 delete,多数分支确实删了,异形板分支漏删见 L3);
  • 末尾固定做三次 transformBy(2137-2148):X 轴、Y 轴、Z 轴各一次,即使 m_dRotate[] 全为 0 也照做。

对 n 顶点异形板,一次调用 = 3n 次顶点矩阵乘 + 3 次曲线内部缓存失效。而这个函数在重绘、IsPointInSide、subGetStretchPoints、subMoveStretchPointsAt、SubAddDimPoints 路径上都会被调。

改法:

  • 三个角度都接近 0 时整体跳过;否则用 AcGeMatrix3d 相乘合成一个矩阵,只 transformBy 一次;
  • 对圆/椭圆且仅 Z 旋转时,直接改 center/majorAxis,不必变换整条曲线。

E5 KEY_POINTS 字符串往返 + O(n²) 去重

异形板的顶点以字符串存在 KEY_POINTS(PointsScrToPolyline,108)。热点在于解析次数:

场景 解析次数
选中一次(subGetGripPoints) 2 次(ImpGetGripPoints 3240 一次、SubAddDimPoints 2867 一次)
拖动一个顶点 1 次解析 + 1 次序列化(UserDefImpMoveGripPointsAt 4151)
MOVE/ROTATE 1 次解析 + 1 次序列化(subTransformBy 633)
重绘(缓存失效时) 1 次(GetBasinShapeCurve 2118)

解析本身是 CoreTools::StringToPolyline(失败再退化到 SplitStrintToArray + _ttof),O(n) 次 CString::Find + 子串分配;序列化 PolylineToString 也是逐点 Format 拼接。

另外 ImpGetGripPoints 里:

for (int i = 0;i < pPl->numVerts();i++) {
    ...
    if (gripPoints.find(ptGeT) == -1)   // :3240,AcGePoint3dArray 线性查找
        gripPoints.append(ptGeT);
}

顶点越多越慢,是 O(n²);而夹点顺序本来就对应顶点顺序,这个去重没有必要(重复点只会来自数据损坏)。

改法:

  • 在实体上加 mutable AcGePoint2dArray m_arCachePts; mutable bool m_bPtsDirty;,解析一次后复用;SetAt(KEY_POINTS) 时置 dirty;
  • 去掉 find 去重(或改用 std::unordered_set<int64> 存量化坐标);
  • subTransformBy 只在真正发生变换时序列化(当前无条件回写,且因 S6 的闭合点重复,串会越写越长,代价随时间上升)。

E6 IsPointInSide 的分化与调用位置

AecDbSlab.cpp:1712。折线分支(1757):

AcDbPolyline polyBoundry;
Disperse(*pPl,50,polyBoundry);       // explode + 每弧段 50 份
int nPts = polyBoundry.numVerts();
float* pfPtx = new float[nPts];      // 两个临时数组
float* pfPty = new float[nPts];

Disperse(1628)第一件事就是 poly.explode(arCurSegs)——矩形板(无 bulge)也照 explode 一遍,纯浪费。50 份/弧 的精度是写死的,与当前视图缩放无关。

调用方在 Pool3dCmd/pool3dcmdCmd.cpp:1678:模型空间迭代中对每个可见板调用一次。50 块板的图,一次拾取 = 50 次曲线构造 + 50 次 explode。

改法:

  • 先用包围盒粗筛(KEY_ORGPT + KEY_Length/Width/RAD,或缓存 extents),不相交直接返回 false;
  • 矩形/圆用解析判断(当前圆分支已经是解析的),只有带 bulge 的异形板才走 Disperse;
  • nCirDiv 按当前视口像素尺寸自适应,而不是固定 50;
  • 用 AcGePoint2dArray + 直接遍历,避免两个 float 数组(顺带解决 float 精度问题,见 11.3)。

E7 夹点与动态标注的规模

subGetGripPoints(2594)每个夹点都:

memset(&m_pGripAppData[i],0,sizeof(AecGripData));   // 256 项数组里的一项
pGripData = new AcDbGripData;                       // 每夹点一次堆分配
pGripData->setGripPoint/setAppData/setWorldDraw/setToolTipFunc
_tcscpy(szTip,...); _tcscpy(szID,...)

SubAddDimPoints 又为每段加一个 dim grip,于是 n 顶点异形板的夹点数 ≈ 3 + n + n。200 顶点的板选中一次 = 400+ 次 new AcDbGripData + 400 次 256B memset + 400 次回调设置,之后宿主还要逐个绘制夹点。

改法:dim grip 做 LOD——顶点数超过阈值(如 32)时只标注外包矩形,或按 curViewUnitSize 决定是否出标注。这是显示增强,不是核心交互,砍掉不影响编辑。

E8 BIM 提示触发 3D 重建

GetBimInfoTip(2225)调 GetArea() 与 GetVolume():

  • GetArea()(AecDbCurveBase.cpp:1156)在 !Has3DBuffer() 时先 AcGiViewDraw dc; Draw3d(dc);,再遍历 3D 实体求面积;
  • GetVolume()(AecDbEwPipeBase.cpp:6369)非 body 时转 AecDbCurveBase::GetVolume,同样依赖 3D 缓存。

而 3D 缓存又被 E1 每次写使能清掉。结果是:鼠标在板上悬停 → 提示框 → 一次完整 3D 建模。

改法:面积/体积用参数直接算——KEY_Length × KEY_Width、π × KEY_RAD²、× KEY_BOMTHICK;异形板用缓存的 AcGePoint2dArray 鞋带公式。只有用户显式查询时才走精确 3D。

E9 键字面量与 map 查找

KEY_* 全是 #define KEY_Length _T("len")(inc/EarThwormDef.h:1367),因此每次

GetSpecialtyData()->GetAt(KEY_Length, dL);

都会构造一个临时 CString(非空串走堆分配),再到 std::map<CString, AecXData>(inc/AecDbObjXDataMap.h:284)里做 O(log n) 的宽字符比较。

单次开销不大,但 ImpGetGripPoints、SubAddDimPoints、GetBasinShapeCurve、GetProperties 每个函数都有 5-15 次这种调用,乘上每秒几十次的调用频率就不可忽略。

改法:

  • 把 KEY_* 改成 static const CString(或 inline constexpr wchar_t[] + GetAt 的 LPCTSTR 重载),消除临时对象;
  • 每个函数开头 const AecDbObjXDataMap* pData = GetSpecialtyData();,一次取多个键;
  • 更高阶:把 slab 热路径需要的 6-8 个参数缓存在成员里,写时同步(当前每次都要回 map 查)。

E10 Add2DBuffer 的线性去重

AecRxBase/AecDbEntityCommon.cpp:104:

if (pEnt && m_ar2DDrableBufPtrs_.find(pEnt) == -1)
    m_ar2DDrableBufPtrs_.append(pEnt);

每次加入都线性扫描。slab 的缓存通常只有几条(外轮廓 + hatch),影响很小;但 DrawXShape2d(2427)会把 OrthoProjected3DSolid 产出的所有投影曲线逐条加入,截面复杂时是 O(n²)。优先级低,顺手可改。

E11 其他小项

  • GetRotation(Z) 在 1995/2713/3163/3916/4037/4099 共 6 处调用,本身是数组读,不贵——但它恒返回 0(见 11.1),导致"按旋转方向计算夹点"这段逻辑白算;
  • GetProperties(4735)每次刷新做 10+ 次 GetAt + SimpleProperty::makeAndInsert(后者内部有 CString 格式化和容器插入),属性面板打开/切页时是主要开销,但不在绘制循环里,优先级低;
  • SetProperties(4899)改板厚时也会把 KEY_POINTS 解析→改标高→序列化→再写回(eLB/eGB 分支),纯字符串往返,可只改 KEY_ORGPT/KEY_BOMTHICK 后置 dirty;
  • GetBaseCurve() 返回 clone,SetBaseCurve() 再 clone 一次再删旧指针(见 5 章"已核对、确认不是问题的点"),每次属性写多两次分配,量级小。

10. 效率改造顺序

阶段 动作 预期收益 风险
1 拖动期间抑制 Clean2DBuffer/Clean3DBuffer(slab 局部开关),松开后清一次 拖动帧率提升最明显 低(不改基类)
2 Draw2d 在 dc.IsDragging() 时跳过 DrawHatch 与洞口 loop 拖动/平移大幅减负 低
3 GetBimInfoTip 改解析式算面积/体积 悬停不再触发 3D 建模 低
4 圆/椭圆拖夹点走轻量路径(只改参数 + 缩放 region) 拖半径不再每帧 ACIS 建模 中(要保证 region 与参数同步)
5 GetBasinShapeCurve 合成单矩阵 + 零角度跳过 重绘/点选线性下降 低
6 KEY_POINTS 内存缓存 + 去掉 O(n²) 去重 异形板夹点/拖动线性化 中(缓存失效要覆盖所有写点)
7 IsPointInSide 加包围盒粗筛 + 解析判断矩形/圆 大图拾取从 O(N·n) 降到 O(N) 低
8 夹点/标注 LOD 大板选中不卡 低(显示层)

11. 顺带发现(跨章,含新发现的逻辑 bug)

11.1 GetRotation(Z) 恒返回 0

AecDbBDPipeBase.cpp:233:

if (nAxisIndex > -1 && nAxisIndex < 2)   // 只放行 0、1
    return m_dRotate[nAxisIndex];
return 0.0;

而 X=0, Y=1, Z=2(Arx/R19.0/inc/adsdef.h:62 的 enum { X = 0, Y = 1, Z = 2 })。所以 slab 里 6 处 GetRotation(Z) 全部拿到 0:旋转过的板,夹点方向、标注方向都按未旋转算,而 GetBasinShapeCurve 用的是 m_dRotate[Z](正确),于是画出来的轮廓转了、夹点没转。改 nAxisIndex < 3 即可(需确认其它实体的 0/1/2 语义一致)。

11.2 DrawHatch 重复 DELETE_PTR(pCurve)

4660 与 4732 各一次。宏内会置空,第二次是空操作,不崩,但 pPl(同一指针的 cast)在 4660 之后已悬空,后续若有人加代码用到 pPl 就是 use-after-free。

11.3 IsPointInSide 的 float 精度

1760-1770 把 double 坐标截断成 float 再交给 IsPointInPoly。坐标绝对值上百万(大坐标总图)时 float 只有约 7 位有效数字,误差可达 0.06~0.1 m,边界点判断会随机抖动。改成 double 数组或直接用 AcGePoint2d。

11.4 Disperse 对矩形也 explode

Disperse(1628)无条件 poly.explode(arCurSegs),之后才判断 bulge。矩形板、正交折线板全部白 explode 一遍(还要 FreeBuffer)。可先扫一遍 getBulgeAt,全为 0 时直接复制顶点。

12. 验证方法(本仓库无单测)

基准图:50 块板,其中 10 块异形板(200 顶点)、5 个带堰墙水池(3 个洞口)、5 块旋转 30° 的板。

指标 测法 期望
拖动半径夹点帧率 开 PERF 计时,累计 SetCircleBasin 次数与总耗时 阶段 1+2+4 后单帧 < 5 ms
静止重绘 连续平移视图,统计 DrawHatch 调用次数 缓存命中时不应再进 DrawHatch
悬停提示 鼠标扫过 50 块板,统计 Draw3d 次数 阶段 3 后应为 0
框选/拾取 拾取 50 块板中心点,统计 IsPointInSide 内 Disperse 次数 阶段 7 后仅异形板触发
内存 拖动 30 秒后看私有字节 不应随帧数线性增长(对照 2 章 L1~L7)

临时埋点(改完删掉):

static LONGLONG s_t = 0; static int s_n = 0;
LONGLONG t0 = GetTickCount64();
... 被测代码 ...
s_t += GetTickCount64() - t0; ++s_n;
if (s_n % 100 == 0) acutPrintf(_T("\n%s: %d 次, 累计 %lld ms"), _T("DrawHatch"), s_n, s_t);

编译按 CODEBUDDY.md:cmake --preset R190 && cmake --build --preset R190 --config Debug --target CoreLibrary_BAL_REMOTE(只编 CoreLibrary,不跑全量)。