ARTICLE · 1065573
AvevaMarine插件AI实战--鼠标悬停在模型上,显示我想看的信息(含源码)
前段时间刷到一篇博客,讲 AVEVA Marine 里怎么让鼠标悬停在模型树上弹出信息。看完觉得非常有用,索性照着自己的需求让AI做了一个:属性行能自己配、能选显示哪一级元素、配色字号随便改,还带一个诊断窗口。这篇文章把整个思路、踩过的坑和关键源码都写下来,供想入门 AM 二次开发的同行参考。
(备注就连此文也是AI所写~~~~源码和插件放在本文最后网盘链接供大家学习下载)
一、先明确需求
AM 自带的悬停提示只有 Name / Type / Description 三行,而且不同版本的显示还不太一样。实际做设计、做校对的时候,我们更想看的是:
这个弯头属于哪根管子(显示层级:本体 / 管段 / 整根管道,可以自己选) 元素的描述文本 DTXR、净重 NWEI(这两个是 UDA,不是系统属性) 参考号 REF、修改人、修改时间、占用用户、锁定状态…… 最好颜色、字号、边框都能自己调,跟 AM 的界面风格搭一点
最后做出来的插件叫 ModelHoverInfo,两个使用场景:
模型树(Design Explorer):鼠标停在节点上约 0.35 秒,弹出信息卡片; 
3D / 2D 视图:复用 AM 自己的拾取,把原生提示的文字换成我们的属性表。
二、最关键的一个判断:拾取别自己写
很多同学第一反应是“我自己做拾取”:把鼠标屏幕坐标反投影到模型空间,遍历模型求交,再考虑遮挡、剖切、线框显示…… 这条路能走通,但工作量和难度完全不是一个量级,精度也很难追上原生。
实际上,AM 的 3D 视图本来就有悬停提示(视图设置里的 Show Tooltips),它是原生代码实现的:鼠标压在哪根管子上、拾取到哪个元素,AM 自己算得又准又快。
所以我们要做的事只有一件:把 AM 已经算好的那段文字接管过来,换成我们想要的排版。
结论一句话:不重写拾取,只替换文字。指哪根管子就是哪根管子,精度和 AM 自带的一模一样。

