文件

92 KiB

SolidWorks COM 对接:已实测确定的事实(2026-09-12)

这份只记在本机实测通过的东西。没实测的不写。 环境:SOLIDWORKS 2025 SP01.1(RevisionNumber = 33.1.1), D:\Program Files\SOLIDWORKS Corp\SOLIDWORKS\SLDWORKS.exe, Python 3.14.3 64 位(C:\Python314),pywin32 已装。

1. 连接

import win32com.client as wc
app = wc.GetActiveObject('SldWorks.Application')   # 挂已经开着的那个实例
  • ⛔ 不要用 gencache.EnsureDispatch:实测报 TypeError: This COM object can not automate the makepy process。只能走动态派发。
  • Dispatch 会另起一个(可能不可见)实例;联调时用 GetActiveObject, 出问题在用户屏幕上能直接看见。

2. ⛔ 最大的坑:无参成员是属性,不是方法

pywin32 动态派发下,SolidWorks 的无参 GetXxx() 全被当成属性。加括号就报 'str' object is not callable / 'bool' object is not callable。

写法 正确形式
文档数 app.GetDocumentCount(不加括号)
全部文档 app.GetDocuments → 返回 tuple
活动文档 app.ActiveDoc
版本 app.RevisionNumber → '33.1.1'
忙否 app.CommandInProgress
标题 / 类型 / 路径 doc.GetTitle / doc.GetType / doc.GetPathName
重建 doc.EditRebuild3(属性,加括号会炸)
有参的才是方法 app.CloseDoc(title) / app.OpenDoc6(...) / app.LoadFile4(...) / doc.GetPartBox(True)

⛔ 不要写 V = lambda o: o() if callable(o) else o 这种"自动适配"包装 —— COM 对象 callable() 一律为 True,会把文档对象当函数调,报"找不到成员"。 按上表逐个记死即可。

  • doc.GetType:1 = 零件,2 = 装配体,3 = 工程图。

3. 导入 STEP:GetImportFileData + LoadFile4

data = app.GetImportFileData(step_path)          # 有参 ⇒ 方法
err  = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
doc  = app.LoadFile4(step_path, 'r', data, err)  # err.value == 0 即成功
  • ⛔ LoadFile4(path, 'r', **None**, err) 会报**"类型不匹配"** —— 第三参必须是 GetImportFileData 返回的 ImportStepData 对象。
  • ⛔ OpenDoc6(step, 1, 1, '', err, warn) 实测返回 None、err=2097152 (swApplicationBusy)。导 STEP 用上面那条路。
  • 实测耗时:刀片 31 MB ≈ 11 s,底板 54 MB ≈ 20 s。

导进来的是带位号的装配树(很重要)

EasyEDA 以 'Outfit' 模式导出的 STEP,SolidWorks 会解析成完整装配体:

  • 顶层 = PhoneRail_blade(type=2,未保存)
  • 板体 = Board~kkk5.step.sldprt,包围盒实测 x 0.01~89.99 / y 0.01~67.99 / z 0~1.67 —— 正是刀片 90×68×1.6,说明 STEP 的坐标原点 = 板左下角,与 EDA 板坐标一致
  • 每个器件一个子装配体,名字里带位号:R1~R0402~…、U4~TSSOP-24~…

STEP 文件里位号在 NEXT_ASSEMBLY_USAGE_OCCURRENCE 的实例名(底板 164 个、刀片 75 个, 与器件数对得上);PRODUCT 里只有几何体名(Board~/TopCopper~/TopSilkscreen~…)。 ⇒ 干涉报告可以按位号指名。

代价:一次导入产生上百个打开的文档

刀片一块就 95 个,两块板 295 个,子件都是 %TEMP%\swx…\IC~~\*.sldprt / *.SLDASM 的临时文件。

  • doc.GetPathName 能拿到这些临时件的真实路径(AddComponent5 可以直接引用它们)。
  • ⛔ 顺序约束:要引用临时件,必须在导入之后、清理之前做完。 一旦 CloseAllDocuments,%TEMP%\swx…\IC~~\ 就空了(实测候选数 95 → 0)。

4. 清理

app.CloseAllDocuments(True)    # True = 连未保存的一起关,不弹框

实测 295 个文档 0.9 s 关完,SolidWorks 本身不退出。

5. ⛔ 带外部引用的装配体不能用 SaveAs 复制

doc.SaveAs3(path, 0, 2) 实测返回 0(失败)。官方也写明: 带 in-context / 外部引用的装配体要用 ISldWorks::CopyDocument,不是 SaveAs。 ⇒ 本 skill 的做法是不另存:导入后直接引用 GetPathName 给出的临时 SLDASM。

6. 两块板的 STEP 怎么来

见 easyeda-api 侧:eda.pcb_ManufactureData.get3DFile(name, 'step', ['Component Model'], 'Outfit', true)。实测:

板 生成 大小
刀片 22.6 s 31.2 MB
底板 40 s 53.9 MB
  • ⛔ 桥有 30 s 硬超时:底板直接同步调用会 Request timed out after 30000ms, 且大导出会把 EDA 窗口搞断(EDA window disconnected)。 ⇒ 必须异步启动 + 轮询:JS 里把 promise 挂到 window.__gen,立刻 return,再每 5 s 查一次。
  • 取回用 4 MB 分块 base64(String.fromCharCode.apply 按 32 KB 小段拼,避免爆栈): 31 MB 实测 2 s、54 MB 4 s。 ⛔ 别用 512 KB 分块去推算大文件耗时 —— 瓶颈是往返次数不是带宽,线性外推会得出 "51 分钟"这种离谱结论(实际 2 秒)。
  • 归档在 .claude/r2/3d/(已 .gitignore,几十 MB 不进 git)。

7. 还没实测通的(⚠ 别当成已知)

  • AddComponent5(path, 0, '', False, '', 0.0, 0.0, 0.0) → 返回 None (当时因 SaveAs 失败、路径不存在;用临时 SLDASM 路径重试尚未完成)
  • ToolsCheckInterference2 → 六种参数形式全部报"非选择性的参数", 错误码一致 ⇒ 不是参数个数问题,需要从 sldworks.tlb 读真实参数表与 invkind。
  • 刀片相对底板的装配变换(绕 X 转 90° 竖起 + 平移到槽位)未做。

8. 权威签名:从 sldworks.tlb 读,别猜

import pythoncom
tlb = pythoncom.LoadTypeLib(r'D:\Program Files\SOLIDWORKS Corp\SOLIDWORKS\sldworks.tlb')
# 遍历 GetTypeInfo(i) → GetFuncDesc(f) → GetNames(fd.memid):names[0]=方法名,names[1:]=参数名
# ⚠ PyFUNCDESC 没有 cParams 属性,用 len(fd.args);有 cParamsOpt。

实读到的(SOLIDWORKS 2025):

接口.方法 参数 invkind
ISldWorks.GetImportFileData (FileName) 1 方法
ISldWorks.LoadFile4 (FileName, ArgString, ImportData, Errors) 1
ISldWorks.CloseAllDocuments (IncludeUnsaved) 1
IAssemblyDoc.AddComponent5 (CompName, ConfigOption, NewConfigName, UseConfigForPartReferences, ExistingConfigName, X, Y, Z) 1
IAssemblyDoc.ToolsCheckInterference2 (NumComponents, LpComponents, CoincidentInterference, PComp, PFace) 1
IAssemblyDoc.GetComponents (ToplevelOnly) 1
IModelDoc2.SaveAs3 (NewName, SaveAsVersion, Options) 1
IModelDocExtension.SaveAs3 (Name, Version, Options, ExportData, AdvancedSaveAsOptions, Errors, Warnings) 1
IComponent2.Transform2 — 2 属性读 / 4 属性写
  • ⛔ ToolsCheckInterference2 是 5 个参数、0 可选。只传 3 个会一律报 "非选择性的参数"(= 缺必需参数),换几种写法都一样 —— 别以为是类型不对。 后两个 PComp / PFace 是输出参数,要传 VARIANT(VT_BYREF|VT_VARIANT, None)。
  • ⛔ AddComponent5 的 8 参形式是对的;之前返回 None 纯粹因为传进去的文件不存在。
  • 摆位靠 comp.Transform2 = <MathTransform>(属性赋值,不是方法调用)。

9. ⛔ 类型库说"方法",动态派发下可能仍是属性

IModelDoc2.EditRebuild3 在 tlb 里是 invkind=1(方法、0 参), 但实测 asm.EditRebuild3() 报 'bool' object is not callable —— 动态派发对 0 参成员一律按属性取值(取值时就已执行)。 ⇒ 0 参一律写成属性形式,不管 tlb 标的是什么。

10. ⛔ 顶层导入文档是"未保存"的,GetPathName 是空串

导入 STEP 后 app.ActiveDoc 是顶层装配体(PhoneRail_blade),它没有路径; 有路径的是它的子件(%TEMP%\swx…\IC~~\*.sldprt / *.SLDASM)。 ⇒ AddComponent5 不能拿顶层文档的 GetPathName(空串 ⇒ 文件不存在 ⇒ 返回 None), 要去临时目录找 PhoneRail_*.step.SLDASM,或遍历 GetDocuments 挑有路径的那个。

11. ✅ 另存:Extension.SaveAs3 的空参数要传 VARIANT(VT_DISPATCH, None)

EMPTY = wc.VARIANT(pythoncom.VT_DISPATCH, None)
err   = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
warn  = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
ok = doc.Extension.SaveAs3(dst, 0, 1, EMPTY, EMPTY, err, warn)   # → True, err=0
  • ⛔ 传 None 会报 "类型不匹配",且错误里指明是第 4 个参数(ExportData)。 AdvancedSaveAsOptions(第 5 参)同理。
  • Options=1 = swSaveAsOptions_Silent。
  • ⛔ IModelDoc2.SaveAs3(path, 0, 2)(3 参那个)实测返回 0,别用。
  • 实测:刀片顶层装配体另存成 .SLDASM = 16.5 MB、底板 29.7 MB。

⛔⛔ 但这两个文件是废件 —— 更正上面这条。SaveAs3 返回 True、err=0、文件也写出来了, 可是再打开就是 OpenDoc6 → None 且 err=0(文档数仍为 0), AddComponent5 引用它们也一律返回 None。 原因:另存只写了顶层,子件引用仍指向 %TEMP%\swx…\IC~~\, 而那个目录在 CloseAllDocuments 时已被清空 ⇒ 引用链断了。 这正是官方那句「带 in-context / 外部引用的装配体不要用 SaveAs 复制,要用 ISldWorks::CopyDocument」的实际后果。 ⇒ 判据教训:SaveAs3 返回 True 不等于文件可用;必须再打开一次才算验证通过。

12. ⛔ LoadFile4 不会插进当前装配体,它总是另开一个顶层文档

试过"在空装配体里连续导入两块板的 STEP,让它们成为同一装配的两棵子树"——不成立: 每次 LoadFile4 都把活动文档顶成新的 PhoneRail_base / PhoneRail_blade, 空装配体里 GetComponents(True) 仍是 0 个。

⇒ 唯一可行的装配路径是三步:

  1. LoadFile4 导入 STEP(得到未保存的顶层装配体)
  2. Extension.SaveAs3 另存成 .SLDASM(见 §11)
  3. CloseAllDocuments(True) → 新建总装 → AddComponent5(那个 .SLDASM 路径, …)

13. ✅ ToolsCheckInterference2 的 5 参形式已经对

pcomp = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None)
pface = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None)
n = asm.ToolsCheckInterference2(0, EMPTY, False, pcomp, pface)

空装配体上调用不再报错(返回 None / PComp 0 项)⇒ 参数形式正确, 先前那一串"非选择性的参数"纯粹是参数个数不够(只传了 3 个,它要 5 个)。

⛔⛔ 更正(2026-09-12 晚):参数形式对了,但这个方法本身是死的,别用。 "不报错"当时只在空装配体上验证过 —— 从没验证它能不能检出一个已知的干涉。 实测:把刀片摆成板体直接穿过底板板体(两块 PCB 在 z 0.50~1.67 实打实体积重叠)的姿态, ToolsCheckInterference2(0, EMPTY, False, pc, pf) 仍返回 None、PComp / PFace 各 0 条。 同一姿态下 IInterferenceDetectionMgr 报 692 处、板对板 4915.9 mm³。

⇒ 它不抛异常、返回值看着就是个正常的"0 处干涉",拿它当判据会得出 "刀片一路插到底都不碰" 这种彻底相反的结论(本会话真的得出过一次,整轮扫描作废)。 ⇒ 判据教训(同 §11 那条):一个检测类 API「调用不报错」不等于它在工作; 必须先喂它一个已知必然为真的样本,看它报不报。见 §20。

