NX二次开发CustomFeature持久化回放与测试避坑

发布时间:2026/10/29 5:20:36
NX二次开发CustomFeature持久化回放与测试避坑

简介:面向具备C++与NX Open基础开发者的CustomFeature实战记录文档,聚焦用户自定义特征从配置到运行的落地与排错。内容围绕CustomFeatureConfiguration.xml展开,逐项说明FeatureClass、FeatureName、FeatureIcon、FeatureLibrary、FeatureUILibrary与IsWithoutBody的含义及取值影响,并记录核心库、UI库两个DLL与XML部署到application目录时的注意事项;同时梳理特征创建、几何修改、更新前回调所依赖的头文件,以及对话框如何区分创建与编辑模式、通过GetEditedCustomFeature判断状态并按属性名回填界面参数的写法。包内仅1个docx文档,压缩包约18KB,篇幅精炼,便于开发时随查随用。已有348人学习,适合希望减少配置失误、快速定位运行异常的中级开发者参考。

1. 从一次“打开部件特征变红”说起:CustomFeature 难在哪

做 NX二次开发的人多半遇到过这种情况:本地跑得好好的自定义特征,换一台机器打开部件,导航器里那个特征直接变成红叉,模型上少了一块体,日志里只有一行看不出因果的报错。参数还在,几何没了。CustomFeature 的难点从来不是把几何造出来,那部分录一段脚本就能模仿出来;难的是让这段逻辑在别人的机器上、在部件被重新打开、被回滚、被编辑、被抑制之后还能原地恢复。它考验的是对引用持久化、参数存储、更新时机和输出体归属的理解。这篇文章记录我在一个槽类自定义特征上的测试过程,包括哪些位置必须打日志、哪几个参数必须留出调节入口,以及五次翻车分别是怎么发生的。

2. CustomFeature 的代码骨架:回调分工、GUID 注册和参数契约

2.1 Journal 脚本为什么撑不起 CustomFeature 的回放

录制出来的 Journal 是一条线性脚本,写死了“先选中这条线,再输入 12,再确定”。它假设运行时的那条线还在、还叫那个名字、还是当前选中的对象。而 CustomFeature 的执行环境完全不满足这些假设。

NX 会在下面这些时刻重新调用你的代码:部件被别人打开、上游特征被编辑后向下更新、特征被抑制再取消抑制、用户按了撤销和重做、装配里换了引用集。每一次调用,你的类都会被重新实例化一次,上一次的内存状态不可能跨进程留下来。这意味着代码里任何“一次性假设”都会在回放时暴露出来:读一个写死的绝对路径、依赖 UI 里选中的对象数组、把上一次构建出来的 Body 引用存在字段里、在构造函数里做几何运算。

我一般把判断标准简化成一句话:假设你的代码明天早上会在一个刚启动的进程、一个刚打开的部件、一个完全空的字段状态下被调用一次,它还能不能算出和昨天一样的几何。不能,那就是结构问题,不是调试问题。

这条标准直接决定了后面所有设计:输入必须是可重新解析的持久标识,输出必须交给 NX 的体管理,参数必须存在特征自己身上,副作用必须放在回放路径之外。

2.2 Compute 与 Update 的分工:把几何构建和状态维护拆开

继承NXOpen.Features.CustomFeature之后,Visual Studio 里对着类名敲一遍重写列表,就能看到当前版本要求实现的抽象成员。不同版本的方法名和签名会有细微差别,以本机 NXOpen 程序集列出来的为准,我一般不去背方法名,而是先看抽象成员再动手。常见的分工是:一个入口负责把几何算出来并把结果体返回给 NX,另一个入口负责在特征被更新时维护自身状态。

下面这段是几何入口的骨架,用拉伸造一个槽体,关键点在注释里:

