From 60d0b51d6c42ec1410c019770bb0c44f37447a31 Mon Sep 17 00:00:00 2001 From: company-desktop-computer Date: Mon, 26 Oct 2026 13:38:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=20AecDbSlab=20=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=95=88=E7=8E=87=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 AecDbSlab-analysis.md 与 AecDbSlab-performance.md 合并为 AecDbSlab-review.md:第 1~6 章为内存泄漏/潜在 Bug/运行逻辑,第 7~12 章为运行效率(调用链、E1~E11、改造顺序、验证方法),并补充 GetRotation(Z) 恒返回 0 等跨章发现。 --- AecDbSlab-review.md | 630 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 AecDbSlab-review.md diff --git a/AecDbSlab-review.md b/AecDbSlab-review.md new file mode 100644 index 0000000..ed933a9 --- /dev/null +++ b/AecDbSlab-review.md @@ -0,0 +1,630 @@ +# 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`(第 1~6 章)与 `AecDbSlab-performance.md`(第 7~12 章)合并而成。 + +> 目录: +> 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) + +```cpp +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) + +```cpp +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) + +```cpp +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) + +```cpp +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) + +```cpp +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) + +```cpp +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) + +```cpp +properties.findAndSet(_T("堰墙高度"), pData, KEY_HEIGHT); +properties.findAndSet(_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) + +```cpp +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) + +```cpp +if (!m_pRegion) return Acad::eOk; // 基类 subTransformBy 还没调用 +AecDbBDPipeBase::subTransformBy(xform); +``` + +截面丢失的板 MOVE/ROTATE **完全无效**(基曲线、KEY_ORGPT、PIPE_EL 都不动),且没有任何提示。应先调基类,再按需处理 region。 + +### S8 夹点"改厚度"方向判断依赖 Y 分量为 0(:3530、:3719) + +```cpp +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 (iclone()`,`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 章(第 7~12 章)为运行效率部分:先给热点结论,再拆一帧内的调用链,然后逐项分析 E1~E11,最后给效率改造顺序与验证方法。行号同样基于 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`)无条件执行: + +```cpp +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` **打开一次数据库对象**,再解析 `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` 里: + +```cpp +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` 存量化坐标); +- `subTransformBy` 只在真正发生变换时序列化(当前无条件回写,且因 S6 的闭合点重复,串会越写越长,代价随时间上升)。 + +### E6 `IsPointInSide` 的分化与调用位置 + +`AecDbSlab.cpp:1712`。折线分支(1757): + +```cpp +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)每个夹点都: + +```cpp +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`),因此每次 + +```cpp +GetSpecialtyData()->GetAt(KEY_Length, dL); +``` + +都会构造一个临时 `CString`(非空串走堆分配),再到 `std::map`(`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`: + +```cpp +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`: + +```cpp +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) | + +**临时埋点**(改完删掉): + +```cpp +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,不跑全量)。