14. 刀片竖插到底板槽位的变换(纯几何,已算好)

⚠ 坐标系要看插入方式,不能一概而论(2026-09-12 二次更正):

插入方式 实测包围盒 原点在哪
我用 AddComponent4(p,'',0,0,0) 刀片 x -45~45、底板 x -49~49 几何中心(被重心归零)
用户在 GUI 里手工插入 底板 x 0.01~97.99 / y 0.01~97.99 板左下角(与 EDA 板坐标一致)

⇒ 下表那套"原点 = 板左下角"的平移量,在手工插入的装配体里是直接可用的; 在 AddComponent4 插出来的装配体里要按 GetBox 现算。 动手前先 GetBox 读一次,看原点在哪,别假设。

(原始推导)坐标系:STEP 原点 = 板左下角 (刀片板体包围盒 x 0.01~89.99 / y 0.01~67.99 / z 0~1.67,正是 90×68×1.6)。

刀片绕 X 轴 +90° 竖起:刀片 y(板高) → 底板 z(高度),刀片 z(板厚) → 底板 -y。 MathTransform 是 16 元素数组、单位米:[R00..R22 (9 个), dx, dy, dz, 缩放, 0,0,0]。

⛔ 更正(2026-09-12 晚实测):那 9 个数是列主序,不是这里原先写的"行主序"。 绕 X 轴 +90°(刀片 y→底板 z、刀片 z→底板 −y)要写成 [1,0,0, 0,0,1, 0,-1,0],写成它的转置 [1,0,0, 0,0,-1, 0,1,0] 会把刀片倒插 (板往 −z 长,包围盒 z 变成 −47.99~19.99)。 ⇒ 两种都是合法旋转、都不报错,只能靠回读包围盒判;place_and_probe.py --validate 就是干这个的:在够不着的高度摆一次,看 box 的 z 是往上长还是往下长。

槽 槽心 y 平移 (dx, dy, dz) mm
1 8.36 (4.00, 9.16, 5.60)
2 28.68 (4.00, 29.48, 5.60)
3 49.00 (4.00, 49.80, 5.60)
4 69.32 (4.00, 70.12, 5.60)
5 89.64 (4.00, 90.44, 5.60)
  • dx = SLOT_CX(49) − J1_X(45) = 4.00(金手指中心对齐槽心 x)
  • dy = 槽心 y + 0.8(板厚 1.6,转后 z=0 面朝 −y)
  • dz = Z0 = 塑料体高 11.1 − 插入深度 D_INSERT
  • ⚠ D_INSERT 是估值 5.5(LCSC 页和 SOFNG 都查不到)。装配跑通后可用干涉检查反推: 刀片底边刚好贴到槽底时的 Z0 才是实测值。改这一个常量即可。

摆位用属性赋值:comp.Transform2 = mathUtil.CreateTransform(那 16 个数)(见 §8,Transform2 是属性)。

15. ✅✅ 装配终于打通:用 AddComponent4,不是 AddComponent5

c = asm.AddComponent4(path, '', 0.0, 0.0, 0.0)     # (CompName, ConfigName, X, Y, Z)
  • ⛔ AddComponent5(8 参)在这台机器上恒返回 None,试过 ConfigOption=0/1、 坐标 0/-1、正反斜杠、先 OpenDoc6 预载、总装先存盘——全都 None,且不抛异常。
  • ✅ AddComponent4(5 参)一次就成,返回 PhoneRail_blade.step-1。 ⇒ 遇到 AddComponent5 返回 None 别再调参数,直接换 AddComponent4。
  • 类型库里还有更简的 AddComponent(CompName, X, Y, Z) 和批量的 AddComponents(Names, Transforms)(可一次给变换,没试)。

被插的文件用临时目录里的顶层 SLDASM,别另存

导入 STEP 后不要关文档,直接遍历 GetDocuments 找: 有路径 + GetType == 2 + 标题含板名 + 后缀 .SLDASM → 得到 %TEMP%\swx…\IC~~\PhoneRail_base.step.SLDASM,把它喂给 AddComponent4。

  • ⛔ 别用 glob 猜文件名:真实名字是 PhoneRail_blade**.step**.SLDASM(中间夹着 .step)。 用 GetDocuments 遍历最可靠。
  • ⛔ 别走"另存再插":SaveAs3 出来的是废件(§11),CopyDocument 返回 2(失败), OpenDoc7 + GetOpenDocSpec(DocumentType=1) 也打不开 STEP(返回 None)—— STEP 走的不是 OpenDoc* 那条文档通道。
  • IImportStepData 全表只有 MapConfigurationData 可写, "导入成单一零件"在 API 层没有开关。

实测结果:两块板都插进同一个总装,GetComponents(True) = 2 个顶层零部件, ToolsCheckInterference2 可正常调用(1 s 返回)。

⛔⛔ AddComponent4 要求被插入的文档当时是打开的(2026-09-13 坐实)

自己建的托盘零件,SaveAs3 返回 True、err=0、os.path.exists 为真、文件 85 KB —— 一切都对,但 AddComponent4(那个路径) 恒返回 None。 唯一的差别是:我在插入前 app.CloseDoc(零件标题) 把它关掉了。

对照本会话三次成功的插入(STEP 临时顶层件 §15、风扇占位体 §32、Orion OD6025), 共同点都是目标文档仍开着。把 CloseDoc 去掉后,同一份文件立刻插入成功 (AddComponent4 → Tray_v2-1,装配包围盒精确命中期望)。

⇒ 固定动作:建好零件 → SaveAs3 → 别关它 → AddComponent4 → 之后再关。 ⚠ 这个坑的伪装性很强:它和「文件没落盘」(§34 heredoc 中文那条)现象完全一样, 都是 AddComponent4 → None。分辨办法是先 os.path.exists + 看文件大小 —— 文件在 ⇒ 是本条(没打开);文件不在 ⇒ 是落盘失败。

16. ⚠ 干涉检查的第 2 参:只能传 EMPTY,别传零部件元组

⛔ 本节整节已作废 —— ToolsCheckInterference2 是死的(§13 更正),改用 §20。 保留只为记住"两种口径都试过"这段弯路。

已验证可用的唯一形式(全量检查):

pc = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None)
pf = wc.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None)
n  = asm.ToolsCheckInterference2(0, EMPTY, False, pc, pf)      # ✅ 1 s 返回

⛔ 改成 ToolsCheckInterference2(len(comps), comps, False, pc, pf) (把 GetComponents() 拿到的 COM 对象元组直接塞进去)会报 "服务器出现意外情况"(-2147417851)——两种口径都试过,顶层和含子件都一样。 ⇒ 想限定范围,得先把零部件封送成 SafeArray of IDispatch,不能直接传 Python 元组。

17. 摆位:GetMathUtility 是方法,要加括号

报 -2147352573 找不到成员 的原因查明了 —— 类型库里:

成员 invkind
ISldWorks.GetMathUtility() 1 方法(⚠ 少数几个无参却是方法的例外,见 §2)
IMathUtility.CreateTransform(ArrayDataIn) 方法
IMathUtility.CreateTransformRotateAxis(PointObjIn, VectorObjIn, Angle) 方法
IComponent2.Transform2 属性读/写
IComponent2.SetTransformAndSolve2(XformIn) 方法(摆位更稳妥的入口)
IMathTransform.ArrayData 属性读/写
mu    = app.GetMathUtility()                  # ⇐ 加括号
xform = mu.CreateTransform(arr16)             # arr16 可能要封送成 VT_ARRAY|VT_R8
comp.SetTransformAndSolve2(xform)             # 或 comp.Transform2 = xform

⚠ §2 那条"无参 = 属性"的规律有例外:GetMathUtility 无参却是方法。

⛔ 但加括号也不行 —— app.GetMathUtility() 照样报"找不到成员"。 动态派发对这个成员建的包装是坏的。绕过 getattr,直接按 dispid 调:

did = app._oleobj_.GetIDsOfNames('GetMathUtility')      # → 132(本机实测)
raw = app._oleobj_.Invoke(did, 0, pythoncom.DISPATCH_METHOD, 1)
mu  = wc.Dispatch(raw)                                   # → 可用的 IMathUtility

⇒ 通用招数:动态派发报"找不到成员"时,先用 _oleobj_.GetIDsOfNames(名字) 确认 这个名字在不在。在 → 用 _oleobj_.Invoke(dispid, 0, DISPATCH_METHOD 或 DISPATCH_PROPERTYGET, 1) 手动调;不在 → 名字真错了(如 MathUtility 就报"未知名称")。 IGetMathUtility(dispid=133)虽然名字在,但两种 Invoke 都失败,别用。

18. 进度小结(2026-09-12)

已验证可用:STEP 导出(异步+4MB 分块)→ LoadFile4 导入 → GetDocuments 找临时顶层 SLDASM → AddComponent4 插入 → ToolsCheckInterference2(0, EMPTY, …) 可调 → CloseAllDocuments(True) 清理。

未完成:① 刀片摆位(§17)② 干涉结果解读(PComp 数组解析、限定范围检查 §16)。

19. 📍 当前 SolidWorks 里的装配体(2026-09-12 交接点)

用户手工把两块板载入了同一个装配体(不是脚本做的):

活动文档:D:\DCDC\新建文件夹\3D_BASE-PCB_2026-09-12.SLDASM
├─ 3D_BASE-PCB_2026-09-12.step-1     底板(用户自己重导的 STEP,不是脚本那份)
│     包围盒 x 0.01~97.99  y 0.01~97.99  z -6.30~13.17  mm
│     变换 = 单位阵 ⇒ **原点 = 板左下角,与 EDA 板坐标一致**
│     引用 %TEMP%\swx32108\IC~~\3D_BASE-PCB_2026-09-12.step.SLDASM  ⚠ 临时件
└─ PhoneRail_blade-1                 刀片
      包围盒 x -34.57~55.40  y 33.71~101.68  z -2.35~4.02  mm
      变换 = 无旋转 + 平移 (-34.6, 33.7, 2.4) mm ⇒ **还是平放的,没竖起来**
      引用 D:\DCDC\新建文件夹\PhoneRail_blade.SLDASM  ✅ 实体文件,可用

状态快照存在 .claude/r2/3d/_sw_assembly_state.json。

磁盘上的文件(D:\DCDC\新建文件夹\,126 MB,未进 git)

文件 是什么
3D_BASE-PCB_2026-09-12.SLDASM 29.97 MB 装配体本身(活动文档)
3D_BASE-PCB_2026-09-12.step 53.87 MB 用户自己重导的底板 STEP
PhoneRail_blade.SLDASM 16.61 MB 刀片零部件 ✅ 实体,装配体引用的就是它
PhoneRail_blade.step 31.22 MB 刀片 STEP
~$*.SLDASM(各 6 B) SolidWorks 锁文件 ⇒ 这两份正被打开

⚠⚠ 底板零部件引用的是临时件(%TEMP%\swx32108\IC~~D_BASE-PCB_2026-09-12.step.SLDASM), 不是上表那个 29.97 MB 的装配体文件(那是装配体自己)。 ⇒ 只要没 CloseAllDocuments、没重启 SolidWorks,引用就还在; 一旦清理或重启,重开装配体时底板零部件会丢引用。 ⇒ 要长期可用:在 GUI 里把底板零部件也另存到 新建文件夹\ 下并替换引用 (⛔ 别用脚本的 SaveAs3,那个写出来是废件,见 §11)。

⚠ 新会话第一件事:别急着 CloseAllDocuments —— 那会清空临时目录、 让当前这个装配体的底板引用断掉。先确认用户是否已把底板另存。

下一步只差摆位

刀片要绕 X 轴 +90° 竖起,再平移到某个槽位。因为底板原点 = 板左下角, §14 那张表的平移量可以直接用(dx = 4.00、dy = 槽心 y + 0.8、dz = Z0)。 卡点仍是 §17:CreateTransform 的 pywin32 封送没解决。

绕开的办法:在 GUI 里手工拖 + 加配合(比解封送快),或者继续攻 §17。

20. ✅ 干涉检查的唯一可用入口:IInterferenceDetectionMgr