// 每次都从零构建,不读取上一次留下的任何对象引用 public override Feature[] Compute() { Session session = Session.GetSession(); Part workPart = session.Parts.Work; // 1) 输入用持久标识重新解析,而不是用内存里存的 Body/Edge 引用 Section section = ResolveSection(workPart); // 2) 临时 builder 用完必须销毁,否则会在会话里堆积 ExtrudeBuilder builder = workPart.Features.CreateExtrudeBuilder(null); try { builder.Section = section; // 用不变文化格式化数字,避免小数点在其它区域设置下变成逗号 builder.Limits.StartExtend.Value.RightHandSide = "0"; builder.Limits.EndExtend.Value.RightHandSide = _depth.ToString(CultureInfo.InvariantCulture); builder.BooleanOperation.Type = BooleanOperation.BooleanType.Create; // 3) CommitFeature 返回真正的 Feature,输出体挂在它下面 Feature result = builder.CommitFeature(); return new Feature[] { result }; } finally { builder.Destroy(); } }

ResolveSection是最容易出错的一环。它要根据特征上记录的标识去部件里重新找到那条曲线或那个面,找不到就抛出可读的异常,而不是返回 null 让后面崩在更远的地方。参数_depth不是字段里的默认值,而是从特征自身读出来的值,这一点在 2.3 里展开。

还有一条经验:几何入口里不要主动触发部件更新。它本身就在更新流程内部,再调一次更新就是递归,轻则卡住,重则栈溢出。需要刷新视图的话,交给 NX 在本次计算结束后自己做。

2.3 参数契约:Edit 里写、Compute 里读的必须是同一份值

我见过最常见的 bug 是参数漂移:用户在编辑对话框里把深度从 10 改成 15,点确定,模型没变;再打开编辑框,显示的还是 10。原因通常是编辑时把值写进了 Builder 的字段,而几何入口读的是另一个字段,两边根本不是同一份数据。

解决办法是把参数收敛成一张契约表,所有读写都走同一组方法:

参数类型存储位置默认值有效范围越界处理
槽深 depthdouble特征自身10.00.1 ~ 500抛 NXException 并写明期望范围
槽宽 widthdouble特征自身5.00.05 ~ 200同上
圆角半径 filletdouble特征自身0.00 ~ min(width, depth)/2自动收敛到上限并写日志
截面引用 sectionRef标识字符串特征自身无必须可解析解析失败直接报错,不做兜底
// 编辑入口:把对话框里的值写回特征,NX 保存部件时一起持久化 public override void SetValues(CustomFeature target) { SlotCustomFeature slot = target as SlotCustomFeature; if (slot == null) return; // 只在这里做一次范围校验,Compute 里不再重复判断 slot._depth = Clamp(_depth, 0.1, 500.0); slot._width = Clamp(_width, 0.05, 200.0); slot._fillet = Clamp(_fillet, 0.0, Math.Min(slot._width, slot._depth) / 2.0); } // 读取入口:无论谁来问参数,都从这里拿 public override void GetValues(CustomFeature source) { SlotCustomFeature slot = source as SlotCustomFeature; if (slot == null) return; _depth = slot._depth; _width = slot._width; _fillet = slot._fillet; }

要点在于:几何入口只认特征上的值,编辑入口只负责把值从对话框搬回特征。中间不设第二份缓存,也不做“先存字段、提交时再同步”的两段式。Clamp这种兜底逻辑只能出现在一处,出现在两处早晚会打架。校验失败时抛出带参数名和期望范围的NXException,比返回一个默认值让用户以为操作成功了要好得多,后者是典型的后悔药设计,吃下去才发现问题被藏起来了。

2.4 注册与加载:GUID 一旦发布就别再动

自定义特征要在部件里被认出来,靠的是一个稳定的类标识,通常用 GUID 字符串表达。这个 GUID 一旦交付出去,就不能再改。改了会怎样:旧部件里存的是旧 GUID,NX 打开时找不到对应的类,特征就变成无法识别的节点,几何还在但不可编辑,用户只能删掉重建。

加载环节的检查我习惯按这个顺序做,出问题时逐条排除:

  1. 编译产物的目标框架位数要和 NX 进程一致,64 位进程加载 32 位程序集只会给一句轻描淡写的加载失败。
  2. 程序集放在启动时会被扫描到的自定义目录里,路径本身不要有中文、空格和特殊符号,这一条在家里机器上很常见,在别人机器上很致命。
  3. 依赖的第三方 DLL 要么一起放进去,要么装在全局程序集缓存,缺一个就是“找不到类型”。
  4. 注册完后重启 NX,新建一个部件,能把这个特征从命令里调出来,才算加载成功。