三、找到注入点:Druid 画布上的 Tooltip 对象
AM 的视图控件是 Druid 的 UI_DruidCanvas(internal 类,在 DruidNet.dll 里)。它有一个 Tooltip 属性,类型是 UI_DruidCanvas+TooltipControl,反编译看到的关键成员:
public event EventHandler NeedTooltipText; // 要文字的时候问一声public string Text { get; set; } // 当前提示文字public bool Enabled { get; set; }publicvoidSetTooltipDelay(int initial, int reshow);
AM 自己的处理器会在这个事件里,把原生算好的文字写进 Text。这就是当前鼠标压着的元素。
于是插件的做法:
每隔 2 秒扫一遍主窗口的子控件,收集所有 UI_DruidCanvas;反射拿到 Tooltip属性、NeedTooltipText事件(类型是 internal,编译期引用不到,只能反射);挂上我们自己的处理器:读出 Text→ 解析出DbElement→ 用InfoBuilder生成属性表 → 写回Text。
代码骨架(节选):
// 1) 找类型(DruidNet 通常已经由 AM 载入)canvasType = asm.GetType(”Aveva.PDMS.Presentation.Druid.UI_DruidCanvas”, false);// 2) 摸清成员tooltipProp = canvasType.GetProperty(”Tooltip”, BF); // BF = Public|NonPublic|InstanceType tipType = tooltipProp.PropertyType;textProp = tipType.GetProperty(”Text”, BF);needTextEvent = tipType.GetEvent(”NeedTooltipText”, BF);// 3) 挂事件needTextEvent.AddEventHandler(tip, new EventHandler(OnNeedTooltipText));
坑一:提示延时不能小于 500 毫秒
AM 画布上的提示框是系统原生的 tooltips_class32 + TTF_SUBCLASS,由系统在 TTDT_INITIAL 之后弹出来;而文字是 AM 自己的 400 毫秒定时器到点后才写进去的。
所以如果 TTDT_INITIAL 小于 400(比如设置里把延迟填成 0),提示会以空文字弹出来,或者干脆不弹 —— 看起来就是“3D 视图没有信息了”。
插件现在把下限钳到 500 ms、上限 5000 ms。这是平台机制决定的。
坑二:处理器顺序(AM 会把自己重新挂到最后)
AM 在 UI_Canvas_Reparented 里会再挂一次它自己的 OnNeedTooltipText。那样它排在我们后面,会把我们写好的文字覆盖回去。
解决办法:每次扫描时,把我们的处理器先 Remove 再 Add,永远挪到事件列表末尾,保证我们的文字说了算。
四、认元素:为什么悬停弯头却显示成整根管道
AM 原生提示的文字长这样(一整段,名字里带层次):
Name: ELBOW 1 of BRANCH 1 of PIPE /ZZZS-1WZ_HO0018Type: UNKNOWNDescription:
第一版的做法很朴素:把整段文字切成单词,从长到短喂给 DbElement.Parse,谁先命中就算谁。
结果就是:PIPE 比 ELBOW 长,于是悬停弯头显示的是整根管道—— 这也是一开始最容易被吐槽的问题。
修正后的解析规则(ViewTooltipHook.ExtractNameCandidates)按这个顺序收集候选名:
整段文字(去掉 Name:/Type:/Description:这类前缀);逐行:冒号后面的值优先,再退化成整行; 斜杠形式:把 A of B of C /D拼成/D/C/B/A—— 这是 PDMS 从根到叶的写法。
然后是最关键的一条:
// 文字里有 ”/xxx” 这种完整名字 -> 已经够可信了, 不再用零散单词去猜bool hasPath = false;foreach (string c in list) { if (c.IndexOf('/') >= 0) { hasPath = true; break; } }if (hasPath) return list;
一旦文字里出现完整的层次路径,就完全放弃“按单词猜”。宁可认不出(保持 AM 原文提示),也不要认错 —— 显示错误的信息比不显示更糟糕。
五、显示层级:弯头 / 管段 / 整根管道,自己选
这是需求里最实用的一条:有时候我要看弯头本身,有时候我要看它属于哪根管段,有时候只关心整根管道。
实现很直接:从光标下的元素出发,沿着 Owner 往上走 N 级;
// InfoBuilder.ApplyLevel: 往上走 N 级, 走到 WORLD 或无效元素就停for (int i = 0; i < levels; i++){DbElement up = cur.Owner;if (up == null || up.IsNull) break;DbElementType t = up.GetActualType();if (t != null && t.IsWorld) break; // 到 WORLD 为止cur = up;applied++;}
设置里是一个下拉框(0 - 8 级),悬停效果:
两个防呆细节:
往上找的时候,标题自动追加「(父级 N 级)」,一眼能看出显示的是谁; 属性表里可以加一行 #LEVEL,写清“本体”还是“父级 N 级(从光标下的元素往上 N 级)”,免得被当成解析错误。
六、模型树:AM 不给你事件,只能自己扫
视图那边还算“有个口子”,模型树这边就粗暴得多:模型树是 Infragistics 的 UltraTree,AM 没有提供“节点悬停”的公开事件。
所以插件的做法是:
每 2 秒扫一遍主窗口的子控件,找所有 UltraTree;给每个树挂 MouseMove/MouseLeave;用 Timer做延迟弹框(同一个节点不重复弹,鼠标离开就收);从命中的节点上反射取出 DbElement,交给同一个InfoBuilder。
一个必须处理的小麻烦:AM 自带的树提示会盖住我们
AM 的 Design Explorer 自己给树挂了一个 System.Windows.Forms.ToolTip(ExplorerCtrl.mToolTip,文字来自 ToolTipManager.ToolTipForDbElement,就是 Name / Type / Description 那三行)。它弹出的位置正好在鼠标下面,会盖住我们的提示框。
办法:顺着树往上找父控件,反射拿这个私有字段,我们开着的时候把它的 Active 设成 false,树上就只剩我们的提示框;插件关掉时再还原。
// 顺着父控件往上找 4 层,看谁有 mToolTip 字段FieldInfo fi = cur.GetType().GetField(”mToolTip”, BF);if (fi != null && fi.GetValue(cur) is System.Windows.Forms.ToolTip tip){tip.GetType().GetProperty(”Active”).SetValue(tip, false, null); // 屏蔽它}
反射私有字段属于“能用但脆弱”的做法,所以插件每次扫描都会重新找一遍,实在找不到就只在日志里留一行,不影响其它功能。
七、外观设置为什么“不生效”:一次反编译找到真凶
这是整个插件花时间最多的部分,也是我觉得最值得分享的一段。
现象:设置里改了背景色、文字色,模型树上的提示框颜色纹丝不动。
第一版树上用的是 Infragistics 的 UltraToolTip(跟树是同一套控件)。试过把 UseAppStyling 设成 false,颜色确实能好一点,但换样式还是会被主题盖掉。
于是把 Infragistics.Win.ToolTip 反编译出来,看它的 IL —— 一下子就有答案了。它的 GetResolvedToolTipAppearance 是这样的:
当 DisplayStyle解析成Office2007时:BackColor = Office2007ColorTable.Colors.ToolTipGradientLight、BackColor2 = ToolTipGradientDark、ForeColor = ToolTipForecolor、边框用ToolTipBorder;WindowsVista就换成 WindowsVistaColorTable里的一套;标题外观 GetResolvedToolTipTitleAppearance同样处理;只有 Standard 样式才真正认我们设的 BackColor/ForeColor。
也就是说:用它的 ToolTip,就必须在“主题样式”和“自定义配色”里二选一。而设置界面上这两个选项都摆着,用户一定会同时想要 —— 那就干脆自己画。
自绘提示框:HoverTipForm + TipRenderer
先说承载它的窗口 HoverTipForm。三条要求:无边框、置顶、绝对不能抢焦点(一抢焦点,AM 的视图就掉激活状态,操作手感全变了)。
public sealed class HoverTipForm : Form{protected override bool ShowWithoutActivation { get { return true; } }protected override CreateParams CreateParams{get{CreateParams cp = base.CreateParams;cp.ExStyle |= 0x08000000; // WS_EX_NOACTIVATE 不抢焦点cp.ExStyle |= 0x00000080; // WS_EX_TOOLWINDOW 不进任务栏/Alt+Tabreturn cp;}}}
内容由 TipRenderer 纯 GDI+ 画出来,几个要点:
背景不是纯色,是「比设定色略亮一点的颜色 → 设定色」的竖直渐变,看起来有一点质感; 圆角用 GraphicsPath拼(四段圆弧 + 两段直线);气球样式再在下面加一个尖角,尖角跟着鼠标走;边框:立体(Raised)用亮暗两条线画(上左亮、下右暗),单线(Solid)就是一个描边,None 不画; 正文两列排版:标签列用「文字色和背景色混 25% 的浅色」,值列用文字色,交给 TextRenderer画,对齐得整整齐齐;超宽自动折行(优先在空格处断),并且按屏幕工作区贴边摆放,不让提示框跑出屏幕。
核心几行:
TipShape shape = ShapeOf(st.DisplayStyle); // Rounded / Glass / Square / Balloonint border = BorderWidth(st.BorderStyle); // None=0, 其余=1GraphicsPath path = CardPath(card, shape, tailX);using (LinearGradientBrush br = new LinearGradientBrush(card,Shade(back, 0.10), back, LinearGradientMode.Vertical)){g.FillPath(br, path);}
设置窗口里的样式下拉框,对应关系是:
还有一个“防不一致”的设计:设置窗口的预览面板用的是同一个TipRenderer.Render,所以预览和真实提示框长得完全一样,不会出现“预览挺好看、实际不是那样”。
至于 3D / 2D 那条提示:它是系统原生的 tooltip 窗口,插件只能反射拿到句柄发 Win32 消息:
TTM_SETTIPBKCOLOR | |
TTM_SETTIPTEXTCOLOR | |
TTM_SETMAXTIPWIDTH |
字体、边框、图标改不了 —— 那是 comctl32 自己画的。这是平台能力的边界,写进文档别让用户以为是 bug。真要在 3D 里换字体,只能像模型树那样自己造一个无边框窗口来画,那就要自己实现跟随和生命周期,插件暂时没做。
八、设置窗口的幽灵 bug:勾了保存不上
现象:把「3D/2D 视图悬停也提示」勾上 → 保存 → 再打开设置,又是没勾的状态。用户会觉得“设置根本没保存”。
原因:设置窗口没有重入保护。载入设置时给控件赋值会触发 ValueChanged → UpdatePreview() → 把界面上的“控件默认值”(没勾的复选框、系统灰、默认字号)写回草稿,把刚读出来的设置覆盖掉了。
修法:一个 loading 标志,载入期间所有“控件变化”回调直接返回。
loading = true; // 载入开始... // 给各个控件赋值(会触发 ValueChanged, 但这时不做任何事)loading = false; // 载入结束privatevoidOnAnyChanged(object sender, EventArgs e){if (loading) return; // 装载界面时不要拿控件默认值覆盖设置CollectScalars(draft);UpdatePreview();}
顺带一句:同一个 bug 也是“设了颜色反而变回系统灰”的根源。写 WinForms 设置窗口的同学,这个坑值得记一下 —— 症状很像“设置没保存”,其实是设置被界面自己覆盖了。
九、属性行怎么配:DTXR、NWEI 这种 UDA 怎么取
插件默认显示这 13 行:
#TYPE | ||
#NAME | ||
DTXR | ||
NWEI | ||
REF | ||
DESC | ||
#OWNER | ||
USERMLASTM | ||
USERC | ||
CREANCDATECR | ||
LOCK |
取值逻辑这里有个坑:REF、DESC、USERM 这些系统属性在 DbAttributeInstance 上有同名的静态字段,取起来很快;而 DTXR、NWEI 是用户自定义属性(UDA),没有字段,得走 DbAttribute.GetDbAttribute()。
// 先找系统属性的静态字段(每次重新取值, 避免缓存到失效对象)FieldInfo fi = typeof(DbAttributeInstance).GetField(key, BF | BindingFlags.Static);if (fi != null) return fi.GetValue(null) as DbAttribute;// 字段里没有 -> 当作 UDA / 其它属性名return DbAttribute.GetDbAttribute(key);
取不到属性就跳过这一行,绝不让一行坏数据毁掉整个提示框 —— 提示框宁可少一行,也不能弹个错误出来。
另外还有 5 个特殊项由插件自己算:#TYPE #NAME #PATH #LEVEL #OWNER。其中 #PATH(完整层次路径)要沿 Owner 一路往上拼到 WORLD 为止,AM 没有现成的接口。
十、不弹提示的时候,怎么排错
插件类的工具最怕“没反应”却不知道为什么。所以做插件的时候顺手做了一个诊断窗口,两节信息:
模型树一节
找到几个 UltraTree(数量 0 = 树没打开,或者版本结构不同);有没有找到 AM 自带的 mToolTip、有没有成功屏蔽;最近一次拿到的节点、解析出的元素、最终文本。
视图一节
反射就绪 / 已绑定画布数(0 = 没找到 Druid 画布); 事件触发次数/ 已改写次数/沿用上次次数/已上色次数/候选个数;最近一次 AM 原始提示的原文(这个信息最有用,能直接看出 AM 给的是不是元素名); 原生提示框句柄(拿不到句柄,颜色就没生效)。
再加一个日志 %APPDATA%\ModelHoverInfo\hover.log:头 15 次每次都记,之后每 50 次记一条汇总—— 既不刷爆磁盘,又能看出趋势。
几个典型判据:
另外 AM 拾取不到时有个兜底:鼠标几乎没动(12 像素内、5 秒内)就把上一次的信息再写一遍,避免提示框忽然变空白。这个可以关掉,关掉就完全是“AM 给什么显示什么”。
十一、怎么测试一个“界面插件”
界面插件最难的是回归测试。我的做法是两条腿:
第一条腿:把逻辑做成纯函数。解析(ExtractNameCandidates)、层级上溯(ApplyLevel)、文本拼接(JoinLines)、摆位(PlaceNear)全都是 public static 的纯函数,不依赖 AM 窗口,可以直接写断言。
第二条腿:离屏窗口 + 抓图 + 数像素。把窗口设成 StartPosition = Manual、Location 挪到屏幕外(比如 -4000, -4000),然后 DrawToBitmap 抓成位图,再数像素颜色:
// 用”设置里指定的背景色/文字色”画一份提示框, 然后在位图里数颜色占比Bitmap bmp = new Bitmap(w, h);using (Graphics g = Graphics.FromImage(bmp))TipRenderer.Render(g, new Rectangle(0, 0, w, h), info, settings, false, Point.Empty);int bg = CountNear(bmp, settings.GetBackColor(), 40); // 容差 40 内算命中Assert(bg * 100 / (w * h) > 45, ”背景色真的画出来了”);
这样“设置里的颜色有没有真的生效”就变成了可以自动断言的数字,而不是靠肉眼看。整套回归脚本目前有123 项检查,包括:
属性行有没有丢、配置文件回读是否一致、DTXR/NWEI 在不在名称后面; 四种样式各自能否画出背景色和文字色; 字号变大 → 提示框变高; 立体边框是否“上亮下暗”; 提示框是否会被摆到屏幕外; 预览面板的颜色是不是真的来自设置。
这套方法对任何自绘控件都好用,强烈推荐。
十二、部署的两个硬规矩
1. DLL 文件名必须等于注册名。
AM 是按 OutfittingAddins.xml 里的字符串去找同名 DLL的。产物叫 ModelHoverInfoAddin.dll,注册名就必须是 ModelHoverInfoAddin;名字对不上,插件就是“装了没反应”,而且不会有任何报错。
<!-- D:\AVEVA\Marine\OH12.1.SP4\DesignAddins.xml -->ModelHoverInfoAddin
2. 位数和框架要对。
AM 12.1 是 32 位进程 + .NET Framework 3.5,所以插件必须:
编译成 /platform:x86;引用 AM 安装目录下的 Aveva.Pdms.Database.dll、Aveva.Pdms.Presentation.dll、DruidNet.dll、Infragistics.*.dll等;目标框架 3.5(用 C:\Windows\Microsoft.NET\Framework\v3.5\csc.exe编译最省事)。
部署完重启 AVEVA Marine,菜单栏出现 [悬停信息],Design 工具栏出现放大镜按钮,就装好了。
十三、源码结构
Addin.cs | |
ExplorerHook.cs | |
TipRenderer.cs | |
HoverTipForm.cs | |
ViewTooltipHook.cs | |
InfoBuilder.cs | |
HoverSettings.cs | |
SettingsForm.cs | |
DiagnosticsForm.cs | |
Log.cs | |
build.ps1deploy.ps1 |
最后:几条心得
先找“现成的轮子”再动手。3D 拾取这种活,能复用原生就复用,插件只做“显示什么”这一层; 界面效果不生效,先怀疑框架主题,别急着怀疑自己的代码 —— 反编译一遍 IL,答案比猜快得多; 反射访问 internal 成员是这类工业软件二次开发的常规操作,但一定要留诊断出口(诊断窗口 + 日志),否则出了问题只能靠猜; 平台能力的边界要写进文档。3D 那条提示只能改颜色和宽度,是 comctl32 的限制,写清楚了用户就不会当成 bug; 设置窗口的作用域 bug 很常见(载入时被控件默认值覆盖),一个 loading 标志能省掉一下午的排查。
这个插件是在 AVEVA Marine 12.1 SP4 + .NET Framework 3.5 上做的,用反射访问 Druid / Infragistics 的 internal 成员,AM 大版本升级后如果类名变了需要同步调整(诊断窗口就是为这个准备的)。
度盘链接: https://pan.baidu.com/s/1zBfm7Oelo_de-QyqdgXIuQ?pwd=AMXX 提取码: AMXX
由于微信群已被解散,大家还有学习交流需要的话可以加入qq群里学习交流,qq群学习分享资料更为方便,qq群号819848455