idm = asm.InterferenceDetectionManager          # 属性,不加括号
idm.TreatCoincidenceAsInterference   = False    # 面贴面不算干涉
idm.IncludeMultibodyPartInterferences = True
idm.TreatSubAssembliesAsComponents   = False    # False ⇒ 报到**叶子零件**,能指名
idm.IgnoreHiddenBodies = True
idm.UseTransform       = False
n = idm.GetInterferenceCount                    # ⇐ 属性形式
for it in idm.GetInterferences:                 # ⇐ 属性形式,返回 tuple
    o = wc.Dispatch(it)
    v_mm3 = o.Volume * 1e9                      # Volume 单位是 m³
    names = [wc.Dispatch(c).Name2 for c in o.Components]
idm.Done()                                      # 每轮都要调,否则结果会串
  • ⛔ GetInterferenceCount / GetInterferences 在 sldworks.tlb 里标 invkind=1(方法), 但动态派发下必须当属性取(不加括号),同 §9。加括号报 'int'/'tuple' object is not callable。
  • ⛔ SetSelectedComponents 不存在(GetIDsOfNames 报"未知名称")。想限定范围用 GetComponentsTransformInterference(Component, Transform, InterferingComponent)(memid=18), 或者像下面那样自己按子树过滤。
  • IComponent2.Name2 返回的是全路径(顶层-1/子装配-1/叶子-1), ⇒ 判断某个叶子属于哪块板,看路径前缀,不能看叶子名(R0402~… 两块板上都有)。

⚠ 必须按子树过滤:底板自身就有几百处"预存干涉"

EasyEDA 导出的 STEP 里,器件 3D 模型压进板体 / 相邻器件模型互穿是常态。 实测两块板各自摆开、互不接触时,全装配干涉数就已经是几百。 ⇒ 直接看 GetInterferenceCount 等于看噪声。只统计 「一端在刀片子树、另一端在底板子树」 的跨对,并且先在"刀片悬空"姿态下 确认跨对数 = 0 作为基线,再往下扫。

21. ⚠⚠ STEP 里的 3D 模型严重不全 —— 干涉结论的适用范围

实测(GetBox 递归到叶子,转回各自板的局部坐标):

3D 模型覆盖 实测最大凸出 设计值
刀片 A 面 一个都没有 0.00 手机主板 + J4(BM24) + C60(11)
刀片 B 面 75 个标准库件 4.70(一个 1.25mm 2P 座) —
底板 背面 有,但缺高件 6.30(SRP1265A 电感) 最高 12(C2/C3 直立电解)
底板 插槽面 ✅ 插槽在 插槽体 11.56 高,顶面 z=12.33 文档写"约 9"

⇒ 能用干涉检查回答的:舌头 / 台阶与插槽塑料体的配合、刀片板与底板板面的距离。 ⇒ 不能用干涉检查回答的:刀片之间会不会打架、刀片与底板背面高件的关系 —— 缺的恰恰是最高的那些件。那些只能靠 pcb_place.py 里的高度预算算,别指望 3D。

另外:插槽 3D 模型的 y 中心比焊盘中心偏 −0.45 mm(五个槽一致), 而刀片板厚只有 1.67 —— 摆位时若按焊盘槽心对中,在模型里是偏心的。

22. ⛔⛔ 自动化连接会丢:GetActiveObject 报 -2147221021 操作无法使用

本会话实测的完整现象与排查顺序(这一串每条都踩过):

先确认不是崩溃(这三条同时成立 ⇒ 进程好好的,别急着重启):

查什么 怎么查 本次实测
进程在不在 Get-Process SLDWORKS 在,Responding=True
忙不忙 采样 TotalProcessorTime 3 秒 0 ms,完全空闲
引用链断没断 ls %TEMP%\swx<PID>\IC~~\ 584 个文件,完好

⇒ 重启是最后手段:%TEMP%\swx<PID>\ 跟着 PID 走,一重启底板零部件的引用就断了(§19)。

真因之一:SolidWorks 停在活动命令里时拒绝自动化。 本次是干涉检查的 PropertyManager 没退出(脚本崩在 AttributeError, finally 里的 idm.Done() 只清了 COM 侧,GUI 侧命令还开着)。 ⇒ 最快的判据是截屏看状态栏左下角 —— 它当时写着「选择要检查干涉的零部件。」, 模型也还是干涉检查特有的半透明灰。Esc 之后状态栏恢复、着色恢复。 ⇒ ⚠ 但退出命令后 ROT 注册并没有自己回来 ⇒ 活动命令不是唯一原因。

⛔ Dispatch 真的会另起一个空实例(§1 那条警告已实测坐实)

连接前 SLDWORKS PID: ['32108']
GetActiveObject 失败 → 改用 Dispatch
连接后 SLDWORKS PID: ['22388', '32108']   ⇐ 新进程
RevisionNumber = 33.1.1   文档数 = 0      ⇐ 空的,原装配体不在里面

⇒ 护栏写法:连接前后各抓一次 PID 集合,比对有没有新增; 再用 GetDocumentCount / ActiveDoc.GetTitle 确认连的是不是那个装配体。 没有这层护栏,会对着一个空实例跑完整套脚本而毫无察觉。

两个附带的 Windows 侧坑

  • Get-Process.MainWindowHandle 给的不一定是主窗口(本次拿到的是 SysShadow 那个影子窗口,句柄 2692654,而真正带标题的是 Afx:... 类的 857392)。 ⇒ 要发按键就自己 EnumWindows,按「类名以 Afx: 开头 + 标题含 SOLIDWORKS」挑。
  • PowerShell Add-Type 的内联 C# 里不能有 public static … Main(…) —— 会被当成程序入口点,报「签名错误,不能作为入口点」,整个类型编译失败。换个名字即可。

23. 插槽配合的实测数据(2026-09-12,中间槽 J36)

刀片按「绕 X +90° 竖起、dx=4.00、板厚中面对焊盘槽心 y=49.00」摆入, 自上而下扫 dz(= 刀片底边 / 金手指尖的 z),只统计刀片↔底板跨对:

dz 跨对数 干涉体积 谁碰谁
13.00 / 12.50 0 0 —
12.00 2 0.466 mm³ 刀片 Board ↔ PCIE-64P11L
二分 首次接触 12.312,恰好不干涉 12.328

塑料体顶面 z = 12.33 ⇒ 表面看是「一点都没插进去,d = 0」。

⚠ 但别就此下结论,体积数对不上「平贴在实心顶面上」:

  • 若顶面是实心、刀片平贴:接触面积 = 舌头 36.0 × 板厚 1.67 = 60.1 mm², dz=12.00 时(压入 0.33)体积应是 19.8 mm³。
  • 实测 0.466 mm³,只有 2.3%;反推接触面积仅 1.41 mm²,而且分成 2 个区。

⇒ 这是两处很局部的接触,不是整条边平贴。两种候选解释,尚未分清: (A) 腔比舌头短,舌头两端顶在腔的两端壁上(1.41 mm² ≈ 两端各压入 0.25 mm 的量级) —— 若成立,说明舌头 36.0 还是太宽,正是用户待办 ⭐① 要量的那个数; (B) 腔是够的,但插槽 3D 模型的 y 中心比焊盘中心偏 −0.45, 1.67 的板按焊盘槽心对中就会蹭到腔口边缘。

⇒ 分辨办法:在腔内高度做 y 向扫描找无干涉窗口((B) 会有明显窗口), 再做 x 向扫描 / 换窄舌头验证 (A)。两个都还没跑成。

§22 续:ROT 丢了之后,哪些路已经试死

试法 结果
等待重试 12 次 × 4 s(共 44 s) 全失败,不是「忙一下」
Esc 退出干涉检查命令(确认生效:状态栏恢复、半透明着色恢复) ROT 仍未回来
枚举整张运行对象表 pythoncom.GetRunningObjectTable().EnumRunning() 0 条(不是"没有 SolidWorks",是一条都没有)
带版本号 ProgID 只有 SldWorks.Application / .33 存在(.32/.31/.1 报「无效的类字符串」),两个都是「操作无法使用」
UAC / 会话隔离理论 排除:两进程同用户、同会话 2、均未提权(客户端 Medium 完整性)

⇒ 结论:注册一旦被撤销,客户端侧无论如何都连不回去,只能靠 GUI 侧动作或重启 SolidWorks。 ⇒ 所以重点在预防:脚本里任何异常都不能把 SolidWorks 留在活动命令里。 干涉检查尤其要 try/finally 保证 idm.Done(),并且出异常后主动再 Esc 一次 (idm.Done() 只清 COM 侧,GUI 侧的 PropertyManager 不会跟着关)。

⚠ 小坑:拿全精度值去比对"四舍五入过的快照"

_blade_xform_backup.json 存的是全精度(-0.03458581301934173), 而 _sw_assembly_state.json 是round 到 6 位的(-0.034586)。 用 1e-9 容差比会判「不一致」——是误报。按快照的舍入位数定容差(这里 5e-6)。

24. ⛔⛔ 更正 §22 的因果:丢 ROT 是用了 IDM,不是"崩在活动命令里"

§22 当时把失联归因于"脚本崩溃时把 SolidWorks 留在了干涉检查命令里"。这个因果是错的。

第二次实测:place_and_probe.py sweep exit code 0 干净跑完、没有任何异常 (idm.Done() 正常调用、兜底 except 根本没触发),跑完之后 GetActiveObject 照样报 -2147221021,运行对象表照样枚举为 0 条。

两次失联的共同点只有一个:重度使用了 IInterferenceDetectionMgr。 而此前十几个短脚本(probe1~6,含一次轻量 IDM 调用)反复连接都没事。

⇒ 实操结论:把 IDM 当成"用完连接就会没"的一次性资源。 凡是需要 SolidWorks 连接的事,必须和 IDM 调用放在同一个进程里一次做完; 不要指望跑完 IDM 之后还能再连上去截图 / 读几何 / 收尾。 ⇒ 恢复只有一条路:taskkill /F /IM SLDWORKS.exe → 重启 → 重导 STEP(底板 ≈50 s)。 所以脚本最好像 slot_cavity_test.py / slot_cavity_measure.py 那样自带自愈 (连不上就自己杀、自己起、自己重导),而不是假设连接还在。

25. ⛔ IBody2.GetFaceCount 返回 0(假值),别拿它做判据

实测插槽零件 3 个实体,GetFaceCount 全部返回 0 —— 实体不可能 0 个面。 用 len(body.GetFaces)(属性,返回 tuple)。

由此引出的一个判据写法教训(比这个 API 本身更重要)

slot_cavity_test.py 原来的判据是:

if r > 0.80 or faces < 30:      # r = 实体体积/包围盒
    print('实心块,没有槽腔')

实测 r = 0.492(明明说"不是实心"),却被假值 faces = 0 满足的 faces < 30 一票翻盘,打印出与证据相反的结论。

⇒ 不要把强弱不同、可信度不同的信号用 or 并列。 弱信号(尤其是可能返回假值的 API)只在读到有效值时才允许参与判断。 这与 §11 / §13 是同一类错误:返回值看着正常 ≠ 它在工作。

26. ✅ 几何遍历:GetFaces 是方法,以及一个通用取值助手

IBody2.GetFaces 取回来是个 python 方法对象(len() 报 object of type 'method' has no len()),要加括号。这是 §9「0 参即属性」的 又一个例外(前一个是 §17 的 GetMathUtility)。

⇒ 与其逐个记,不如用这个助手 —— 它只认 python 侧的方法类型, 因此不会犯 §2 那个「COM 对象一律 callable ⇒ 把子对象当函数调」的错:

def prop(o, name):
    v = getattr(o, name)
    if type(v).__name__ in ('method', 'function', 'builtin_function_or_method'):
        return v()          # 动态派发把它包成了方法
    return v                # 属性(含返回 COM 子对象的情形)

实测可用的遍历链(全部经 prop()): part.GetBodies2(0, True) → body.GetFaces → face.GetSurface → surface.IsPlane / surface.PlaneParams(前 3 个是法线)→ face.GetBox(米)/ face.GetArea(m²)/ face.GetLoops → loop.IsOuter / loop.GetEdges。

27. 🎯 PCIE-64P11L 插槽模型的实测解剖(这决定了 3D 能回答什么)

零件本地坐标:包围盒 x ±20.50 / y −4.39~4.96 / z −1.56~10.00,塑料体顶面 z = 10.00。

3 个实体:两个 Ø 定位柱(各 4.8 mm³、26 面)+ 塑料壳(1883 个面、2172.8 mm³)。 体积/包围盒 = 0.492 ⇒ 是建了真结构的连接器,不是简化实心块 (⚠ 早前据假值 GetFaceCount=0 得出的"实心块"结论作废,见 §25)。