注意:GUID、类名、程序集名这三样构成对外契约,任何一样变动都等价于发布了一个新特征。版本升级时如果确实需要改结构,用新 GUID 加迁移逻辑,不要在原 GUID 上改语义。

3. 测试记录怎么留:把一次特征回放拆成能复现的现场

3.1 五个必打日志的位置和字段设计

测 CustomFeature 最怕的不是报错,而是“偶发”——某台机器上某次更新时少了一块面,重开一次又好了。这种问题靠人眼观察永远抓不住,只能靠日志把现场固定下来。我一般在这五个位置埋点:

第一,进入几何入口的瞬间。记录时间戳、进程号、部件名、特征名、特征的 GUID、以及这次调用是被什么触发的(打开部件、编辑后更新、抑制恢复)。触发来源很多时候靠上下文推断,拿不到就记录当时是否有活动命令。

第二,输入解析完成之后。记录解析到了几个输入对象、每个对象的稳定标识、有没有解析失败的。这一条能排掉一半“打开就红叉”的问题。

第三,几何构建完成之后。记录结果体的数量、面数、边数、包围盒尺寸。包围盒尤其有用,尺寸差一个数量级往往说明单位或者坐标系数搞错了。

第四,输出挂接完成之后。记录返回给 NX 的 Feature 数组长度。返回空数组和返回 null 在行为上不一样,前者通常安静地失败,后者可能直接崩。

第五,异常出口。记录异常类型、消息和堆栈,并且一定要把前面四个阶段中最后一个成功的阶段标出来。

// 极简日志:追加写、带锁、按部件名分文件,避免多会话互相覆盖 internal static class FeatLog { private static readonly object _lock = new object(); private static string _path; public static void Init(string dir, string partName, int pid) { // 文件名里带进程号,同一台机器开两个 NX 也不会串 _path = Path.Combine(dir, $"{partName}_{pid}.log"); } public static void Write(string stage, string feature, string detail) { if (_path == null) return; string line = $"{DateTime.Now:HH:mm:ss.fff}\t{stage}\t{feature}\t{detail}"; lock (_lock) { // 每次打开关闭,宁可慢一点也不要留下半个缓冲区 using (StreamWriter sw = new StreamWriter(_path, true, Encoding.UTF8)) { sw.WriteLine(line); } } } }

字段顺序固定成“时间、阶段、特征、详情”,是为了后面能直接拿文本工具筛。日志写得花哨但不统一,等于没写。

3.2 用例矩阵:参数、输入、操作序列三轴怎么组合

手工点选测试的覆盖率取决于测试的人当天心情,这是不可接受的。我用一张固定的用例矩阵,每次改代码跑一遍,跑完贴进记录里。

表的三个维度是:输入几何形态、参数取值、操作序列。输入形态至少要覆盖单条直线、闭合轮廓、带缺口轮廓、退化轮廓(长度接近零的线)。参数取值覆盖下界、常用值、上界、越界。操作序列覆盖新建、编辑参数、抑制恢复、回滚到特征之前再回滚回来、另存为新部件再打开。

编号输入depthwidth操作序列期望主要关注点
C01单直线105新建成功,1 个体基线
C02单直线0.055新建报错,提示范围边界校验位置
C03闭合轮廓105新建后编辑 depth=20体尺寸同步变化参数契约是否一致
C04闭合轮廓105抑制后恢复几何与抑制前一致参数持久化
C05带缺口轮廓100新建后编辑 fillet=3无圆角,不报错自动收敛逻辑
C06退化轮廓105新建可读异常,不崩溃异常出口
C07闭合轮廓105另存后重开特征正常,可编辑GUID 与加载
C08闭合轮廓105回滚到特征前再回来几何恢复回放幂等性

C04 和 C08 是最容易被忽略、也最能暴露结构性问题的两条。很多实现在 C01 到 C03 全绿,一到 C04 就丢参数,一到 C08 就多重影分身。

3.3 批量回归:用 run_journal 一次跑完一批部件

