From 5692d9010b2a1305ac795bd4545f6b362de93fc9 Mon Sep 17 00:00:00 2001 From: company-desktop-computer Date: Wed, 21 Oct 2026 22:04:16 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20AecDbEwPipeBase=20?= =?UTF-8?q?=E6=88=AA=E9=9D=A2=E5=AD=98=20SAT=20=E7=9A=84=E8=90=BD=E7=9B=98?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=B8=8E=E4=B8=8D=E5=88=86=E6=B5=81=E5=86=B3?= =?UTF-8?q?=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 3.7 落盘实测:SAT 原始字节、DWG 净增、acisIn 与布尔重建耗时, 并标注该数据来自 accoreconsole 模拟 REGION,非真实实体实测 - 新增 3.8 为什么不分流:createFromCurves 慢、落盘省不下体积、 写路径缺陷、升版本成本 - 修正体积代价表述:字段级估算 5~20 倍,落盘压缩后实为 4~5 倍 --- AecDbEwPipeBase-region-sat-plan.md | 467 +++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 AecDbEwPipeBase-region-sat-plan.md diff --git a/AecDbEwPipeBase-region-sat-plan.md b/AecDbEwPipeBase-region-sat-plan.md new file mode 100644 index 0000000..7d2d8e8 --- /dev/null +++ b/AecDbEwPipeBase-region-sat-plan.md @@ -0,0 +1,467 @@ +# AecDbEwPipeBase::m_pRegion 存储改为 SAT 为主 + +> 目标文件:`SourceCode/Code2026/CoreLibrary/AecDbEwPipeBase.cpp`、`inc/AecDbEwPipeBase.h` +> 当前版本号:`kCurrentVersionNumber = 7`(`AecDbEwPipeBase.cpp:382`) +> 本文只讨论 `m_pRegion`(截面)的持久化,不涉及 `m_pBaseCurve`、`m_arUserInfs`。 + +## 1. 结论 + +`m_pRegion` 的存储改为 **SAT 为主、曲线通道只读兼容**。 + +- 写入:统一走 `nLen = 0` + `RegionAcisoutDwgOutFields`(SAT 字节流) +- 读取:保留两条通道,`nLen > 0` 走曲线重建,`nLen == 0` 走 SAT +- 曲线通道的**写路径退役**,读路径永久保留(老图纸需要) +- **不按截面类型分流**:不存在「标准截面走参数重建、异形截面走 SAT」的第二套逻辑,所有新数据一律 SAT。理由见 3.8 + +体积代价经实测确认可接受(落盘净增约 4 倍,见 3.7);换来的是几何可重现、失败可检测、代码只有一套。 + +--- + +## 2. 现状 + +### 2.1 双通道结构 + +``` +dwgOutFields (:1324) + └ SectionRegionDwgOutFields(ver=7) (:1230) + ├ m_pRegion->explode() 非空 → RegionExplodeDwgOutFields (:1135) + │ └ writeUInt32(曲线数) + 每条曲线(类名 + 参数) + └ explode 空 → writeUInt32(0) + RegionAcisoutDwgOutFields (:1150) + └ acisOut → 读临时文件 → writeString(文件名) + writeUInt32(nLen) + writeBytes(SAT) + +dwgInFields (:854) + └ m_pRegion = SectionRegionDwgInFields(ver) (:893, :825) + ├ ver < 4 → readUInt32(nLen) → RegionFromCurvesDwgInFields (:663) + └ ver >= 4 + ├ nLen > 0 → RegionFromCurvesDwgInFields + └ nLen == 0 → RegionAcisInDwgInFields (:758) +``` + +曲线通道支持的类型:`AcDbLine` / `AcDbArc` / `AcDbCircle` / `AcDbPolyline`(含 bulge 与宽度)/ `AcDbEllipse` / `AcDb3dPolyline` / `AecDb3dRoadAxisLine`(`BaseCurveDwgOutFields:1013`、`BaseCurveDwgInFields:445`)。 + +### 2.2 曲线通道的四个缺陷 + +**(1) `AcDbSpline` 只写类名,不写参数** + +```cpp +// 写端 :1092 +AcDbSpline* pSp = AcDbSpline::cast(pBaseCurve); +if (pSp != NULL) +{ + //... + return Acad::eOk; // 一个参数都没写 +} + +// 读端 :562 +AcDbSpline* pSp = AcDbSpline::cast(pBaseCurve); +if (pSp != NULL) +{ + //... // 不读参数,也不 return +} +// 继续 cast 到 3dPolyline / RoadAxisLine 全部失败,最终 :596 return NULL +``` + +读写对称,所以不会字段错位,但**这条边直接消失**。而 `AcDbRegion::explode()` 对含 NURBS 边的 region 返回 `AcDbSpline` 是常见情况。 + +**(2) `explode` 的输出依赖 ACIS 版本** + +`explode` 是从 B-rep 反推边界,NURBS 边被转成 spline 或打碎成多段线段,结果随 ACIS 版本变化。同一份截面在 CAD 2023 和 2027 上存盘,写出的字节不同;来回存盘几何会逐步漂移。 + +**(3) 多区域靠"最大面积为外环"猜** + +`:717-751`: + +```cpp +// 面积最大的当外环 +if (dAreaTmp > dArea) { dArea = dAreaTmp; pRegionBig = pRegion; } +// 其余全部减去 +es = pRegionBig->booleanOper(AcDb::kBoolSubtract, pRegion); +``` + +`createFromCurves` 对 N 条闭合曲线会生成 N 个 region。截面是两个不相交的独立区域时,小的那块被当成孔减掉。 + +**(4) 退化判据不成立** + +```cpp +m_pRegion->explode(arCurPtrs); +if (!arCurPtrs.isEmpty()) // 只看"有没有炸出东西" + return RegionExplodeDwgOutFields(...); +``` + +上面三种情况(spline 丢失、多区域误判、边被打碎)都会让 `arCurPtrs` 非空,于是**走曲线通道、写出坏数据、读回来几何不对,没有任何提示**。 + +### 2.3 SAT 通道自身的缺陷 + +| 位置 | 问题 | +| --- | --- | +| `:775` | `new char[nLen]` 无 `+1`,当前没有 strlen 依赖,但脆弱 | +| `:784` | `errorfile` 分支 `return pRegion` 前没有 `delete[] pszTxt`,每次泄漏 nLen 字节 | +| `:788-790` | 临时文件路径由 DWG 里的 `sFileName` 拼成,可被构造成 `..\..\xxx` | +| `:1162` | 文件名 `Region_<句柄>.dat` 同图不同文档会冲突 | +| `:1182` | `new char[nLen]` 无 `+1`,`writeBytes` 有长度,当前安全 | +| `:1206` | `errorfile` 兜底走 `m_pRegion->dwgOutFields(pFiler)`,只写字段不写对象头,`layerId` 等为 `kNull` | +| 全路径 | 失败无日志,`return NULL` 后 `m_pRegion` 为空,绘制时截面直接不见 | + +--- + +## 3. 改为 SAT 为主的优点 + +### 3.1 几何可重现 + +SAT 是 ACIS 自己的序列化格式,`acisOut` / `acisIn` 是同版本内的官方往返。同一份 `m_pRegion`,无论在哪台机器、哪个 ACIS 版本上存盘,写出的字节一致(同版本内)。 + +曲线通道相反:`explode` 的输出随版本变化,同一张图反复存盘会累积漂移。 + +**这是最重要的一条**:图纸数据一旦不可重现,问题就永远无法复现和定位。 + +### 3.2 失败可检测 + +SAT 首行带 ACIS 版本号和 data record 数,`fixACISFormat` 能用它校验完整性(`CoreTools.cpp`)。曲线通道没有整体校验:少一条边、多一段线,格式上完全合法,无法发现。 + +配合 `AcDbX3dSolid` 那次加的失败日志(`CoreTools::StringToRegion`),解析失败至少能在命令行看到长度和首行。 + +### 3.3 代码只有一套 + +写路径删掉 `explode` + `RegionExplodeDwgOutFields` + `BaseCurveDwgOutFields` 的调用后: + +- 不需要为每种曲线类型维护写/读分支 +- 不需要维护"最大面积为外环"的启发式 +- 不需要维护 spline 的补全逻辑 +- 与 `AcDbX3dSolid` 的存储方式一致,后续只有一个心智模型 + +`BaseCurveDwgOutFields` / `BaseCurveDwgInFields` 仍要保留,因为 `m_pBaseCurve`(中心路径)用它,且曲线读通道依赖它。 + +### 3.4 写盘更快 + +`SectionRegionDwgOutFields` 现在每次存盘对每个管道做一次 `m_pRegion->explode()`,这是 ACIS 边界提取,成本与 `acisOut` 同量级甚至更高(要构造曲线对象并写多个字段)。改成 SAT 后只剩一次 `acisOut`。 + +另外 `explode` 出来的曲线在 `RegionExplodeDwgOutFields:1145` 用 `delete pCurve` 释放,未入库实体没有先 `close()`,属于隐患。 + +### 3.5 格式已预留,切换无版本成本 + +读取端 `SectionRegionDwgInFields:836-849`: + +```cpp +readUInt32(&nLen); +if (nLen > 0) → 曲线通道 +else → SAT 通道 +``` + +只要写入时固定写 `nLen = 0`,**旧版本插件读到新数据也会走 SAT 分支**,能正确还原。 + +这意味着: + +- 不需要升 `kCurrentVersionNumber` +- 新图纸在老版本 CAD 上不会变 proxy +- 老图纸(`nLen > 0`)继续走曲线通道 + +对比 `AcDbX3dSolid`:它没有预留位,改成 `m_pSatBuffer` 必须升版本到 5,老插件会 `eMakeMeProxy`。 + +### 3.6 代价:体积(字段级估算) + +| 截面 | 曲线通道 | SAT 通道 | +| --- | --- | --- | +| 矩形(4 条 Line) | ~370 B | 2~4 KB | +| 圆形(1 个 Circle) | ~80 B | 2~3 KB | +| 20 孔异形 | ~2 KB | 20~50 KB | + +曲线通道的固定开销:每条曲线 `writeString(类名)` ≈ 24 B + `Point3d` × 2 = 48 B + `Vector3d` = 24 B ≈ 92 B。 + +按字段级估算,未压缩的 SAT 比曲线通道大 5~20 倍。但 DWG 保存时会对整图做压缩,实际落盘差距远小于此(见 3.7),而且复杂截面(spline 被打碎成数百段)时曲线通道可能反超 SAT。 + +**结论:用体积换正确性。** 在图纸数据上这个交换是值得的。 + +### 3.7 落盘实测 + +> **测量方式**:`accoreconsole` 脚本直接造原生 `REGION` / `SUBTRACT`,**未加载本插件、未创建 `AecDbEwPipeBase` 实例**。 +> 因此下表只反映「截面数据本身的编码体积与重建耗时」,不含实体其它字段,也不能直接乘出整张图纸的增量。 + +**原始 SAT 大小**(`acisOut` 落盘的 ASCII 文本,未压缩): + +| 截面 | 边数 | SAT 字节 | 拟合 | +| --- | --- | --- | --- | +| 圆环 | 2 | 1138 | ≈ 200 + 420 × 边数 | +| 矩形 | 4 | 1548 | 同上 | +| 矩形 + 2 孔 | 6 | 2232 | 同上 | +| 20 边形 | 20 | 8822 | 同上 | + +**DWG 落盘净增**(同一张图,500 个实体减 100 个实体,差值 ÷ 400): + +| 通道 | 每实体净增 | +| --- | --- | +| 曲线通道 | ~32 B | +| SAT 通道 | 126~160 B(其中 SAT 文本 ~112 B) | + +即:**压缩后 SAT 只比曲线通道大 4~5 倍**,而不是 3.6 表里的 5~20 倍。SAT 是高度结构化的重复 ASCII,DWG 的压缩对它的效果远好于对曲线二进制记录。 + +**重建耗时**: + +| 方式 | 单次耗时 | +| --- | --- | +| `acisIn`(SAT 通道) | 1.88 ms | +| 2 × REGION + `SUBTRACT`(曲线通道) | 3.59 ms | + +SAT 约快 1.9 倍。 + +**数据可信边界**: + +- 可信:字节随边数的增长关系、两条通道的落盘相对量级、`acisIn` 与布尔运算的耗时比 +- 不可外推:具体图纸的总体积增量。真实增量 = 每实体截面数 × 截面复杂度 × 上述单值,且实体自身字段也在压缩 +- 待补:在装了 Envi3D 的 CAD 里,用真实管道实体做一次「新建 N 个 → 存盘 → 对比」的验证 + +### 3.8 为什么不分流 + +曾考虑过「标准截面(矩形、圆)走参数重建,异形截面走 SAT」,被否掉: + +1. **曲线通道要 `createFromCurves`**:即使曲线是精确参数(不带 spline),重建仍要跑 ACIS 的边界构造 + 多环布尔,实测 3.59 ms/次,比 `acisIn` 慢 1.9 倍。开图时每个管道都要付一次 +2. **体积省不下来**:落盘压缩后曲线通道只有 ~32 B/实体,省的是 100 B 量级,不值得为它多维护一条分支 +3. **两条写路径 = 两份 bug**:现有曲线通道的缺陷(写端 spline 只写类名不写参数、读端 `&&`/`||` 写反、布尔未做 SEH 保护)都集中在「曲线 ↔ 几何」这条转换链上。读路径因为老图纸必须留着,但写路径一旦也留着,就会有新数据持续进入这条有缺陷的链 +4. **版本保护成本**:分流需要升 `kCurrentVersionNumber` 到 8,新图在老 CAD 上变 proxy;统一 SAT 用 `nLen == 0` 就够,不用升版本 + +结论:**一条写路径,SAT 到底。** 曲线通道只作为老图纸的只读兼容层存在。 + + +--- + +## 4. 优化方案 + +### 4.1 第一步:写路径切换 + +改 `SectionRegionDwgOutFields:1230`: + +```cpp +Acad::ErrorStatus AecDbEwPipeBase::SectionRegionDwgOutFields(AcDbDwgFiler* pFiler, int nVer) const +{ + assertReadEnabled(); + + if (m_pRegion == NULL) + return Acad::eOk; + + // 统一写 SAT:nLen = 0 是读取端的 SAT 分支标志,旧版本插件同样识别 + // 曲线通道仅用于读取老图纸,不再产生新数据 + Adesk::UInt32 nCurs = 0; + pFiler->writeUInt32(nCurs); + + return RegionAcisoutDwgOutFields(pFiler); +} +``` + +`dwgOutFields:1348-1364` 的 `m_pRegion == NULL` 分支保持不变(写 0 + `"***"` + 7 字节占位),读取端走 `RegionAcisInDwgInFields`,读到 `"***"` 后 `acisIn` 失败返回 NULL,行为与现在一致。 + +**这一步完成后曲线通道的写路径已经不再执行**,`RegionExplodeDwgOutFields` 只剩读路径依赖的 `BaseCurveDwgOutFields`。 + +### 4.2 第二步:修 SAT 通道自身缺陷 + +按优先级: + +**(1) `errorfile` 分支内存泄漏(`:775-785`)** + +```cpp +char* pszTxt = new char[nLen + 1]; +::memset(pszTxt, 0, nLen + 1); +pFiler->readBytes(pszTxt, nLen); + +if (sFileName.CompareNoCase(_T("errorfile")) == 0) +{ + AcDbRegion* pRegion = (AcDbRegion*)AcDbRegion::desc()->create(); + Acad::ErrorStatus es = pRegion->dwgInFields(pFiler); + delete[] pszTxt; // 补上 + if (es != Acad::eOk) { delete pRegion; return NULL; } + return pRegion; +} +``` + +**(2) 临时文件路径收敛(`:788-790`)** + +```cpp +// 只取文件名,不接受 DWG 里的路径成分 +CString sName = sFileName; +int nSlash = max(sName.ReverseFind(_T('\\')), sName.ReverseFind(_T('/'))); +if (nSlash >= 0) + sName = sName.Mid(nSlash + 1); +if (sName.Find(_T("..")) >= 0) + sName = _T("region_tmp.dat"); + +CString sFullPath = FilePath::getSystemTemporaryFolder(); +sFullPath += _T("\\"); +sFullPath += sName; +``` + +**(3) 写端文件名加进程号(`:1162`)** + +```cpp +sFileName.Format(_T("Region_%lu_%s.dat"), (unsigned long)::GetCurrentProcessId(), szHandleStr); +``` + +读端从 DWG 里读到的就是带进程号的名字,写到 temp 时再拼回去。这样同图在两个 CAD 实例里同时打开不会互相覆盖。 + +注意:这只解决"同时打开",不解决"同一进程内两个文档"。若需要彻底解决,可把句柄 + 文档名(`acdbHostApplicationServices()->workingDatabase()` 的路径哈希)一起编进文件名。 + +**(4) 失败日志** + +`RegionAcisInDwgInFields` 的每个失败分支加 `acutPrintf`,带 `nLen`、文件名、`acisIn` 返回值,与 `AcDbX3dSolid` 的日志格式保持一致。 + +**(5) `new char[nLen]` 补 `+1`** + +`:775` 和 `:1182` 都补 `+1`,即使当前没有 strlen 依赖,也避免以后被当字符串用时越界。 + +### 4.3 第三步:读路径的曲线通道加固 + +老图纸必须继续可读,`RegionFromCurvesDwgInFields:663` 的三处要修: + +**(1) `isRegionNull` 的判断条件写反了(`:745`)** + +```cpp +// 现在:两个都无效才跳过 +if (isRegionNull(pRegion) && isRegionNull(pRegionBig)) + continue; + +// 应该:任意一个无效就跳过 +if (isRegionNull(pRegion) || isRegionNull(pRegionBig)) + continue; +``` + +`isRegionNull` 返回的是 `getAreaProp` 失败(`:640-661`),一个无效一个有效时进 `booleanOper` 正是最容易崩的组合。 + +**(2) `booleanOper` 没走 SEH 保护(`:750`)** + +同文件 `:600` 已有 `rawBooleanOperWrapper`,用 `__try/__except` 捕 `0xC0000005`,说明 ACIS 布尔会崩。这里却直接调 `pRegionBig->booleanOper(...)`。 + +```cpp +es = rawBooleanOperWrapper(pRegionBig, pRegion, AcDb::kBoolSubtract); +if (es != Acad::eOk) +{ + acutPrintf(_T("\nAecDbEwPipeBase: 截面布尔失败,err=%d。"), es); +} +``` + +**(3) spline 读端补日志** + +读端 `:562` 的 spline 分支现在什么都不做,直接落到 `return NULL`。至少加一行日志,让老图纸里丢失的边能被发现: + +```cpp +AcDbSpline* pSp = AcDbSpline::cast(pBaseCurve); +if (pSp != NULL) +{ + acutPrintf(_T("\nAecDbEwPipeBase: 截面边界含 spline,历史数据未保存参数,该边将被丢弃。")); + delete pBaseCurve; + return NULL; +} +``` + +不要试图"补全" spline 参数:历史数据里本来就没写,读端无从恢复。写端已经不再产生新数据,这条路径只服务老图纸。 + +### 4.4 第四步(可选):`m_pRegion` 懒加载 + +`dwgInFields:893` 现在无条件 `m_pRegion = SectionRegionDwgInFields(...)`,每个管道实体开图时都要跑一次 `createFromCurves`(ACIS 布尔)或 `acisIn`。 + +与 `AcDbX3dSolid` 的 `EnsureCreated()` 同思路: + +```cpp +// 头文件 +mutable bool m_bNeedRegion; +AcDbRegion* EnsureRegion() const; // 内部 const_cast 调 ReCreateRegion + +// dwgInFields +m_bNeedRegion = true; // 不立即重建 + +// 所有读取 m_pRegion 的入口 +AcDbRegion* AecDbEwPipeBase::GetSection() const +{ + if (m_bNeedRegion) + { + m_bNeedRegion = false; + const_cast(this)->ReCreateRegion(); + } + return m_pRegion; +} +``` + +**风险**:`m_pRegion` 在类内被直接访问 20+ 处(`:1539`、`:2759`、`:3104`、`:3358`、`:3898`、`:3910` 等),没有统一的 getter。收口工作量大,漏一个就是空指针或空截面。 + +建议放在写路径切换稳定之后再单独做,不要和格式切换混在一个 PR。 + +### 4.5 第五步(可选):曲线读路径的最终收敛 + +如果确认所有在用图纸的 `nLen > 0` 数据已经过时,可以考虑: + +- 读到时自动升级:`nLen > 0` → 用曲线重建 → 下次存盘写成 SAT +- 由于 4.1 的写路径已经固定写 SAT,这一步**不需要额外代码**,打开再保存即完成迁移 + +`RegionFromCurvesDwgInFields` 和 `BaseCurveDwgInFields` 仍需保留(`m_pBaseCurve` 也用后者),不能删。 + +--- + +## 5. 兼容性 + +| 场景 | 行为 | +| --- | --- | +| 老图纸 + 新插件 | `nLen > 0` → 曲线通道,行为不变 | +| 新图纸 + 新插件 | `nLen == 0` → SAT 通道 | +| 新图纸 + 旧插件(v4+) | `nLen == 0` → SAT 通道,可正常还原 | +| 新图纸 + 旧插件(v4 以下) | 不支持,与现状一致(v4 以下本来就只认曲线通道) | + +**不升版本号**,`kCurrentVersionNumber` 保持 7。 + +--- + +## 6. 风险 + +| 风险 | 影响 | 对策 | +| --- | --- | --- | +| 落盘体积增大约 4 倍(每实体 +100 B 量级) | 大图纸体积略增 | 实测可接受(3.7);不需要自定义压缩 | +| `acisOut` 对某些退化 region 失败 | 截面丢失 | 失败时保留 `errorfile` 兜底,并加日志 | +| 老图纸里的曲线数据本身有损(spline 丢失) | 重建后的截面与原始不同 | 属于历史数据问题,本次不引入;加日志让问题可见 | +| 4.4 懒加载收口遗漏 | 空指针 | 单独 PR,逐处核对 | + +--- + +## 7. 验证 + +### 7.1 编译 + +按项目约定:`R190` + `Debug`,目标 `AecRxBase`、`CoreLibrary_BAL_REMOTE`/`_TRIAL_YEAR`、`CoreCommand_BAL_REMOTE`/`_TRIAL_YEAR`、`CoreImplement`。 + +### 7.2 CAD 实测 + +1. **新建管道 → 存盘 → 重开**:截面几何、面积、位置一致 +2. **老图纸**:改动前保存的图,打开后截面正确(走曲线通道) +3. **老图纸另存**:打开后直接保存,再重开,截面仍正确(已转成 SAT) +4. **混合**:同一张图里既有老数据又有新建管道,存盘重开后都正确 +5. **复制粘贴**:`copyFrom`(`:1539`)走 `m_pRegion->clone()`,验证不丢截面 +6. **布尔截面**:带孔的异形截面(多曲线)在曲线通道下的重建结果与 SAT 通道一致 + +### 7.3 指标 + +已在 3.7 用模拟截面测过「单截面 SAT 字节 / 落盘净增 / `acisIn` 耗时」。仍需在真实数据上补: + +- 同一张典型图改动前后的 DWG 体积(含全部实体字段) +- 存盘耗时(对比 `explode` 与 `acisOut`) +- 开图耗时(若做了 4.4) + +--- + +## 8. 明确不做 + +| 项 | 原因 | +| --- | --- | +| 删除 `RegionFromCurvesDwgInFields` | 老图纸依赖 | +| 删除 `BaseCurveDwgInFields` / `BaseCurveDwgOutFields` | `m_pBaseCurve` 在用 | +| 补全 spline 的读写参数 | 历史数据里没有,补了也读不回来;写路径已退役 | +| 升 `kCurrentVersionNumber` | 格式已预留,不需要 | +| 自动迁移老图纸 | 打开再保存即完成迁移,不需要额外代码 | +| 给 `AcDbX3dSolid` 增加曲线通道 | 拿不到构建曲线,且曲线通道有上述缺陷 | + +--- + +## 9. 实施顺序 + +| 步骤 | 内容 | 风险 | 独立 PR | +| --- | --- | --- | --- | +| 1 | 写路径切换(4.1) | 低 | 是 | +| 2 | SAT 通道缺陷修复(4.2) | 低 | 可合并到 1 | +| 3 | 曲线读路径加固(4.3) | 低 | 是 | +| 4 | 懒加载(4.4) | 中 | 是 | +| 5 | 真实管道实测(3.7 待补项) | — | — | + +第 1 步落地后,新产生的数据全部是 SAT,后续所有改动都只影响读路径,风险可控。