触点通道:z=3.00 处一排排 0.40×0.80 的面,x 间距正好 1.00 mm(= PCIe 脚距), 分两排在 y −0.96~−0.16 与 1.64~2.44 ⇒ 两排中心 −0.56 / +2.04, 卡槽中心线 y = +0.74。

换算到装配坐标:assembly_y = part_y + 48.26(由槽3 的 y 43.87~53.23 反推) ⇒ 卡槽中心线落在 assembly y = 49.00 = 焊盘槽心。 ⇒ ⛔ 此前怀疑的"插槽模型 y 偏心 −0.45"是误判 —— 那 −0.45 只是包围盒被 单侧卡扣台肩(y 到 +4.96)拉偏,卡槽本身正对焊盘中心。

🎯 卡槽开口的准确尺寸(顶面内环,slot_opening_measure.py 实测)

顶面 z=10.000 一共 71 个环:外环 1 个(39.00 × 7.30)、触点窗口 64 个 (0.40 × 1.00,两排在 y −1.96~−0.96 与 2.44~3.44)、角窗 4 个, 卡槽开口 2 个 ——

环0  x −17.540 ~  −5.390 (12.150)   y −0.160 ~ 1.640 (1.800)
环1  x  −4.610 ~  17.540 (22.150)   y −0.160 ~ 1.640 (1.800)
          └─ 中间 0.78 mm 实体 = PCIe 防呆键,中心 part x = −5.00

⇒ 开口全长 = ±17.540 = 35.08 mm,开口宽 = 1.80 mm,键中心 part x = −5.00。

三条独立佐证(都对上了才敢用):

  1. 开口 y 中心 = +0.74 = 由触点两排(−0.56 / +2.04)推出的通道中心线;
  2. assembly_x = part_x + 49.00 ⇒ 键中心落在装配 x = 44.00, 正是封装针位表 _U 里 u −6.5 ~ −3.5 那个键槽的中心(SLOT_CX 49 − 5);
  3. assembly_y = part_y + 48.26 ⇒ 通道中心落在装配 y = 49.00 = 焊盘槽心。

⇒ 刀片舌头 36.00 确实插不进(0.92 mm 过宽)

值
插槽开口长 35.08
刀片舌头(pcb_blade.TONGUE_X0/X1 = 27.0/63.0) 36.00
差 每侧 0.46,共 0.92 mm ⇒ 舌头两端压在端壁上,插不下去
开口宽 1.80 vs STEP 板厚 1.67 ✅ 厚度方向有 0.13 余量,没问题

与碰撞法互相印证:干涉扫描在 dz=12.00(压入 0.33)测得体积 0.466 mm³, 反推接触面积 1.41 mm²;按实测过盈算应是 0.46 × 1.67 × 2 = 1.54 mm² —— 吻合。 ⇒ d = 0.00、y ±1.6 全程干涉、x ±2.0 全程干涉 都是真实几何冲突。

⛔ 但注意判据的先后:碰撞法本身推不出这个结论(§23 那段推理是无效的, 因为"腔比舌头短"和"模型没建腔"会给出完全相同的现象)。 是先直接量到开口 35.08,碰撞数据才获得解释力。顺序不能倒过来。

⚠ 这是厂商 STEP 的标称几何,不含公差。用户待办 ⭐① 的卡尺实测仍是权威口径; 本节的价值是给出了一个具体待验证的数(35.08)和明确方向(36.00 偏宽)。

28. ⛔ 再更正 §24:丢 ROT 与 IDM 无关,是长时间大量 COM 调用后普遍会丢

§24 把失联归因于"重度使用 IInterferenceDetectionMgr"。这个因果也不对。

第三次失联:重启后全程没碰过 IDM,连续跑了 8 个纯几何脚本 (slot_cavity_test / slot_cavity_measure ×2 / slot_opening_measure ×2 / slot_depth_measure / rib),每个都正常复用连接;到第 9 个 (walls.py)时开始返回空结果,第 10 个直接 GetActiveObject 失败。

⇒ 实际规律:SolidWorks 的 ROT 注册在长时间、大量 COM 调用之后会消失, 与调用的是哪类 API 无关。IDM 只是第一、二次碰巧在场。 ⇒ 实操结论不变、但理由要改: 每个脚本都要能独立自愈(连不上就 taskkill → Dispatch 重启 → 重导 STEP, 底板 ≈50 s),不要假设上一个脚本留下的连接还能用。

⛔⛔ 连接将死时的表现:不是报错,是静默返回空

walls.py 那一轮返回「通道内竖壁 0 个」,看上去像个正常的几何结论。 实际是连接已在失效、每个面的 GetBox() 都在抛异常,而被 try/except: continue 逐个吞掉,最后自然剩 0 个。

⇒ 这是本会话第三次栽在同一类坑上(前两次:ToolsCheckInterference2 的假 0、 GetFaceCount 的假 0)。统一对策:任何"遍历 + 过滤 + 计数"的脚本, 必须打分级计数器(总数 / 取值成功 / 各级过滤后剩余)并记录首个异常, 这样"真的是 0"和"全都失败了"一眼可分。不要让 except: continue 静默吃掉全部样本。

§28 附:连接将死的确切表现(有了硬证据)

给面遍历加上分级计数器之后,抓到了这样一轮:

分级计数: {'total': 1883, 'boxok': 495, 'boxfail': 1388, 'zerox': 109, 'zband': 83, 'yband': 28}
首个 GetBox 异常: (-2147417848, '被调用的对象已与其客户端断开连接。')

1883 个面里 1388 个取值失败(RPC_E_DISCONNECTED),只有 495 个成功。 于是"通道两端壁"和"键筋两侧"这些恰好落在失败那 74% 里的面全部消失, 输出看上去是一个完整、干净、却完全错误的几何结论。

⇒ 没有计数器时(上一版 walls.py),同样的情况输出的是 「通道内竖壁 0 个」 —— 一个看着像答案的答案。 ⇒ 所以计数器不是锦上添花,是这类脚本的必需品。 另外:boxfail > 0 就应当直接停手,不要拿残缺样本出结论。

⚠ 这个插槽模型的精度上限:0.2 mm 级的配合判不了

同一个模型里有两处自相矛盾的数,说明厂商模型只在毫米级可信:

  • 簧片触点把通道夹到 1.568,比它要接纳的 1.6 标称卡还窄;
  • 防呆键筋宽 1.78(顶端 0.78),而 PCIe CEM 的键筋应在 1.5 附近。

⇒ 判据边界:用这个模型回答「差 0.9 mm 的开口长度够不够」是可以的; 回答「差 0.18 mm 的键筋/缺口配合」不行。结论的可信度要按被测量的量级来定, 不能因为"同一个模型量出来的"就一视同仁。

29. ✅ 新版两块板的复核结果(2026-09-12 深夜,3D_*-PCB_2026-09-12.step)

用户重导的两份 STEP(底板 44.9 MB / 刀片 25.9 MB)实测,收窄后的舌头装得进:

项 设计 STEP 实测 判
刀片外形 89.5 × 67.0 × 1.6 89.47 × 66.97 × 1.67 ✅
舌头宽 34.50 34.474 ✅ 对开口余 0.30/侧
台阶高 9.9 9.900 ✅
防呆缺口 中心 40.0 / 宽 1.9 / 深 8.3 x 39.037~40.963(1.926)/ 深 8.3 ✅
插槽开口 — 35.080 × 1.800,键中心 −5.000 与上一轮一致
腔深 — 8.000(顶面 10.660 − 腔底 2.660,零件本地) —
插入深度 d 8.00 8.00 达成 ✅ 刀片底边离板面 2.66

⛔ 最重要的一条:「首次接触」不是「机械止点」

dz 扫描的首次接触在 9.296(d 只有 3.03),照上一轮的读法会得出 「还是插不到底」。错的——那是 64 根簧片触点:模型把簧片建成刚性、 通道被夹到 1.568 < 板厚 1.67(§28),所以板一碰到簧片就报干涉,而实物里簧片是要被压开的。

分辨办法(本轮定型,以后照抄):不看"有没有干涉",看体积随深度的斜率, 并且必做一个阳性对照(压到明知该撞的位置,确认检测在工作):

dz 插入深度 跨对数 总体积 最大单块 读法
4.35 7.98(腔底上方 0.02) 72 1.491 mm³ 0.50 簧片蹭,塑料没挡
3.80 8.53(压进腔底 0.53) 70 28.243 mm³ 18.20 ← 阳性对照,检测确实在工作

⇒ 只有簧片蹭 ⇒ 卡能坐到腔底,d = 8.00。 ⚠ 别拿"跨对数"当判据:两行的跨对数几乎一样(72 vs 70),只有体积分得开。

⛔ 量轮廓别用「面包围盒筛选」,要抽底面外环的折线点

第一版用「完全落在 y ≤ 台阶+0.3 的面」筛舌头,量出 85.5(假值)—— 被板四角的 R2 圆角面污染(圆角面的 y 跨度也很低)。 把台阶也量成 11.887(那其实是圆角外角点 (0.013, 11.887))。

✅ 正确做法:取板体法线 −Z 且面积最大的平面 = 底面 → GetLoops → IsOuter 的外环 → 每条边 GetCurveParams2 取两端点 → 直接得到轮廓折线。 本例 22 条边 44 个点,y<14 的 18 个点一眼就能读出凸字形(舌头两端 27.763 / 62.237、 台阶肩 9.900、缺口两壁 39.037 / 40.963、45° 导入角 38.537→39.037)。

30. ✅ 5 块刀片全插上:刀片之间不打架

AddComponent4 用同一个路径连插 5 次没问题(得到 -1…-5),逐个给变换即可。

项 值
干涉总数(fast 口径) 360 = 5 × 72,全是「刀片↔底板」的簧片蹭
刀片↔刀片 0 处
相邻刀片净距 13.950 mm(间距 20.32 − 板 1.67 − B 面器件 4.70)

⚠ 只对 B 面有效:刀片 A 面在 STEP 里一个模型都没有(§21), 手机主板 + J4/C60 那一侧的间距这张图答不了,仍要靠 pcb_place.py 的高度预算。

31. API 增补与一条对 §22 的更正

  • ✅ doc.ShowNamedView2('', 7|1|4) + doc.ViewZoomtofit2(属性)+ doc.SaveBMP(路径, w, h) 三件套可用,截图便宜 ⇒ 摆完先截图再干别的(后面的干涉检查很可能把连接跑死)。
  • ⛔ IModelDocExtension.GetPackAndGo(dispid 207)三条路全死: 属性形式取到 python function 对象(再点属性报 'function' object has no attribute)、 加括号报"非选择性的参数"、_oleobj_.Invoke 的 PROPERTYGET / METHOD / 两者或 分别报"无效的参数数目"/"非选择性的参数"。§17 那招在这个成员上不灵。 ⇒ 要长期可用的装配体文件,改用 GUI 的「文件 → Pack and Go」。 ⚠ 由此:脚本 SaveAs3 出来的总装(本轮 5blades\PhoneRail_5blades.SLDASM 41.2 MB) 仍是 §11 那种废件——子件在 %TEMP%\swx<PID>\IC~~\,SolidWorks 一关就断引用。
  • ⛔⛔ 更正 §22:「Dispatch 一定会另起一个新实例」不成立。 它的真实行为是 ROT 里有注册就附着到现有实例,没有才新建。 本轮实测:前两次(用户实例 ROT 已死)确实新建了 17452 / 23272; 第三次用户自己重启过 SolidWorks、ROT 是活的,Dispatch 直接连进了用户那个实例 (日志里 新实例 [] 文档数 295 就是这个意思)。 ⇒ 护栏照旧:连接前后比 PID 集合 + 看 GetDocumentCount,并且要想清楚 "连进用户实例"是不是可接受(本轮是可接受的:只新增文档,不动用户的装配体)。
  • 一轮"重度 IDM"的代价实测:全量干涉一次 155~163 s(底板 245 处预存干涉参与计算), 连做两次之后连接必死。fast 口径(TreatSubAssembliesAsComponents=True)只要 4 s 且天然只剩跨对 ⇒ 能用 fast 就别用全量,全量只在需要指名叶子零件时打一发。

32. ✅ 往装配体里加「本来没有模型」的件(风扇)

本机没有任何 CAD 内核(cadquery / OCC / build123d / trimesh 全没装,只有 numpy + numpy-stl)⇒ 要凭空造一个占位体,两条路:

路 结果
STL(numpy-stl 能写) ⛔ 不走:SolidWorks 导 STL 默认是图形网格体,干涉检查对网格体不可靠
手写 STEP AP214 ✅ 可行且已验证:make_box_step.py 生成 60×60×25 的 B-rep 实心块(152 个实体行),导入后 GetBodies2(0,True) = 1 个实体 / 6 个面 / 包围盒 060,060,0~25