单个用例手工点没问题,八条跑三遍就没人愿意干了。我的做法是给每个用例准备一个部件副本,再配一段固定的回归脚本,用 NX 自带的脚本执行器批量跑。

#!/usr/bin/env bash # 批量回归:每个用例部件跑一遍固定脚本,日志各自落盘 set -u NXBIN="${NXBIN:-/opt/nx/bin}" CASE_DIR="./cases" LOG_DIR="./logs" mkdir -p "$LOG_DIR" for part in "$CASE_DIR"/*.prt; do name="$(basename "$part" .prt)" # -args 之后是这个脚本自己的参数,这里把期望值传进去 "$NXBIN/run_journal" "./regress.cs" "$part" \ >> "$LOG_DIR/$name.log" 2>&1 # 记下退出码,非零直接标红,别等到翻日志才发现 echo "$name exit=$?" >> "$LOG_DIR/summary.txt" done

run_journal的路径随安装方式不同,脚本里用环境变量兜底比写死绝对路径稳。回归脚本本身要做的事很简单:打开部件、触发一次更新、把特征数量和体的包围盒尺寸打到日志里、保存退出。不要在这个脚本里断言结果,断言交给外部的文本比对,因为脚本里一旦抛异常,后面的检查就全断了。

跑完一批之后,把summary.txt和各个日志里的包围盒数字横向比一遍,差异超过千分之一就人工看一眼。这套流程搭起来大概半天,之后每改一次代码跑一遍,比自己骗自己“这次改动很小应该没事”靠谱得多。

4. 四个必调参数:公差、更新时机、输出体归属和撤销标记

4.1 建模公差:别在代码里写死 0.001

几何构建里判断两个点是否重合、两条边是否连续,都要用到公差。写死一个1e-3在毫米级零件上通常没事,一旦零件尺度变成米级或者微米级,这个值要么太松导致小特征被吞掉,要么太紧导致本该缝合的面缝不上。

我的做法是从部件自身的建模首选项里读当前公差,读不到再退化到一个保守默认值,并且把这个值打进日志。不同版本读公差的入口略有差异,我一般先在部件首选项对象下找公差项,找不到再试部件本身的公差属性,两个都拿不到才用默认值。这段代码写脏一点没关系,关键是不能静默。

// 优先用部件当前建模公差,取不到才退化,并且一定记日志 private static double ResolveTolerance(Part workPart) { double tol = 0.0; try { tol = workPart.Preferences.Modeling.Tolerance; } catch { tol = 0.0; } if (tol <= 0.0) { tol = 1e-3; // 保守默认值,仅作为兜底 FeatLog.Write("TOL", "-", "fallback to 1e-3"); } return tol; }

还有一个容易忽略的点:公差影响的不只是几何,还影响你判断“这条线够不够长、这个轮廓够不够闭合”。用错公差最常见的症状是小尺寸特征被判定为退化然后悄悄跳过,模型看起来成功了,实际上少了一块。

4.2 更新时机:Compute 里调 Update 会把自己绕进去

自定义特征的计算本身就在更新流程里。如果几何入口里再调一次部件更新,等于在更新的中途要求重新更新,好一点的情况是性能骤降,坏一点的情况是递归到栈溢出,最坏的情况是状态半新半旧,日志里看不出任何异常但几何是错的。

需要刷新界面或者让下游特征感知变化时,交给 NX 在本次计算结束后统一处理。确实有必须立刻见效的场景,我的处理是把动作排队,在计算返回之后由外层触发,而不是在计算内部直接调。

提示:调试期如果发现某个特征更新特别慢,先在几何入口和出口各打一条时间戳,算一下单次计算耗时。如果单次只有几十毫秒但整体更新要几秒,问题多半不在你的代码,而在反复触发更新。

4.3 输出体归属:返回还是删除,中间态最危险

几何入口里创建的体,最终只有两种合法归宿:作为结果返回给 NX,或者被显式删除。第三种状态——创建了、没返回、也没删——就是游离体,它不属于任何特征,删不掉也管不住,保存后还在部件里占地方。

