简介:这份文档面向具备C++基础、正在使用NX Open做二次开发的工程师,围绕CustomFeature自定义特征的测试记录与问题处理展开,重点梳理核心库、界面库的配置关系,以及特征创建、参数修改、更新前回调等实现环节。正文结合配置文件和NXOpen头文件,说明特征类注册、数据管理、几何生成与编辑事件的基本用法,并对无实体特征、双击编辑异常、属性读取不一致等常见问题给出排查思路,便于读者定位配置错误与接口调用偏差。包内共1个docx文件,约18KB,适合集中查阅和归档。当前已有348人学习,可作为CustomFeature开发测试与排错阶段的参考材料。
1. 从「按钮建模」到「活特征」:CustomFeature 到底补的是哪块短板
NX 二次开发做到一定阶段,总会撞上一类需求:用户要的不是一个按钮,而是一个能反复编辑、能跟着模型一起存盘、重开还能改参数的「活特征」。CustomFeature(自定义特征)就是 NX 给这类需求留的入口。它不像块 UI 那样点完就结束,而是把你的几何生成逻辑注册成一个特征类,挂进模型树,随 Part 一起序列化。适合谁?适合已经能写 NX Open 脚本、手里有参数化建模逻辑、但被「改一个参数就得删掉重建」折磨过的开发者;也适合做标准件库、工艺特征模板、企业内部专用特征的那批人。这篇不讲概念推导,只讲从第一次注册成功到能稳定交付之间,测试记录里该写什么、参数怎么设、以及最容易翻车的几个位置。
2. 把 CustomFeature 挂进 NX:特征类、构建器和注册目录的三件套
CustomFeature 的运行链路比普通脚本长得多:用户点一次新建 → NX 找注册表里的特征类 → 调工厂方法 → 拿到构建器 → 用户改参数 → 提交 → 特征序列化进 Part → 下次打开再逆序列化。这条链上任何一环的类型分错,表现出来的症状都很统一:要么菜单里根本看不到,要么能建但改不动,要么存盘重开变空壳。
2.1 CustomFeatureClass、CustomFeatureBuilder、CustomFeatureEvent 各管哪一段
先看一张职责表,这张表我一般贴在项目 README 第一页,新人入职先背它。
| 类型 | NX 调用时机 | 必须承担的责任 | 常见的错位用法 |
|---|---|---|---|
| CustomFeatureClass | 用户新建特征、NX 重放特征时 | 校验输入、构造构建器、返回最终落进模型树的几何对象 | 直接在工厂方法里建几何,返回的对象没有登记成特征子项 |
| CustomFeatureBuilder | 创建中、编辑中反复调用 | 持有参数、在 Commit 里落几何、在 ShowResults 里只做预览 | 在 ShowResults 里增删实体,撤销后状态回不去 |
| CustomFeatureEvent | 特征建成后、被修改后、被删除前 | 同步外部数据、联动其他特征、刷新自定义面板 | 在回调里再次 Commit,触发递归堆栈 |
工厂类只做一件事:把外部输入翻译成构建器,然后交出去。我见过最常见的错误,是在Create里直接Commit()一个圆柱,然后 return 那个 Feature。第一次建看起来没问题,但特征树里出现的是圆柱本身,你的自定义特征没有持有任何子对象,保存重开就变成一个没有任何参数信息的空壳——这正是后面第 4 章要讲的「空壳现象」的根因之一。
构建器是整套机制里唯一允许改模型数据库的地方。Commit的调用时机有两个:用户点确定时,以及编辑后再次点确定时。这两次调用必须走同一条路径,否则就出现「新建时是对的,编辑一次尺寸翻倍」这类玄学问题。ShowResults是预览,用户拖滑块时会被高频调用,它只应该更新显示,不应该动实体,更不应该在里面 Commit。
事件类是可选项,我一般只在需要和外部系统同步时才实现。它的价值在于监听特征的生老病死,但它同时也是递归提交的高发区:在OnFeatureModified里再调一次 Commit,NX 会认为特征又被改了,回调再次进来,堆栈一路涨到溢出。
选型上也说一句。普通 UDF 的好处是零代码,但参数受限于表达式,做不了「根据输入自动选择建模策略」这种逻辑;块 UI 的优点是完全自由的交互,缺点是没有特征生命周期,点完即结束;CustomFeature 成本最高,但换来的是模型树里的一个可编辑节点。判断标准很简单:用户会不会要求「明天把这个筋板的厚度从 8 改成 12」,会,就上 CustomFeature;不会,就别为了技术指标上。
2.2 最小工程骨架:程序集、注册目录和一次干净的加载
先解决「加载不上」这个最前面、也最容易卡一天的问题。NX 启动时会去固定的几个目录扫描自定义特征程序集,把继承自CustomFeatureClass的类型收进注册表。所以第一步不是写代码,是把目录和位数定下来。
# 目录结构示例(路径按本机实际配置替换,不要照抄) nx_custom/ ├── features/ # 自定义特征程序集目录,NX 从这里扫描 │ ├── DemoTube.dll │ └── DemoTube.pdb # 保留调试符号,崩溃时能定位行号 ├── journals/ # 测试用 journal 脚本 └── logs/ # 自己写的运行日志,不要用 NX 自带日志文件三个硬性约束。第一,位数必须和 NX 一致,64 位 NX 只能加载 x64 程序集,AnyCPU 在引用原生库时也会翻车。第二,.NET 目标框架要和 NX 版本匹配,版本越新对框架要求越高,编译前先确认本机 NX 帮助里写的那一版。第三,程序集依赖的第三方库要么一起拷进features,要么走 GAC,前者更省心。
部署脚本我会写成一个幂等的小工具,避免每次手工复制漏文件:
# deploy.ps1:把编译产物同步到自定义特征目录,并做一次基本校验 param( [string]$Source = ".\bin\x64\Release", [string]$Target = "D:\nx_custom\features" ) if (-not (Test-Path $Target)) { New-Item -ItemType Directory -Path $Target | Out-Null } # 只拷 dll 和 pdb,避免把 obj 之类的中间产物带过去 Get-ChildItem -Path $Source -Include *.dll,*.pdb -Recurse | Copy-Item -Destination $Target -Force # 校验:目标目录里必须有主程序集,否则后面加载失败会很难查 if (-not (Test-Path (Join-Path $Target "DemoTube.dll"))) { Write-Error "主程序集未同步成功,先检查编译配置是不是 x64" exit 1 } Write-Host "同步完成,重启 NX 生效"参数说明:-Source指向编译输出目录,-Target指向 NX 扫描的自定义特征目录。为什么建议用独立目录而不是直接塞进安装目录?因为升级或修复安装会覆盖安装目录,你的程序集连同调试符号一起没了,第二天上班发现功能消失,血泪经验。独立目录通过环境变量或者 NX 的配置文件指过去,升级不受影响。
注意:目录名和配置方式在不同 NX 版本之间可能有差异,第一次搭环境时以本机帮助里自定义特征部署那一节为准,别拿别的版本照抄。
2.3 第一个能创建的最小特征:一个圆柱加一个高度参数
骨架跑通之后,写一个最小可用的特征:输入高度和直径,生成一个圆柱,并且保证这个圆柱被登记成自定义特征的子对象。代码保留的是调用顺序和状态机,具体的构造函数签名在不同版本之间有差异,复制前先对照本机帮助里的那一个。
using System; using NXOpen; using NXOpen.Features; using NXOpen.CustomFeature; namespace DemoTube { // 工厂类:NX 需要新建这个特征时调用 Create public class TubeFeatureClass : CustomFeatureClass { public override NXObject Create(CustomFeatureBuilder builder) { TubeBuilder tb = builder as TubeBuilder; if (tb == null) return null; // 只做参数校验和几何入口的返回,重活留给 Commit if (tb.Diameter <= 0 || tb.Height <= 0) throw new Exception("直径和高度必须大于 0"); return tb.BuildGeometry(); } } // 构建器:参数容器 + 唯一的事务边界 public class TubeBuilder : CustomFeatureBuilder { private Part _owner; private double _height = 20.0; private double _diameter = 10.0; public TubeBuilder(Part owner) { _owner = owner; } public double Height { get { return _height; } set { _height = value; } } public double Diameter { get { return _diameter; } set { _diameter = value; } } // 预览:只刷新显示,绝不改数据库 public override void ShowResults() { // 复杂度不高时留空即可,NX 会用上一次 Commit 的结果做显示 } // 提交:新建和编辑共用这一条路径 public override void Commit() { BuildGeometry(); base.Commit(); // 少了这句,重开就是空壳,这是最常见的一处漏写 } internal NXObject BuildGeometry() { CylinderBuilder cb = _owner.Features.CreateCylinderBuilder(null); try { cb.Type = CylinderBuilder.Types.DiameterAndHeight; cb.Diameter.RightHandSide = _diameter.ToString("F3"); cb.Height.RightHandSide = _height.ToString("F3"); cb.BooleanOption.Type = BooleanOperation.BooleanType.Create; NXObject result = cb.Commit(); return result; } finally { cb.Destroy(); // 不销毁会留下孤立构建器,连续操作时内存一路涨 } } } }逻辑说明:Create只负责把构建器交出去并返回几何入口,真正的建模动作在BuildGeometry里完成,Commit和Create共用同一个建模方法,保证新建和编辑走同一条代码路径。最后那句base.Commit()是把几何登记为特征子对象的动作,漏掉它,特征在模型树里看起来正常,存盘重开后就只剩一个没有任何形状的空节点。
参数上,直径和高度都以毫米为单位,精度保留三位小数即可,多余位数会让表达式比对失效。布尔选项默认用「新建」,如果业务要求自动合并到已有实体,改成求和之前先确认目标体在同一个 Part 里,跨 Part 的布尔在装配下会直接失败。
3. 测试记录怎么写才有用:从「能生成」推到「能改、能存、能回放」
新人写测试记录,往往只有一行「功能正常」。这种记录在出问题时一点用没有,因为它没有告诉你「在哪个状态下、观察了哪个量、失败的第一现场在哪里」。CustomFeature 的测试必须按状态机来切,每个状态都要有独立的观察点。
3.1 一张覆盖十个状态的用例矩阵
我固定用这张表,每个项目开工时先复制一份,测试结果直接填在最后一列。
| 编号 | 操作 | 期望结果 | 失败时第一现场 |
|---|---|---|---|
| C-01 | 空 Part 新建特征 | 模型树出现特征节点,几何正确 | 工厂方法是否被调用,参数初值是否合理 |
| C-02 | 编辑高度参数 | 几何随之变化,无残留旧体 | Commit 是否走了同一条建模路径 |
| C-03 | 连续两次编辑 | 第二次结果正确,尺寸不叠加 | 上一次的构建器是否 Destroy |
| C-04 | 单步撤销 | 回到上一状态,特征仍可编辑 | ShowResults 里有没有改数据库 |
| C-05 | 撤销后重做 | 几何与撤销前一致 | 事件回调是否被重复触发 |
| C-06 | 保存并重新打开 | 特征节点在,参数可读,几何在 | base.Commit 是否被调用 |
| C-07 | 在装配中调用 | 特征落在正确的工作部件 | 用的是 WorkPart 还是 DisplayPart |
| C-08 | 删除特征 | 几何随特征一起消失 | 子对象是否真正登记 |
| C-09 | 同一 Part 建两个实例 | 参数互不影响,命名不冲突 | 参数是否挂在全局静态变量上 |
| C-10 | 输入 0 或负值 | 弹出友好提示,不生成脏几何 | 校验是否放在工厂方法里 |
这张表里,C-04 和 C-05 是最容易被跳过、也最容易出事的两个。撤销链路会把你ShowResults里做过的所有小动作回滚,如果预览时建了临时体而没登记,撤销之后模型树里就会多出一个孤儿节点,用户看着莫名其妙,你查起来更莫名其妙。C-09 则专治「参数写在静态字段」这种毛病:两个实例共用一个静态变量,改一个另一个跟着变,属于典型的看起来能用、用起来崩溃。
3.2 ShowResults 与 Commit 的职责边界,决定编辑会不会崩
这一对方法的边界,用一句话概括:ShowResults只允许改显示,Commit才允许改模型。下面这段是错误示范,很多人第一版都这么写。
// 错误示范:在预览里建实体 public override void ShowResults() { BuildGeometry(); // 每拖动一次参数就多一个体 base.ShowResults(); }拖动滑块时这个方法会被调用几十次,每调一次多一个圆柱,用户松开鼠标发现模型里堆了一排同心圆柱,撤销一次还只能删掉最后一个。正确做法是预览阶段什么都不做,或者只更新一个不参与布尔运算的显示体,并且这个显示体要在下一次调用时先删掉上一次的。
还有一个更隐蔽的坑:编辑状态下 NX 会先调ShowResults再调Commit,如果ShowResults里抛了异常,Commit根本不会执行,用户看到的是「点了确定没反应」。所以这个方法里的代码要么极简,要么整体包在 try/catch 里,把异常写进自己的日志。
注意:任何时候都别在
ShowResults里调base.Commit(),那是把预览和提交揉在一起,撤销栈会直接错乱。
3.3 用独立日志文件打开 NX 的黑匣子
NX 自带的日志文件长期被进程占用,边跑边看经常读不到最新内容,而且里面混着大量系统消息。我的习惯是在自定义特征里挂一个极简的日志类,所有关键节点都写自己的文件,出问题时直接按时间戳对。
public static class FeatLog { private static readonly string Path = System.IO.Path.Combine(@"D:\nx_custom\logs", "tube.log"); public static void Write(string stage, string msg) { // 每次追加一行,带毫秒时间戳,方便和 NX 操作对齐 string line = string.Format("{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}", DateTime.Now, stage, msg); System.IO.File.AppendAllText(Path, line + Environment.NewLine); } }调用点固定四个:工厂方法入口、Commit入口、ShowResults入口、异常捕获块。日志内容不要只写「进入方法」,要写参数值,比如Commit dia=10.000 h=20.000,这样两次编辑的差异一眼能看出来。
日志攒多了以后,用一个小脚本做分类统计,比人眼翻快得多:
# log_stat.py:按阶段统计异常出现次数,快速定位高发环节 import re from collections import Counter pat = re.compile(r"\[(\w+)\].*?(异常|失败|error)", re.IGNORECASE) counter = Counter() with open(r"D:\nx_custom\logs\tube.log", encoding="utf-8") as f: for line in f: m = pat.search(line) if m: counter[m.group(1)] += 1 for stage, n in counter.most_common(): print(f"{stage}: {n} 次")这个脚本只做一件事:把带异常关键字的行按阶段归类。跑完一眼就能看出是Commit阶段炸得多,还是ShowResults阶段炸得多,排查方向立刻收窄。
3.4 表达式绑定与参数回读的验证
参数如果只存在构建器里,重开 Part 之后就丢了。常见做法是把关键参数写成表达式,特征只负责读写表达式,几何由表达式驱动。这样用户还能在表达式编辑器里直接改,改完特征自动更新。
// 写入:特征创建时把参数固化成表达式 Unit mm = _owner.UnitCollection.FindObject("MilliMeter"); Expression hExp = _owner.Expressions.CreateExpression(mm, "tube_h", _height.ToString("F3")); Expression dExp = _owner.Expressions.CreateExpression(mm, "tube_d", _diameter.ToString("F3")); // 回读:编辑时先按名字找,找不到说明是旧版本特征,走默认值兜底 Expression found = _owner.Expressions.GetStringExpressions() .FirstOrDefault(e => e.Name == "tube_h"); double h = found != null ? double.Parse(found.RightHandSide) : 20.0;验证方法很直接:建一个特征,把表达式改名,再打开 Part,看特征是报错还是走默认值。走默认值说明兜底逻辑生效,报错说明你的回读没有做空判断。这一步在版本升级时尤其重要,旧特征在新程序集里反序列化,参数名对不上是常态。
4. 五类高频翻车:加载失败、编辑退出、重开丢参数怎么排查
下面这五条,是我在测试记录里出现频率最高的,按「现象 → 原因 → 解决」写,能对号入座就别从头查。
4.1 现象:NX 启动后,自定义特征列表是空的
原因通常有三个层次。最外层是目录不对,程序集根本没被扫描到;中间层是位数或框架不匹配,扫描到了但加载失败;最内层是类型没有正确继承,编译器没报错,但 NX 找不到可注册的类。
解决顺序是从外往里。先确认程序集确实在扫描目录里,并且重启过 NX,热加载不生效。再用依赖查看工具检查程序集的位数和目标框架,64 位 NX 加载 32 位程序集不会报错,只会静默跳过。最后看类型定义,工厂类必须继承自定义特征基类并且可被公开构造,类上加internal或者构造函数私有化都会导致注册失败。
注意:有些加载失败只在 NX 启动日志里有记录,界面完全无提示。查这个问题时先把启动日志翻出来,别在代码里瞎改。
4.2 现象:第一次能建,第二次编辑时 NX 直接退出
这种「首次正常、二次崩溃」的模式,八成是构建器没收尾。每次Commit之后如果不Destroy,上一次的构建器还挂在会话里,第二次编辑时 NX 拿到两个指向同一特征的构建器,内部状态冲突,直接崩。
解决方法是把所有构建器的创建都放进 try/finally,finally里无条件Destroy。同时检查事件回调里有没有再次触发 Commit 的路径,递归提交也会表现为编辑时退出。定位手段是在Commit入口和出口各打一条日志,看崩溃前最后一条是入口还是出口,能立刻区分是建模崩了还是收尾崩了。
4.3 现象:保存重开后,特征节点在但几何没了
前面提过的空壳问题。根本原因是几何对象没有被登记为特征的子对象,Part 在序列化时只存了特征节点的壳,没存几何。
解决方法是确认Commit里调了基类提交,并且Create返回的对象就是Commit产生的那个对象,两者不能是两次独立建模的结果。有个快速自检办法:新建特征后立刻删除它,如果几何跟着消失,说明登记成功;如果几何还留在模型里,说明它是个游离体,重开必丢。
4.4 现象:撤销一次之后重做,几何错位或者参数翻倍
这类问题几乎都出在状态管理上。要么ShowResults里偷偷改了数据库,要么参数存在静态字段里跨实例共享,要么事件回调在撤销时被触发又提交了一次。
解决方法是先把参数从静态字段挪进构建器实例,再把ShowResults清空,然后检查事件回调里有没有Commit调用。三步做完还有问题,就在撤销前后各打一条日志,把特征 ID 和参数值都打出来,对比两次的值,差异点就是问题点。
4.5 现象:中文参数名或者含中文的路径读回失败
编码问题在老项目里很常见。表达式名、属性名用中文,在某些读写路径上会被转成乱码,回读时按名字找不到,于是走默认值,用户以为参数没保存。
解决方法是参数名统一用英文加下划线,把中文留给界面显示层,两者之间做一层映射。路径方面,程序集目录、日志目录、临时文件目录都避开中文和空格,这类问题排查成本极高,规避成本几乎为零。
5. 装配与多实例:CustomFeature 最容易越界的两个场景
单 Part 环境跑通,只能算完成一半。装配环境下的部件归属和多实例下的参数隔离,是交付前必须单独测的两块。
5.1 WorkPart、DisplayPart 与 OwnerPart 别混用
NX 里同时存在三个「当前部件」的概念:工作部件是用户正在编辑的,显示部件是窗口里显示的,而特征真正属于的那个部件是拥有者。在装配里打开一个子部件建模,工作部件是子部件,显示部件可能是总装,如果你用显示部件去创建特征,特征会挂到总装下面,用户回到子部件找不到,以为功能失效。
代码里的原则是:所有几何创建都用构建器传进来的拥有者部件,不要自己从会话里取当前部件。参数校验可以用工作部件,建模一律用拥有者。测试用例 C-07 就是专门测这个的,在总装里双击进入子部件,建一个特征,然后回到总装看特征树,看它挂在哪一层。挂错层的表现是能建、能看,但子部件单独打开时特征消失。
装配下还有第二个坑:跨部件的布尔运算。默认的布尔选项是新建,如果业务要求自动求和,必须确认目标体和工具体在同一个部件里,跨部件的布尔在 NX 里不是不允许,而是行为依赖加载状态,批量处理时容易出现「有的合并了有的没合并」。
5.2 同一特征多实例下的命名与参数隔离
同一个 Part 里建两个同类型特征,最容易出的事是命名冲突和参数串味。命名冲突的表现是第二个特征建出来后,第一个的表达式被覆盖;参数串味的表现是改第二个,第一个跟着变。
根因有两个:一是参数写在了静态字段或者单例里,二是表达式名用了固定字符串。解决方法很直接,参数全部放构建器实例,表达式名带特征唯一标识,比如tube_h_1、tube_h_2,序号在创建时从已有特征里扫描最大值加一。
// 生成不冲突的表达式名前缀:扫描已有表达式的最大序号 int idx = 1; var names = _owner.Expressions.GetStringExpressions() .Select(e => e.Name) .Where(n => n.StartsWith("tube_h_")) .ToList(); if (names.Count > 0) { idx = names.Select(n => int.Parse(n.Substring("tube_h_".Length))).Max() + 1; } string prefix = "tube_h_" + idx;这种做法有个副作用:删除特征后序号不回收,表达式名会一直往后涨。可以接受,因为回收序号反而会引入「新特征读到了旧参数」的风险,宁可名字难看一点。
5.3 旧版本特征在新 DLL 下的兼容读法
程序集升级是必然会发生的。旧特征里存的参数结构和新程序集里的读取逻辑对不上,表现是打开老模型时特征报错,或者参数全部回到默认值。
常见做法是给参数读取加一层版本判断:先尝试按新名字读,读不到再按旧名字读,都读不到才用默认值。不要用异常来控制流程,try/catch包整个反序列化会导致真正的错误被吞掉。
| 兼容策略 | 适用场景 | 代价 |
|---|---|---|
| 新名优先、旧名兜底 | 参数只是改名,语义没变 | 读取代码多一层判断 |
| 版本号写在特征属性里 | 参数结构变过,需要分支处理 | 每次建模要多写一次属性 |
| 老特征只读、提示重建 | 结构变化太大,迁移成本高于重建 | 用户需要手工操作一次 |
我一般选第一种,成本最低。第二种只在参数语义真的变过时用,比如原来存的是半径后来改成直径,这种必须显式区分,否则老模型打开尺寸直接翻倍。
6. 把手工点击变成回归:journal 批跑脚本和两条固定习惯
手工点十遍用例,不如让脚本跑一遍。NX 自带 journal 执行入口,可以把 Python 或 C# 写的操作脚本批量跑起来,配合返回码和日志关键字判断成败。
# run_regression.ps1:批量执行 journal,收集失败用例 param( [string]$JournalDir = "D:\nx_custom\journals", [string]$Runner = "D:\nx_custom\bin\run_journal.exe" # 按本机安装位置替换 ) $failed = @() Get-ChildItem -Path $JournalDir -Filter *.py | ForEach-Object { $log = Join-Path "D:\nx_custom\logs" ($_.BaseName + ".out") # -batch 无界面执行,适合放在计划任务里夜间跑 $p = Start-Process -FilePath $Runner ` -ArgumentList @("-batch", $_.FullName) ` -RedirectStandardOutput $log -PassThru -Wait $text = Get-Content $log -Raw if ($p.ExitCode -ne 0 -or $text -match "ERROR|异常") { $failed += $_.BaseName } } if ($failed.Count -gt 0) { Write-Host ("失败用例:" + ($failed -join ", ")) } else { Write-Host "全部通过" }关键参数就两个:-batch让 NX 不开界面跑完就退,适合挂在夜间任务里;-RedirectStandardOutput把每个 journal 的输出单独成文件,失败时直接看对应那一份,不用在几万行总日志里翻。脚本本身不判断业务正确性,只判断有没有报错,几何是否正确还得靠 journal 里的断言,比如建完之后查体的数量、查表达式的值。
两条固定习惯,是我踩了几年坑之后留下来的。第一,每次改完程序集,先跑 C-01 和 C-06 两个用例,新建加保存重开,这两条过了再谈别的;很多空壳问题就是在这两步之间暴露的,早跑早省事。第二,日志里永远打参数值,不打「成功」「失败」这种结论词,因为结论会骗人,参数不会。
这套东西搭起来大概两天,之后每次改代码的回归时间从半天压到二十分钟。值不值得做,我的判断是:只要这个自定义特征会被超过三个人用,就值得。希望帮到你。
本文还有配套的精品资源,点击获取