手写 B-rep 的唯一要点:共享边必须复用同一个 EDGE_CURVE(12 条边被 6 个面两两共用, 靠 ORIENTED_EDGE 的 .T./.F. 定方向)。不复用就不是流形实体,会被导成一堆曲面。

⛔ 单体 STEP 导进来同样没有路径,但它另存是安全的

LoadFile4 一个单体 STEP → 得到 type=1 的未保存零件,GetPathName 是空串 (同 §10),于是 AddComponent4 无从引用(实测返回前就卡在"拿不到路径")。

⇒ 先 fan_doc.Extension.SaveAs3(路径, 0, 1, EMPTY, EMPTY, err, warn) 存成真 .SLDPRT(61 KB), 再拿这个路径 AddComponent4。

⚠ 这和 §11 的「另存出来是废件」不矛盾:§11 坏在装配体的子件仍指向 %TEMP%; 单体零件没有外部引用,另存就是完整的。⇒ 判别标准是「有没有外部引用」,不是「用没用 SaveAs」。 (仍按 §11 的教训验一次:不看 SaveAs3 的返回值,看 os.path.exists + 文件大小。)

摆位:把零件本地轴转到想要的全局轴

风扇要沿 x 吹 ⇒ 本地 z(厚度 25)转到全局 x。列主序 9 元素 = 三个本地轴的像: [0,1,0, 0,0,1, 1,0,0](本地 x→全局 y、本地 y→全局 z、本地 z→全局 x), 于是本地点 (a,b,c) → 全局 (c+tx, a+ty, b+tz),平移量直接按想要的包围盒反推。

🎯 上哪儿弄机械件的 STEP:Orion Fans 是唯一能脚本直下的(2026-09-13 实测)

找 60×60×25 风扇模型时把主流渠道挨个试了一遍,结论对以后找任何机械件都适用:

渠道 实测结果
orionfans.com ✅ 225 个 .stp 直接挂在站上,形如 https://orionfans.com/3d/OD6025_m0_frame.stp,curl 直下(application/step,1.3 MB),无登录无表单
山洋 SANYO DENKI ⚠ 要填表(techcompass-lp.sanyodenki.com),文件名 9G_60x25_RBD,人可以下、脚本不行
Noctua ⛔ HTTP 429,WebFetch 和 curl 都被限流(站点级,不是鉴权)
GrabCAD / Digi-Key ⛔ HTTP 403(curl 直接被挡)
Same Sky(原 CUI Devices) ⛔ 页面可达(200),但 3D 是 JS 组件,HTML 里没有任何 .stp 直链,还挂着 /cad-model-terms
TraceParts / 3DContentCentral / partcommunity ⛔ 一律要登录
Mouser ⛔ 连站点根都 403

⇒ 先试 Orion;要别的品类时,判据是「HTML 里能不能 grep 出 href=".../xxx.stp"」。 ⚠ 本机 curl 有网(raw.githubusercontent.com 200),但 gh 没装,GitHub 代码搜索这条路走不通。

⛔ 删零部件:EditDelete 是假成功,要用 Extension.DeleteSelection2(0)

一次删两只占位体,踩到三层坑,每层的表现都是「看着成功了」:

写法 现象
SelectByID2(nm,'COMPONENT',0,0,0,False,0,**None**,0) ⛔ 报「类型不匹配,参数 8」——第 8 参 Callout 是对象参数,要传 VARIANT(VT_DISPATCH,None)(同 §11 的 SaveAs3)
SelectByID2(..., **EMPTY**, 0) ⚠ 返回 False,但其实选中了 —— doc.SelectionManager.GetSelectedObjectCount2(-1) = 1。⇒ 别拿它的返回值当判据,要看选中数
doc.EditDelete(属性形式) ⛔ 不报错、也不删。脚本打印了"删除占位体 ×2",回读装配树两只原样还在,干涉里也照旧出现(placeholder ↔ OD6025 12256 mm³)
doc.Extension.DeleteSelection2(0) ✅ 返回 True 且回读验证确实删掉(顶层 10 → 8,残留 0)

⇒ 这是本 skill 第四次栽在「返回值看着正常 ≠ 它在工作」上(前三次:§11 SaveAs3、 §13 ToolsCheckInterference2、§25 GetFaceCount)。 固定动作:删除 / 另存 / 检测这类操作,一律用"回读装配树 / 回读文件"验证,不信返回值。 (这次正是靠"干涉报告里出现了本该已被删除的件"把假成功揪出来的。)

🎯 厂商模型的可信边界:Orion OD6025 实测解剖

单体、203 个面,包围盒 ±31.477 × ±31.477 × 0~25。

量 实测 判
安装孔 4 个 R2.150,轴心 (±24.999, ±24.999),z 贯穿 0~25 ⇒ 孔距 50.00 × 50.00 / Ø4.30 ✅ 标准 60 mm 风扇
风道 圆柱面 R30.5(Ø61) —
框体外缘 62.954 见方 ⚠ 比 60 标称每边大 1.477

⇒ 这份模型只对「安装接口」可信:外壳螺孔按 50×50 照抄没问题, 开孔轮廓必须按标称 60×60 留间隙,不能照抄 62.95。同 §28 那条「按被测量的量级定可信度」。

⛔ 量孔距时的判据陷阱(差点又出一个假结论)

脚本先按「圆柱面 + 半径 1.6~2.8 + 轴线沿 z」筛出 9 个候选,再对 |x|、|y| 各取最小值配对,打印出 「孔距 50.00 × 34.98」 —— 假的。 因为混进了一个 (−30.253, 17.486) 的 R1.829、只有 z 0~3 的杂孔(线夹/铭牌孔)。

⇒ 配对前先按"四孔应关于原点对称 + z 贯穿全高"筛,别对两个互不相关的集合各取极值。 这类"分别取极值再配对"的写法,在任何有杂散样本的几何统计里都会给出看着合理的假数。

33. ✅✅ 原生特征建模打通(草图 + 拉伸),外壳不必用手写 STEP

2026-09-13 实测:能用 API 建可编辑的原生 SolidWorks 特征,回读包围盒精确等于设计值。 ⇒ §32 那条手写 STEP 只适合"本来就没有模型的外购件占位体";自己设计的零件走这条链路。

tmpl = app.GetUserPreferenceStringValue(8)      # → …\templates\gb_part.prtdot
app.NewDocument(tmpl, 0, 0.0, 0.0); part = app.ActiveDoc
part.Extension.SelectByID2('前视基准面', 'PLANE', 0,0,0, False, 0, EMPTY, 0)
sm = part.SketchManager
sm.InsertSketch(True)                            # 进草图
sm.CreateCornerRectangle(0,0,0, 0.050,0.030,0)   # 6 参,单位**米**,返回 4 条线
sm.InsertSketch(True)                            # 再调一次 = 退出草图(toggle)
part.ClearSelection2(True)
part.Extension.SelectByID2('草图1', 'SKETCH', 0,0,0, False, 0, EMPTY, 0)
feat = part.FeatureManager.FeatureExtrusion3(
    True, False, False,   # Sd(单向), Flip, Dir
    0, 0,                 # T1,T2 = swEndCondBlind
    0.0024, 0.0,          # D1 = 2.4 mm, D2
    False, False, False, False, 0.0, 0.0,
    False, False, False, False,
    True, True, True,     # Merge, UseFeatScope, UseAutoSelect
    0, 0.0, False)        # T0 = 从草图基准面开始
# → 返回 '凸台-拉伸1';回读 GetBodyBox = [0,0,0, 50,30,2.4] ✅

要点与坑:

  • 基准面在中文界面下叫 前视基准面 / 上视基准面 / 右视基准面,别用英文名。 稳妥做法是遍历 FirstFeature→GetNextFeature 挑 GetTypeName2 == 'RefPlane'; 草图特征则是 GetTypeName2 == 'ProfileFeature'。
  • FeatureExtrusion3 / FeatureExtrusion2 都是 23 个参数(从 tlb 读的,别猜): Sd, Flip, Dir, T1, T2, D1, D2, Dchk1, Dchk2, Ddir1, Ddir2, Dang1, Dang2, OffsetReverse1, OffsetReverse2, TranslateSurface1, TranslateSurface2, Merge, UseFeatScope, UseAutoSelect, T0, StartOffset, FlipStartOffset
  • ⚠ 同名不同接口:tlb 里 IModelDoc.InsertSketch 是 0 参,而 ISketchManager.InsertSketch(UpdateEditRebuild) 是 1 参。查签名时要连接口名一起看。
  • ⚠ SelectByID2(..., **'PLANE'** / **'SKETCH'**, ...) 这两类返回 True, 而 §33 里 'COMPONENT' 返回 False 却其实选中了 ⇒ 返回值的可信度按类型而异, 统一的判据仍是 SelectionManager.GetSelectedObjectCount2(-1)。
  • ⛔ GetBodies2(0, True) 对"还没有实体的空零件"返回 None,不是空元组 —— 直接 len() 会炸。一律写 part.GetBodies2(0, True) or []。
  • 收尾用 app.CloseDoc(标题) 关掉临时零件;⛔ 永远不要 CloseAllDocuments(§19)。

34. ⛔ 基准面的草图轴映射:必须按面记,不能想当然

第一版托盘整块躺错了轴 —— 画的是 106×106 的托盘,回读却是 x −4~102 / y 0~2.4 / z −102~4(106×106 落在 x-z 平面、厚度沿 y)。 两次实测(都有回读包围盒作证)得到:

基准面 草图 (u, v) → 模型 拉伸法线(正方向) 证据
前视基准面 (x, y) +z 50×30 矩形拉 2.4 → box [0,0,0, 50,30,2.4]
上视基准面 (x, −z) +y (−4,−4)~(102,102) 拉 2.4 → box x −4~102 / y 0~2.4 / z −102~4
右视基准面 (−z, y) +x 50×30 矩形拉 2.4 → box x 0~2.4 / y 0~30 / z **−50~0**

⇒ 三个面里有两个的草图横/竖轴会映射成模型的负轴(上视的 v→−z、右视的 u→−z)。 要在右视基准面上按模型 z 坐标画竖板(法线沿 x),草图横坐标必须取 −z, 直接写 z 值画出来是上下颠倒的。

⇒ 要让零件「平铺在 XY 平面、厚度沿 z」(也就是和 PCB 板坐标同向),画在前视基准面上。 ⚠ 这与直觉相反(名字叫"上视"的那个反而不是俯视平铺),所以这一格必须查表,不能猜。

⛔ 反向拉伸要用第 3 参 Dir,Flip(第 2 参)对凸台无效

想让支柱朝底板的另一侧长,第一版传了 Flip=True(第 2 参)—— 实测无效:柱子仍与底板同向长(box 从 0 长到 15,和底板 0~2.4 同侧,还被 Merge 合并成一个实体)。 Flip 是"反转切除侧",对凸台不起作用。⇒ 反方向用第 3 参 Dir(ReverseDirection)。

⛔ 切除是 IFeatureManager.FeatureCut4,27 参(同名的 IModelDoc 版只有 17 参)

第一次调用照抄了 FeatureExtrusion3 的 23 个参数 ⇒ 报「非选择性的参数」(= 参数个数不够)。 去 tlb 查时又踩了第二层坑:扫描器只记录第一个同名项,把 IModelDoc.FeatureCut4(17 参) 当成了答案。⇒ 扫 tlb 必须按接口名过滤,同一个方法名在不同接口下签名完全不同:

接口.方法 参数
IModelDoc.FeatureCut4 17
IFeatureManager.FeatureCut4 27
IFeatureManager.FeatureCut3 26
IFeatureManager.FeatureCut2 23
IFeatureManager.FeatureCut 20

实测可用的 27 参调用(在柱顶面上画 4 个圆,往下切 6 mm 的 M3 热熔螺母孔):

fm.FeatureCut4(True, False, False, 0, 0, M(6.0), 0.0,
               False, False, False, False, 0.0, 0.0,
               False, False, False, False, False,      # …, NormalCut
               True, True, False, False, False,        # UseFeatScope, UseAutoSelect, 装配体三参
               0, 0.0, False, False)                   # T0, StartOffset, FlipStartOffset, OptimizeGeometry