我在这上面栽过:早期版本为了“先试试看”,在计算中途造了临时体做布尔运算,算完把结果返回,临时体忘了删。单次测试完全正常,跑二十次回归之后部件体积翻了三倍,打开慢到没法用。

处理原则很简单:在一个明确的代码块里创建的所有中间体,在该块的finally里统一清理,只把最终结果交出去。清理时注意别把已经作为输出返回的那个体也删了,判断依据用对象标识做比对,不要用名字。

List<Body> temporaries = new List<Body>(); try { temporaries.Add(BuildToolBody(workPart)); // 中间体登记 Body result = BooleanSubtract(workPart, target, temporaries[0]); temporaries.Remove(temporaries[0]); // 已并入结果,不能删 return new Feature[] { result.OwningFeature }; } finally { foreach (Body b in temporaries) { if (b != null) b.Delete(); } }

4.4 撤销标记:让一次失败的 Compute 能干净退回

自定义特征计算失败时,最糟糕的结果不是报错,是留下半个模型:体建了一半、布尔做了一半、参数改了一半。用户只能撤消,但撤消到什么程度取决于你之前有没有打过标记。

我的习惯是在进入几何入口之前打一个不可见标记,正常结束时不管,异常时回退到这个标记。这样一次失败的计算对模型来说等于没发生。

Session.UndoMarkId mark = session.SetUndoMark( Session.MarkVisibility.Invisible, "SlotFeature.Compute"); try { // ... 几何构建 ... } catch (NXException ex) { // 回退到入口状态,避免留下半成品 session.UndoToMark(mark, "SlotFeature.Compute"); FeatLog.Write("FAIL", featureName, ex.Message); throw; }

标记名字带上方法名,出问题时在日志里一眼能看出是哪一段回退的。回退之后不要吞掉异常,该往上抛就往上抛,NX 会在界面上给出提示,用户至少知道刚才那一下没成功。

5. 避坑与排查:CustomFeature 测试里最常翻车的五个现场

5.1 现象:换台机器打开部件,特征变红叉且几何消失

现象很直接:同一个 prt 文件,开发机上打开正常,交付到另一台机器上打开,导航器里特征节点显示异常,模型上对应的体不见了,右键也没有编辑入口。

原因基本集中在加载这一环。按概率排序:程序集没有被扫描到,通常是因为放在了非启动目录;位数不匹配,64 位进程加载不了 32 位程序集;依赖的第三方库缺失,主程序集能加载但类型初始化失败;路径里带中文或空格导致解析失败;GUID 在两次编译之间被重新生成,旧部件里的标识对不上。

排查顺序我固定成这样:先看 NX 启动目录里到底加载了哪些程序集,确认你的 dll 在列表里;再确认位数;再确认 GUID 和交付版本一致。GUID 这一条尤其容易犯,用模板新建项目时随手生成的 GUID,如果每次重编译都重新生成,那每次发布都是一个新特征。解决办法是把 GUID 写死在源码里作为常量,编译器不会替你保管这个值。

注意:交付前在干净机器上做一次“拷过去、打开、编辑、保存”的全流程验证,比在开发机上跑一百遍都有用。

5.2 现象:改完参数提交,模型纹丝不动

编辑对话框里把深度从 10 改成 20,确定,模型没反应;再打开编辑框,显示的还是 10。

原因是参数写到了错误的地方。常见有三种:写进了 Builder 的私有字段但没同步回特征;同步回特征了但几何入口读的是另一个字段;几何入口读对了,但特征没有被标脏,NX 认为不需要重算。前两种属于契约问题,第三种属于更新机制问题。

排查方式是在几何入口第一行把读到的参数值打进日志。如果日志里是 20 而模型没变,问题在更新机制;如果日志里还是 10,问题在参数同步。定位之后按 2.3 的契约表收敛,所有读写只留一个入口。

顺带说一句,这个问题在中文环境下还有一个变体:参数是数值但被格式化成带千分位的字符串再解析回去,结果解析失败静默用了默认值。数值和字符串之间的转换一律指定不变文化,别依赖当前线程的区域设置。

5.3 现象:连续更新两次,模型上多出一块重影

第一次更新正常,触发第二次更新后模型上多出一块位置偏移的体,或者同一个位置叠了两层。

原因是几何入口不具备幂等性。每次调用都新建一个体,但没有把上一次的输出清理掉,NX 又把新旧两个都当成了输出。还有一种情况是中间体没删,布尔运算把它并进了结果,看起来像重影。

判断方法很简单:连续触发三次更新,看体的数量是不是线性增长。是,就是幂等性问题。修法不是在下游去重,而是在入口处保证“给定同样的输入和参数,必然得到同样数量的输出体”,中间体在finally里清干净。

5.4 现象:撤销一次之后,特征树里出现幽灵体

用户按了一次撤销,界面上特征没了,但模型里还留着一块体,删不掉也选不中,保存后还在。

原因是几何入口里做了非事务性的写操作:直接改了部件属性、往文件里写了东西、或者创建了不受撤销栈管理的对象。撤销只能回退事务内的变更,事务外的副作用会留下来。日志文件写到一半也是同样的道理,那个不属于模型,但会让人误判。

修法是把这个入口当成纯函数看待:只读输入、只产出几何、不写任何外部状态。日志和统计信息在入口之外记录,或者至少保证写日志本身失败不影响几何计算。

5.5 现象:大批量跑回归时日志互相覆盖

批量跑的时候,二十个用例的日志混在一个文件里,时间戳交错,根本没法按用例读。

原因是日志路径写死成一个固定文件,多个进程或者多个部件共用了同一个句柄。修法在 3.1 的代码里已经体现:文件名带部件名和进程号,写入时加锁,每次追加都重新打开文件。

还有一个隐蔽变体:日志路径依赖当前工作目录,而批量脚本执行时的工作目录和手工执行时不一样,结果日志写到了一个谁也没想到的地方。路径一律用绝对路径拼出来,不要用相对路径。

6. 最小复现部件:把“偶发失败”钉成“必然失败”

测试做到后面,最耗时间的不是写用例,是把一个“偶尔出错”的现象固定成“每次必错”。我的办法是维护一组最小复现部件,每个部件只保留触发问题所必需的那几个对象,多余的特征全部删掉。

建这组部件的过程有固定套路。先在出问题的部件上做二分:删掉一半上游特征,看还复现不复现;复现就继续删另一半,不复现就把删掉的加回来换个方向删。通常三到四轮就能把几十个特征砍到三五个。然后把参数推到边界上——深度取到最小值、轮廓改成刚好不闭合、引用换成一个被抑制过的面——问题从概率事件变成必然事件。

我常用的三个对照部件是这样的:

部件内容用途判定标准
base.prt单条直线 + 一个自定义特征,默认参数基线,任何改动后先跑它体数 1,包围盒与记录值误差小于千分之一
edge.prt退化轮廓 + 边界参数验证异常出口和公差处理抛出可读异常,日志有 FAIL 记录
loop.prt闭合轮廓 + 上游被抑制的特征验证回放和引用重解析抑制恢复后几何与抑制前一致

三个部件跑完,再把base.prt另存一份、关掉 NX、重新打开、编辑一次参数、再保存。这一套下来能覆盖住绝大多数回放相关的问题。剩下的那些只在特定装配上下文里出现的,才需要单独再建。

验证几何是否一致,别用眼睛看。我一般在回归脚本里取三个数:体的数量、面的总数、包围盒对角线长度,输出成一行固定格式的文本,和上一次的基线做字符串比对。数字对不上就人工看,数字对得上基本可以放心。这一招比截图对比省事得多,也不受显示模式和视角影响。

最后说个我自己的习惯。每次改完 CustomFeature 的代码,我不急着打开那个复杂的总装部件,而是先在base.prt上跑一遍新建、编辑、抑制恢复、另存重开这四步,四步都过再往上叠复杂度。这个习惯是踩坑踩出来的:早些年图省事直接在总装上验证,一个报错要花半天才能定位到是自己代码的问题还是上游引用变了,那种感觉就像在黑匣子里找一根针。基础部件跑通只要两分钟,能挡掉八成低级错误,剩下两成才值得动复杂模型。希望帮到你。

本文还有配套的精品资源,点击获取