# → 返回 '切除-拉伸1'
  • ⚠ 按坐标选面会静默撞到邻面 —— 返回 True、选中数 1,但选中的根本不是你要的那个。 实测两例:
    • 托盘面板 z=0 处:想选朝上的面板顶面,选中的是法线 (0,0,−1) 的底面 (那里压根没有朝上的自由面,因为底板是从 z=0 往 −z 拉、支柱又从 z=0 往 +z 长);
    • 端板 L 脚:在 (16.4, 51.345, −7.6) 想选脚顶面,选中的是 box=[2.4,−4,−7.6, 2.4,102,72]、法线 (1,0,0) 的板内侧立面。 ⇒ 选面一律遍历 GetFaces 按法线 + 面积 + 位置筛,选完回读 PlaneParams 与包围盒确认, 再 face.Select4(False, EMPTY)。别用坐标去撞。 ⇒ 连带教训:SelectByID2 的返回值和选中数都不能证明选对了对象,只能证明"选中了某个东西"。
  • ISketchManager.CreateCircleByRadius(XC, YC, Zc, Radius) 是 4 参(IModelDoc 的同名是 2 参)。

⛔⛔ 含中文路径的脚本不要用 bash heredoc 跑,要用 Write 落成 .py

heredoc 传输会打碎脚本里的中文与反斜杠:实测路径变成 D:\DCDC\u65b0建文件夹\x05blades\...,随后 SaveAs3 返回 err=1、文件根本没落盘, 而前面的建模步骤全部成功 —— 于是现象是「零件建得好好的,最后一步莫名其妙存不下来」。

⇒ 本 skill 里凡是路径带 新建文件夹 这类中文的脚本,一律 Write 成 .py 文件再运行。 纯 ASCII 的只读探测脚本用 heredoc 没问题。 ⚠ 同一个坑还会伪装成别的样子:AddComponent4 返回 None(因为它引用的文件压根不存在)。 ⇒ 另存之后立刻 os.path.exists + 看文件大小,别把"返回 True"当落盘成功(同 §11)。

⛔⛔ 包围盒断言对通孔完全没有分辨力 —— 两次独立事故坐实

check(期望包围盒) 能抓住"躺错轴""方向反了",但打孔不改变外形尺寸, 所以孔有没有真的打出来,包围盒一律给 ✅。本会话因此连续两次得到假结论:

事故 包围盒断言 真相(换判据后)
托盘端板锁孔 ✅ 通过 FeatureCut4 → None,Ø4.2 圆柱面只有 4 个(期望 8)⇒ 4 个锁孔一个没打
端板全部孔 ✅ 三步全过 零件总共只有 8 个面、圆柱面 0 个 ⇒ Ø60 风口 + 4 风扇螺孔 + 2 脚锁孔全部没成形,就是块光板

⇒ 固定动作:凡是打孔/开口,判据必须是「圆柱面计数」而不是包围盒。

rs = {}
for f in prop(body, 'GetFaces'):
    s = prop(D(f), 'GetSurface')
    if prop(s, 'IsCylinder'):
        r = round(list(prop(s, 'CylinderParams'))[6] * 1000, 2)
        rs[r] = rs.get(r, 0) + 1
# 每个通孔 = 1 个对应半径的圆柱面;数目对不上就是没打成

⚠ 这和 §13(ToolsCheckInterference2 的假 0)、§25(GetFaceCount 的假 0)、 §33(SelectByID2 返回 False 却选中了)是同一族问题:判据要能区分"成功"与"什么都没做"。

⛔⛔ FeatureCut4 在本机极不可靠:能不用就不用,改「孔画进轮廓草图一次成形」

统计本会话的全部调用:

场景 结果
托盘柱顶面(实体的面上建草图)打 4 个 Ø4.2 ✅ 唯一成功的一次,返回 '切除-拉伸1'
托盘面板 z=0(按坐标选面 → 选中的是法线 −z 的底面) ⛔ None(两个方向都试)
托盘面板(改用前视基准面草图) ⛔ None(两个方向都试)
端板 Ø60 风口 / 4 风扇螺孔(右视基准面草图) ⛔ None,成品是块光板
端板 L 脚顶面(面草图,与成功那次同款) ⛔ None

⇒ 首选做法:把孔和外框画进同一张草图,一次 FeatureExtrusion3 成形。 实测可行(矩形 + 内部圆 → 拉伸后圆柱面数正确),端板的 Ø60 风口 + 4 螺孔就是这么出来的。

⚠ 前提:圆必须完全落在外框内部。托盘第一版锁孔放在 x=−2.0、半径 2.1 ⇒ 圆占 x −4.1~0.1 越过面板边界 −4 ⇒ 轮廓自相交,FeatureExtrusion3 直接返回 None、 整个零件建不出来(回读 box 为 None)。孔心离边至少要留 半径 + 壁厚。

⛔⛔⛔ 画草图前必须 sm.AddToDB = True,否则自动捕捉会静默改掉你的几何

这是本 skill 里最阴险的一个坑:CreateCircleByRadius 返回了对象、草图里 segment 数也对、 类型也对(4 线 + 4 圆),但圆的坐标和半径已经被 SolidWorks 的草图推理改掉了。

实测对照(同一份代码,只差 AddToDB):

传入 AddToDB = False(默认) AddToDB = True
(1.0, 21.345) r=2.1 (0.000, 21.345) r=3.100 ← 圆心吸到原点纵轴、半径 +1.0 (1.000, 21.345) r=2.100 ✅
(1.0, 81.345) r=2.1 (0.000, 81.345) r=3.100 (1.000, 81.345) r=2.100 ✅
(97.0, 21.345) r=2.1 (97.000, 21.345) r=2.100(离参考线远,侥幸没被吸) ✅

⇒ 被改大的圆要么尺寸错,要么越出外框边界让整个轮廓失效, FeatureExtrusion3 于是返回 None、零件建不出来 —— 而现象看起来像"拉伸 API 有问题"。 我为此先后误判成「破边」「一次成形不可用」「FeatureCut4 不可靠」,绕了三大圈。

sm.InsertSketch(True)
sm.AddToDB = True          # ← 关掉全部自动捕捉/推理,几何按精确坐标写入
...画线画圆...
sm.AddToDB = False         # ← 记得关回去
sm.InsertSketch(True)

⚠ app.SetUserPreferenceToggle(11, False) 不是这个开关,实测设了照样被吸。 ⚠ 固定动作:画完立刻回读每个圆的 GetRadius / GetCenterPoint2 比对, 不要只数 segment 条数 —— 条数正确、几何错误,正是这个坑的伪装方式。

⛔ 一连串"静默失败"的幕后黑手:提交量(commit charge)耗尽,不是物理内存

症状:SaveAs3 返回 err=1 却"文件存在"(其实是旧文件没被覆盖)、 后续脚本卡在第一个 COM 调用上毫无输出。

真因是 SolidWorks 弹了个模态对话框在等人点:

警告!您的系统内存极低。执行此命令可能会导致 SOLIDWORKS 失败。
是否要继续?SOLIDWORKS 极力推荐您不要继续。   [是] [否]

而"内存极低"指的不是物理内存(当时物理还空着 9.3 GB),是提交量:

量 值
提交量 / 上限 36.5 / 38.0 GB(96%)
物理内存 31.5 GB,可用 9.3 GB
页面文件上限 只有 6.6 GB(当前才用 132 MB)⇒ 提交上限 = 31.5 + 6.5
SolidWorks 自身提交量 9.0 GB(600 个文档,其中 584 个是 STEP 导入的临时子件)

诊断三件套(COM 卡住时按顺序做,别急着重启):

  1. Get-Process SLDWORKS → 进程在、Responding=True、CPU 采样 0 ms ⇒ 不是崩溃、不是在算;
  2. 枚举可见窗口找 #32770(Windows 标准对话框类)—— 主窗口 enabled=False 就是被模态框挡住了;
  3. 先读对话框文字再决定点什么(EnumChildWindows 取子控件文本), ⛔ 别盲点确认 —— 有可能替用户选了"覆盖/丢弃修改"。

清理办法:⛔ 不能 CloseAllDocuments(会清空 %TEMP%\swx<PID>\IC~~\、废掉总装引用,§19)。 要点名关废弃的临时零件与装配体;关掉 7 个之后 SolidWorks 提交量 9.0 → 6.5 GB、 总量 96% → 88%,SaveAs3 立刻恢复正常(err=0、文件真的写出来了)。 清完立刻复验总装每个零部件的 GetBox 读不读得出来(读不出 = 引用断了)。

✅ 反证:FeatureCut4 的失败与内存无关

提交量降到 88% 之后再跑,FeatureCut4 第 7 次返回 None(同一处脚锁孔)。 ⇒ 上面那条"内存导致静默失败"不适用于它,前面关于它不可靠的结论维持不变: 能用「孔画进轮廓草图一次成形」就别用切除特征。

⛔ 外壳件的落脚面:先量「托盘露在 PCB 之外的环」,别拿托盘外形尺寸当可用面

端板 L 脚抬到托盘面板上表面(z 0~2.4)之后,反而撞上了底板 PCB:3 处、最大 468 mm³。 原因是托盘面板顶面 z=0 正是 PCB 的底面,而 PCB 本体占 x 0.01~97.99 / z −6.30~13.17 —— 脚只要伸进 x>0.01 就压在板子上。

实测可用的落脚环(回读两个零部件包围盒之差):

托盘 PCB 可落脚环宽
西侧 x −4.00 0.01 4.01 mm
东侧 x 102.00 97.99 4.01 mm

⇒ 连带约束:托盘上给端板用的锁孔必须整个落在这 4.01 的环里。 原来打在 x=1.0 / 97.0 的孔全在 PCB 底下,螺丝根本拧不进去; 而环中线 x=−2.0 配 Ø4.2 的孔(半径 2.1)又会与托盘外边相切/破边。 ⇒ 孔径降到 Ø3.4(M3 过孔)、孔心 x=−1.9 / 99.9:离托盘边 2.3、离 PCB 1.7,两侧都有料。

⚠ 教训:「装在托盘上」不等于「托盘的整个面都能用」——PCB 自己占着中间那块。 画任何搭接件之前,先把相邻件的包围盒读出来相减,不要凭外形尺寸推。

⛔ 旋转后再平移:对齐基准要按语义选,不能一律用 box 最小端

两块端板用同一个零件、东端绕 y 轴 180°。若统一按「目标最小值 − 旋转后最小值」平移, 东端板会落到 x 123.00~153.40 —— L 脚朝 +x 伸出机外。 因为旋转 180° 后,"本体"从 box 的最小端跑到了最大端。

⇒ 平移量要按你真正想对齐的那个面来算: 西端板用 want_x − box[0],东端板用 (want_x + 板厚) − box[3]。 ⚠ 判据仍是回读 box 并与期望值比对,旋转件尤其要比两端,不能只看一端。

⚠ 圆柱面计数这个判据本身也会被写错:不同孔径必须分开数

托盘上有两种孔:柱顶热熔螺母孔 Ø4.2 和端板锁孔 Ø3.4。 我把判据写成「Ø3.4 圆柱面应有 8 个 = 4 柱顶 + 4 端板」—— 柱顶那 4 个根本不是 3.4,于是永远只数到 4, 把已经正确成形的锁孔误判成"没成形",脚本还据此提前 exit 了,零件没落盘。

⇒ 正确写法是打直方图、按半径分桶各自比对:

hist = {}   # {半径mm: 面数}
...
n_end  = hist.get(3.4/2, 0)   # 端板锁孔,期望 4
n_post = hist.get(4.2/2, 0)   # 柱顶孔,  期望 4

修正后实测 {1.7: 4, 2.1: 4, 4.0: 4}(4.0 是支柱外圆),两类孔各 4 个,全部正确。

⚠ 这是本会话第二次栽在"判据本身有毛病"上(前一次是包围盒对通孔没有分辨力)。 换了更强的判据之后,还要检查这个判据的参数有没有写对 —— 否则只是把 "看不见的失败"换成了"看得见的假失败"。

⛔ 180° 翻转要选对轴:绕 y 会连带翻 z,把底部特征甩到顶部

两块端板共用一个零件,东端要翻个面。零件本地:本体 x 0~2.4 / z −10~72, L 脚 x 2.4~27.4 / **z 0~2.4**(脚在底部)。

旋转 结果
绕 y 180°([−1,0,0, 0,1,0, 0,0,−1]) ⛔ x 翻的同时 z 也翻;再按"z 最小端对齐"平移 dz=+62,脚被甩到 z 59.6~62,正好扎进风扇腰部(风扇 z 6.37~69.33)⇒ 实测干涉 483.9 mm³
绕 z 180°([−1,0,0, 0,−1,0, 0,0,1]) ✅ z 不翻,脚留在底部 z 0~2.4,从风扇正下方掠过(2.4 < 6.37);x 翻使脚朝机内伸

⚠ 绕 z 翻转会镜像 y:本零件的风口中心在 y=51.345 而板的几何中心在 y=49(不对称), 翻转后风口跑到 −51.345 ⇒ 必须平移 2×51.345 = 102.69 才能对回风扇中心, 不能再用"box 最小端对齐"。附带好处:锁孔 y 21.345/81.345 互换成 81.345/21.345, 与托盘孔位仍然重合,托盘不用改。

⇒ 通用做法:翻转件先只转不移、回读 box,逐轴确认哪些轴被翻了, 再按"你真正想对齐的那个面/那个特征中心"分轴算平移量 —— 不要三个轴统一套一个公式。

35. ✅ 边缘圆角:FeatureFillet3(14 参)可用,但只能喂给它做得出来的边

签名(按接口名读,同名的还有 7 参 / 11 参两个旧版):

接口.方法 参数
IFeatureManager.FeatureFillet 7
IFeatureManager.FeatureFillet2 11
IFeatureManager.FeatureFillet3 14
feat = fm.FeatureFillet3(195, M(3.0), 0, 0, 0, 0, 0,
                         None, None, None, None, None, None, None)   # 195 = 简单等半径

⛔ 失败的真因:混进一条做不出的边,整批放弃且不报错

最小实验(纯长方体、12 条边)倒 4 条竖边 → 成功,直方图 {3.0: 4}。 但真实零件上一律返回 None:

零件 选中 结果 混进来的坏边
托盘(60 条边) 12 条 ⛔ None USB-C 缺口的内角边 —— 缺口只有 4.2 深,R3 在窄槽内角会自相交
端板(34 条边) 8 条 ⛔ None L 脚与板面的交线,同理超出可行范围

⇒ 三条对策(v2 脚本,实测 5 个零件各 4/4 条全成):

  1. 只倒外轮廓的角:按实体包围盒筛,边的两个端点必须都落在包围盒角上 (at_corner()),自动排除所有缺口/开口/台阶产生的内部边;
  2. 逐条单独倒:一条失败不拖垮其余,还能精确报出是哪条边做不出来;
  3. 失败自动降半径:R3 → R2 → R1.5 → R1 依次重试。

⚠ 判据仍是圆柱面半径直方图——包围盒对圆角没有分辨力(外形尺寸不变), 和"通孔"那次是同一个坑。

⛔ SaveAs3 返回 err=1 的第二种诱因:同名文件正被打开

§34 记过一次 err=1 是提交量耗尽。这次又遇到 err=1,但排查下来完全不同:

查什么 本次实测
提交量 29.7 / 37.0 GB(80%),物理还空 12.3 GB ⇒ 不是内存
盘上文件时间戳 EndPlate_v4 停在倒角那一步写入的时刻,没被写坏(回退版完好)
锁文件 ~$EndPlate_v4.SLDPRT 存在 ⇒ 该文档正开在 SolidWorks 里

⇒ 真因是同名覆盖 + 文档被自己占用(脚本为了 AddComponent4 一直让零件保持打开)。 换新文件名立刻 err=0。 ⇒ 固定动作:改几何就换版本号,别覆盖正开着的文件; 并且永远用 os.path.exists + 时间戳 判落盘,不看返回值。

⚠ 外壳件上的阵列特征,基准要取外壳自己的中心,不是 PCB 的

顶盖第一版的泄压缝写成 x 5~93(8 条)——那是底板的坐标范围。 可顶盖跨 x −27.4~125.4、中心在 49,于是缝整片偏在东半侧, 西端一大片顶盖是实心的:气流短路、外观也不对称。几何断言全过,是俯视图看出来的。

⇒ 改成按盒子中心对称(11 条、跨度 106、中心 49)后才正常。 ⇒ 规律:外壳件的尺寸基准来自「整机包围盒」,而 PCB 上的坐标只适用于板内特征 (安装孔、接口开口这些要对准板上实物的)。两套基准混用是这一步最容易犯的错, 而包围盒/面数这类判据完全抓不到——只能靠截图人眼看。

⛔⛔ 干涉为零 ≠ 装配成立:还要单独验「该贴的面贴上了没有」

外壳五件干涉查出来一直是 360 基线、"零冲突",看着很完美。 但把两两包围盒拿来算贴合面时发现:Tray ↔ SouthPanel 和 Tray ↔ NorthPanel 一处接触都没有 —— 托盘 y −4102,而南板在 −4.055−1.66、北板在 104.345~106.745, 两块板整个落在托盘范围之外,悬空,螺丝无处可打。

⇒ 干涉检查只回答"有没有撞",不回答"该碰的碰上了没有"。 凡是靠面接触定位/锁螺丝的装配,必须另做一次贴合面检查:

# 两件在某轴上 |a.max − b.min| < 0.05,且另两轴有重叠 ⇒ 贴合,重叠矩形就是可打螺丝区

⚠ 还要看重叠区够不够宽:本例 南板↔顶盖 虽然贴合,但重叠只有 y 2.3 mm, 打不下 M3 沉头,得先加凸台把贴合面加宽。 ⚠ 这个坑是"改一处带崩一处"的典型:端板改 y 对称 → 南北板跟着外移 → 托盘没跟着改。 每次挪动件的边界,都要重跑一次贴合面检查,别只看干涉。

⛔ ShowNamedView2 的视图 id 与直觉完全不符 —— 别猜,用视线方向反查

想拍"正对南面"的图确认 Type-C 开孔,按「1 前 2 后 3 左 4 右 5 上 6 下」试了四次, 拍出来分别是俯视 / 北面 / 东端 / 仰视,一次都没对。

⇒ 枚举 0~8 全拍一遍,同时回读 doc.ActiveView.Transform.ArrayData, 用旋转矩阵第三列判断相机视线方向,实测映射:

vid 视线方向 实际是
0 (−1, 0, 0) 从 +x 看
1 (0, 0, −1) 俯视
2 (0, 0, +1) 仰视
3 (+1, 0, 0) 从 −x 看
4 (−1, 0, 0) 从 +x 看
5 (0, −1, 0) 北面
6 (0, +1, 0) 南面 ← 要的就是它
7 (−0.577, −0.577, −0.577) 等轴测
mv  = prop(doc, 'ActiveView')
arr = list(prop(D(mv), 'Transform').ArrayData)
dirv = (arr[2], arr[5], arr[8])      # 相机视线在模型坐标里的指向

⇒ 要哪个面,就按视线方向反查 id,不要按名字猜。 (这条和"基准面轴映射"是同一类:SolidWorks 的方位命名在 API 层不可望文生义。)

⛔ 在面上建的草图,坐标同样要按映射给 —— 不是"接受模型坐标"

托盘柱顶孔那次 CreateCircleByRadius(M(hx), M(hy), M(POST_H), r) 成功了, 我据此以为「面上草图接受模型坐标」。这是巧合:柱顶面是 z 常量面, 它的草图 uv 恰好与模型 xy 一致,第三参给多少都无所谓。

在 y 常量的端面上实测(60×20×40 方块的 y=20 面,画草图圆 (10, 30) r=3):

sketch-local centre = (10.000, 30.000)
boss cylinder axis  = (10.000, 25.000, −30.000)      ← 模型坐标

⇒ y 端面草图:u → 模型 x、v → 模型 −z、法线 → +y(与上视基准面同一套)。 传 (x, y_face, z) 会把 y_face 当成 v —— 托盘凸台因此被甩到 z = −104, 而且那两次异常拉伸把已有的 8 个孔全吞掉了(圆柱面直方图从 {1.7:4, 2.1:4, 4.0:4} 变成 {4.0:1}),整个零件报废。

⇒ 固定写法:面上草图一律 CreateCircleByRadius(M(u), M(v), 0.0, r),第三参恒 0, u/v 按该面的实测映射换算;不要把模型坐标直接塞进去。 ⚠ 幸亏"分孔径计数"判据当场拦下并 exit,v11 没落盘、磁盘上的可用版本没被覆盖 —— 这也是"改几何就换版本号"那条纪律的价值。

⛔ AddComponent4 返回 None 的第三种成因:资源耗尽(文档数 / 提交量)

§15 记过"目标文档必须打开",§34 记过"文件其实没落盘"。本轮遇到第三种: 南板 v3 与顶盖 v3 明明开着(GetPathName 路径都对)、文件也在盘上(101 / 116 KB), AddComponent4 照样返回 None;而同一次调用里、同样开着的北板 v2 插入成功。

当时状态:文档数 604、提交量 87%(604 个里绝大多数是废弃版本零件 + 两轮 STEP 导入的临时子件)。与 §34「提交量耗尽 ⇒ SolidWorks 静默放弃操作」同源。

⇒ 排查顺序(三种成因逐一排除,别一上来就重启):

  1. os.path.exists + 文件大小 → 排除"没落盘";
  2. 遍历 GetDocuments 看 GetPathName → 排除"没打开";
  3. GetDocumentCount + 提交量 → 若文档数上百 / 提交量 >85%,点名关废弃文档再重试。

⚠ 清理只能点名关(CloseDoc(标题)),绝不能 CloseAllDocuments: 后者会清空 %TEMP%\swx*\IC~~\,废掉总装里底板/刀片/风扇的引用(§19)。 判别标准:GetPathName 里含 \IC~~\ 的一律不动。

⛔ 一个纯 Python 自伤写法:raise X if cond else None

想"条件性跳过"时写了 raise SystemExit(0) if False else None —— Python 先求三元表达式得到 None,再对它执行 raise, 于是 TypeError: exceptions must derive from BaseException 把脚本炸在半路, 端板 v9 因此没落盘。要跳过就老实写 if/else,别在 raise 后面接表达式。

建模脚本的固定动作:每一步都 check(期望包围盒)

本轮就是靠「拉完一步立刻回读包围盒并与期望值比对」把躺错轴当场抓出来的, 而不是等到装进总装才发现。⇒ 每个特征后面跟一个包围盒断言,不符就立刻停手, 别在错的几何上继续堆特征。

顺带量出来的设计结论

刀片堆叠迎风截面 y 87.6 × z 67.0 = 5869 mm²,一只 60×60 风扇正面只有 3600 ⇒ 覆盖 61%; 按堆叠中心摆时通道 4(槽4–5,中心 y=81.83)落在风扇正面之外。 风扇与刀片/底板零干涉(fast 口径 360 处全是刀片簧片蹭的基线值)。

36. ✅✅ 面上草图 + FeatureCut4 能用 —— 以前的失败主因是选错了面(2026-09-13)

⛔⛔ PlaneParams 是曲面法线,不是面的朝外法线

端板 v11 第一次跑,按「PlaneParams 法线 +z、位于 z=72」找柱顶面,三个目标面一个都没找到 (FeatureCut4 根本没被调用 —— 别把这种失败记成切除失败)。把零件 14 个平面全列出来逐一核:

面 PlaneParams 法线 FaceInSurfaceSense 真实朝外
顶面 z=72 (0,0,−1) True +z
底面 z=−10 (0,0,+1) True −z
外板面 x=0 (+1,0,0) True −x
柱端面 x=12.4 (+1,0,0) False +x
北侧面 y=104.345 (0,−1,0) True +y

⇒ 本机实测:sense == True ⇒ 朝外 = −PlaneParams;sense == False ⇒ 朝外 = +PlaneParams(14/14 无例外)。

n = list(prop(s, 'PlaneParams'))[axis]
if prop(fc, 'FaceInSurfaceSense'):
    n = -n            # ← 不加这一行,「按法线选面」约一半概率选反

§34 那条「按法线 +z 选脚顶面,结果选中了立面」、以及「FeatureCut4 连续 7 次返回 None」, 高度疑似都是这个:面选反了,草图建在错的面上,圆落在材料外,切除无物可切 ⇒ None。

✅ 修正选面之后的战绩:6 刀 5 成,唯一失败是草图坐标映射写错

面(朝外) 孔 结果
柱顶 z=72(+z) 2 × Ø4.2 深 6 ✅
北侧 y=104.345(+y) 2 × Ø4.2 深 6 ✅
南侧 y=−1.655(−y),按 u→x、v→−z 2 × Ø4.2 ⛔ None(映射错,圆在材料外)
南侧(−y),按 u→x、v→+z 2 × Ø4.2 ✅
脚顶 z=2.4(+z) 2 × Ø3.4 切穿 ✅

每刀之后都回读孔的轴线位置核对(不只数直方图),全部落在设计坐标上,已有孔一个没少。

⛔ y 常量面的草图映射随朝外方向变

面朝外 u → v →
+y +x −z(§35 那次测的就是这种)
−y +x +z

⇒ §35 的映射表只对 +y 面成立。是 v 轴翻,不是 u 轴翻(我先猜 u 翻,候选法当场证伪)。 写法:候选映射逐个试,每刀后回读「目标位置有没有出现孔」;错误映射只会把圆画到材料外、返回 None, 不会在错位置切出孔 —— 但仍要守住「已有孔数不能减少」这条。

⭐ 「孔打出来了」≠「螺丝拧得进去」:装配内同轴核对

直方图只证明有几个孔、回读孔位只证明零件内对。真正的判据是装配坐标下两件的孔轴线对齐: 把组件的 Transform2(列主序 R + T)作用到 CylinderParams 的轴点/轴向,逐个配对。

⚠ 配对要「横向对齐 且 轴向最近」:第一版只比横向偏差,南北板过孔的 (x,z) 完全相同, 北侧 4 孔被全部配到南板 —— 报 12/12 通过,其实没验北板。脚本:.claude/r2/shell/check_coaxial.py。

§36 续:x 常量面的草图映射 + 「先打孔、后封口」得盲孔(2026-09-13,两件式顶盖)

x 常量墙面上草图映射(Ø60 风口 + 4 风扇孔回读位置核过,本地 z 为零件坐标):

面朝外 u → v →
−x +z +y
+x −z +y

候选法照旧:8 种(交换 × 两符号),目标都在 y>0、z<0,错误映射全部落到墙外、只返回 None,零误切。

✅ 不用切除特征做盲孔:想要一根从底面进、顶面看不见的长孔(顶盖角柱的螺母孔),

  1. 先拉角柱,孔画进同一张草图(此时是通孔);
  2. 最后拉顶板(Merge),顶板的实体区域覆盖了孔的上端 ⇒ 孔被封成盲孔。 回读:4 个 R2.1 圆柱面 z 都是 −84.4~−2.4(上端正好停在顶板下表面)。 同理,底盖 PCB 立柱的螺母孔画进立柱草图、平板草图里不画 ⇒ 盲孔深 = 立柱高。 ⇒ 能靠「特征顺序 + Merge 覆盖」解决的盲孔,就别去碰面上草图切除。

⚠ 仰视截图看不出贯通孔:底盖四角过孔后面正对着顶盖角柱底面,着色同灰,孔完全隐形。 孔的存在只能靠几何核对(同轴配对)证明,这类位置别拿截图当判据。

§36 续二:同一基准面「两个方向各拉一层」+ 面积判据 + 侧视截图会转 90°(2026-09-13,滤网框)

  • 不用偏移基准面也能做双层件:前视基准面画一张草图向 +z 拉(贴墙层 02),再画一张向 −z 拉(外层 −10)。 两层的内部开口可以完全不同(窗口 vs 格栅)。偏移面 / StartOffset 在本机不可靠,这招绕开了。
  • 矩形开口多的时候,用面积当判据:「朝外 +z、位于 z=2 的平面总面积 = 外框面积 − 开口面积」。 滤网框贴墙面 2456.0、外表面 3051.0 与设计值精确相等 ⇒ 1 个窗口 + 36 个格孔全部开出来了。比数面稳(开口会把面拆碎)。
  • ShowNamedView2(…, 3) 出来的侧视图可能整张转 90°:tf_west 竖向是 y、横向是 z。 读截图先用已知尺寸的宽高比定方向(110.8/84.4 = 1.31 vs 图上 1.28),再去认特征 —— 否则凸起会被认成错位。
  • 文本派生脚本时,锚点替换要断言出现次数:本次锚点意外出现 2 次,断言当场拦下,否则会漏改一处。

37. ⛔⛔ 选中了面 ≠ 草图建在这个面上:进草图后读 ModelToSketchTransform 验平面(2026-09-13)

事故:短机版顶盖,东端墙那一刀 Select4 返回 True、选中数 1、GetSelectedObject6 回读的面 box 也正是东墙 (x=115.9),可 InsertSketch 之后草图建在了顶板 z=0 上 —— Ø60 风口 + 4 个风扇孔从顶板往下切穿, 位置恰好是「东墙草图坐标被当成顶板坐标」。靠「切出了东西但不在目标位置」那道闸才没存盘。 同一段代码上一版(v2)东墙一次成功;本次唯一差别是前面西墙先有两刀候选映射失败、留下了空草图 ⇒ 疑似残留把状态带偏,未坐实。

✅ 根治:进草图后立刻读草图坐标系,不对就退出重进:

sk  = prop(sm, 'ActiveSketch')
arr = list(prop(D(sk), 'ModelToSketchTransform').ArrayData)
Mx  = [[arr[3*c + r] for c in range(3)] for r in range(3)]   # ⚠ 列主序
t   = arr[9:12]
# 模型点 p(米)→ 草图坐标 s = Mx·p + t;Mx 第 3 行 = 草图法线;s[2]==0 ⇔ 点在草图平面上

实测(同一零件):

面 Mx 行 0(u) 行 1(v) 行 2(法线) t
东墙 x=115.9(朝外 +x) −z +y +x (0, 0, −0.116)
西墙 x=−18.4(朝外 −x) +z +y −x (0, 0, −0.018)
顶板 z=0 +x +y +z 0

⇒ 与 §36 用候选法试出来的映射完全一致,而且是直接读出来的。 ⇒ 以后面上草图一律:选面 → InsertSketch → 读变换 → 检查「法线轴 = 目标轴」且「墙面上一点的草图 z ≈ 0」 → 用 to_sketch() 把模型坐标换成草图坐标再画。候选映射法作废(它会留下失败的空草图,恰恰可能是事故诱因)。 改用这套之后:西/东/南/北四刀全部第 1 次就进对平面、切对位置。脚本 .claude/r2/shell/patch_v3_cut.py。

§37 续:面上草图画圆角矩形 —— CreateArc 可用 + 面积判据精确到 0.01 mm²(2026-09-13,Type-C 开口)

  • ISketchManager.CreateArc(XC, YC, Zc, Xp1, Yp1, Zp1, Xp2, Yp2, Zp2, Direction),10 参,坐标用草图坐标(米)。 Direction 按草图坐标系里 (p1−c)×(p2−c) 的符号取:正 ⇒ 1(逆时针 90° 短弧),负 ⇒ -1。 因为墙面草图的 u/v 可能是镜像的(§36/§37 表),必须在变换后的草图坐标里算叉积,别在模型坐标里算。 4 直线 + 4 圆弧 ×2 = 16 段,一次闭合、一刀切出,4 个 R0.7 圆角 + 4 条直边逐个回读全部在位。
  • 面积判据:墙外表面面积 = 无开口面积 − Σ开口面积(圆角矩形 = W·H − (4−π)·R²), 实测 10785.99 与理论值完全相等;北墙去掉窗口后 10828.52 与无开口理论值完全相等 ⇒「窗口真的没了」也能证明。
  • 量连接器口:嵌套在 STEP 子装配里的零件,GetComponents(False) 找到带实体的那一层(名字不带位号的那个), GetBodies2(0) 拿本地实体 + Transform2 换算。⚠ 遍历全部 1098 个子件三遍会超过 120 s 工具超时,查一次就缓存组件对象。

38. 外观件的三个判据 + 视图 id 不可信(2026-09-13,外壳美化)

  1. 格栅 / 多槽开口:切前切后读同一墙面的面积差,与「Σ槽面积 + 同刀的孔面积」比,实测 2379.94 = 2379.94。 圆角、墙面轮廓被别的特征改过都不影响(差值只含这一刀),比「理论整面面积」稳得多。
  2. 槽方向:读槽端半圆柱的包围盒。竖槽的半圆柱 y 跨槽宽、z 跨半径;横槽反过来。 ⚠ 等轴测截图里竖槽会投影成斜线,看起来像横槽 —— 别拿截图判方向。
  3. 凹刻符号有没有镜像:IFace2.GetClosestPointOn(x,y,z)。在符号里挑一个 「只有方向正确时才在面内」的点(例如闪电顶部偏右),离面距离 = 0 ⇒ 在面内;再测镜像位置应 ≠ 0。 面积判据对镜像没有分辨力(镜像后面积完全一样)。
  4. ShowNamedView2 的 id ↔ 方向每次会话都可能变:§35 那张表这次不成立(本次 0=俯视、3=视线 +x、6=视线 +y、2=仰视)。 ⇒ 一律把 0~8 全切一遍读 ActiveView.Transform.ArrayData 的 (a[2], a[5], a[8]) 再挑。脚本 render_v7.py。
  5. 顶面外轮廓倒圆角:在加导轨/凸台之前做,此时顶面外轮廓边 = 4 直边 + 4 段竖边圆角弧, 8 条一起 Select4(append) 后一次 FeatureFillet3 成功;加了外凸件再做,包围盒判据会漏边。
  6. 半圆弧的 CreateArc 方向:叉积 (p1−c)×(p2−c) = 0 分不出方向 ⇒ 改用「弧中点」m:(p1−c)×(m−c) > 0 ⇒ 1。

39. ⛔ 干涉少了一整片刀片 = 有人在 GUI 里隐藏了零件,不是 COM 丢结果(2026-09-13)

复验报 288(基线 360),少的正好是一片刀片的 72 处,清单里没有外壳/风扇。 我先按 §28/§34 当成「提交量 94% ⇒ 静默丢结果」,点名关掉 25 个旧文档、提交量降到 75% 后重跑 —— 仍是 288,判断错了。 逐片查子件状态:中间那片刀片的板体 Board~MDFG.step-1 Visible = 0(用户在界面里看模型内部时隐藏的), 而复验一直设着 idm.IgnoreHiddenBodies = True ⇒ 这片刀片被排除在外。 IgnoreHiddenBodies = False 重跑 ⇒ 360 = 5 × 72 ✅。

⇒ 复验脚本默认 IgnoreHiddenBodies = False,不要去改用户的显示状态。 ⇒ 干涉数与基线不符时的排查顺序:①按件对统计(o.Components 取名分组)看缺的是谁 → ②查那件及子件的 Visible / GetSuppression2 / IsHidden(True) → ③再考虑提交量。别一上来就归咎于资源。

两个脚本侧小坑

  • 重定向到文件时 TextIOWrapper(..., write_through=True) 仍然整块缓冲,长跑脚本日志要到结束才出现; 想看进度用 python -u(本次 v9 用 -u 后才能中途看到格栅那几行)。
  • bash heredoc 里的 Python 原始字符串以 \' 结尾会被吃成 \'(r'...tmp\' → 语法错误)。 含反斜杠路径的补丁脚本一律用 Write 落成 .py(与 §34「中文路径别用 heredoc」同一族)。

§39 续:按视线挑视图也会拍错(2026-09-13,v10)

render_v10.py 里「视线 (0,0,+1) ⇒ 仰视」挑出的 vid 0,存出来的却是南面图(与 v10_south 像素一致)。 疑似 ShowNamedView2 之后立刻读 ActiveView.Transform 读到的是切换前的视角。 ⇒ 读视线前先 doc.GraphicsRedraw2 / 再调一次 ViewZoomtofit2,并截完图再读一次视线核对; 对不上就丢弃该图。几何关系一律以回读数据为准,截图只作人眼辅助。

40. ⛔ 导出 STL 前必须清空选择:有选中的面/边时只导出选中部分(2026-09-13)

part.Extension.SaveAs3('x.stl', …) 返回 err=0、文件也在,但顶盖只导出了 252 个三角形(包围盒 = 最后一步选中的 Type-C 标识凹槽),底盖只有 36 个(= add_fillets 最后选中的那条倒角边)。SolidWorks 把「当前选中的实体」当成导出范围。 ⇒ 导出前 part.ClearSelection2(True),并打印 GetSelectedObjectCount2(-1) 确认为 0。

STL 导出设置(swconst.tlb 读出,别猜):Toggle 69 二进制 / 70 保存后信息框(关,否则模态框卡 COM)/ 71 不平移到正坐标 / 191 预览(关);Integer 78 精度(3=自定义)/ 211 单位(swLengthUnit_e.swMM = 0); Double 2 偏差(米)/ 3 角度公差(弧度)。改前备份、finally 里恢复。

判据(export_stl_v10.py):numpy-stl 读回 ① 包围盒 = 模型包围盒(顺带验单位)② 每条边恰被 2 个三角形共用(封闭) ③ 体积与 Extension.CreateMassProperty.Volume 差 < 0.5%(实测 0.002%)。这次正是 ①②③ 同时报警才发现「只导了一小块」。