夜雨聆风学习资料网

ARTICLE · 1065573

AvevaMarine插件AI实战--鼠标悬停在模型上,显示我想看的信息(含源码)

AvevaMarine插件AI实战--鼠标悬停在模型上,显示我想看的信息(含源码)

前段时间刷到一篇博客,讲 AVEVA Marine 里怎么让鼠标悬停在模型树上弹出信息。看完觉得非常有用,索性照着自己的需求让AI做了一个:属性行能自己配、能选显示哪一级元素、配色字号随便改,还带一个诊断窗口。这篇文章把整个思路、踩过的坑和关键源码都写下来,供想入门 AM 二次开发的同行参考。

(备注就连此文也是AI所写~~~~源码和插件放在本文最后网盘链接供大家学习下载)


一、先明确需求

AM 自带的悬停提示只有 Name / Type / Description 三行,而且不同版本的显示还不太一样。实际做设计、做校对的时候,我们更想看的是:

  • 这个弯头属于哪根管子(显示层级:本体 / 管段 / 整根管道,可以自己选)
  • 元素的描述文本 DTXR净重 NWEI(这两个是 UDA,不是系统属性)
  • 参考号 REF、修改人、修改时间、占用用户、锁定状态……
  • 最好颜色、字号、边框都能自己调,跟 AM 的界面风格搭一点

最后做出来的插件叫 ModelHoverInfo,两个使用场景: 

  1. 模型树(Design Explorer):鼠标停在节点上约 0.35 秒,弹出信息卡片;
  2. 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 { getset; }             // 当前提示文字public bool   Enabled { getset; }publicvoidSetTooltipDelay(int initial, int reshow);

AM 自己的处理器会在这个事件里,把原生算好的文字写进 Text。这就是当前鼠标压着的元素。

于是插件的做法:

  1. 每隔 2 秒扫一遍主窗口的子控件,收集所有 UI_DruidCanvas
  2. 反射拿到 Tooltip 属性、NeedTooltipText 事件(类型是 internal,编译期引用不到,只能反射);
  3. 挂上我们自己的处理器:读出 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)按这个顺序收集候选名:

  1. 整段文字(去掉 Name: / Type: / Description: 这类前缀);
  2. 逐行:冒号后面的值优先,再退化成整行;
  3. 斜杠形式:把 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 = truebreak; } }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 级),悬停效果:

显示层级
悬停弯头时显示的元素
0 本体
弯头 ELBOW
1 父级 1 级
所在管段 BRANCH
2 父级 2 级
整根管道 PIPE

两个防呆细节:

  • 往上找的时候,标题自动追加「(父级 N 级)」,一眼能看出显示的是谁;
  • 属性表里可以加一行 #LEVEL,写清“本体”还是“父级 N 级(从光标下的元素往上 N 级)”,免得被当成解析错误。

六、模型树:AM 不给你事件,只能自己扫

视图那边还算“有个口子”,模型树这边就粗暴得多:模型树是 Infragistics 的 UltraTree,AM 没有提供“节点悬停”的公开事件。

所以插件的做法是:

  1. 每 2 秒扫一遍主窗口的子控件,找所有 UltraTree
  2. 给每个树挂 MouseMove / MouseLeave
  3. 用 Timer 做延迟弹框(同一个节点不重复弹,鼠标离开就收);
  4. 从命中的节点上反射取出 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, falsenull);  // 屏蔽它}

反射私有字段属于“能用但脆弱”的做法,所以插件每次扫描都会重新找一遍,实在找不到就只在日志里留一行,不影响其它功能。


七、外观设置为什么“不生效”:一次反编译找到真凶

这是整个插件花时间最多的部分,也是我觉得最值得分享的一段。

现象:设置里改了背景色、文字色,模型树上的提示框颜色纹丝不动。

第一版树上用的是 Infragistics 的 UltraToolTip(跟树是同一套控件)。试过把 UseAppStyling 设成 false,颜色确实能好一点,但换样式还是会被主题盖掉。

于是把 Infragistics.Win.ToolTip 反编译出来,看它的 IL —— 一下子就有答案了。它的 GetResolvedToolTipAppearance 是这样的:

  • 当 DisplayStyle 解析成 Office2007 时:BackColor = Office2007ColorTable.Colors.ToolTipGradientLightBackColor2 = ToolTipGradientDarkForeColor = 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+Tab            return 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);}

设置窗口里的样式下拉框,对应关系是:

下拉框
内部值
画出来的样子
圆角卡片 (Office 风格)
Office2007
圆角 6px 卡片
圆角浅色 (Vista 风格)
WindowsVista
圆角 9px,更圆润
直角方框 (标准)
Standard
直角矩形
气球提示 (带尖角)
BalloonTip
卡片 + 跟着鼠标的尖角

还有一个“防不一致”的设计:设置窗口的预览面板用的是同一个TipRenderer.Render,所以预览和真实提示框长得完全一样,不会出现“预览挺好看、实际不是那样”。

至于 3D / 2D 那条提示:它是系统原生的 tooltip 窗口,插件只能反射拿到句柄发 Win32 消息:

消息
作用
TTM_SETTIPBKCOLOR
 (0x413)
背景色
TTM_SETTIPTEXTCOLOR
 (0x414)
文字色
TTM_SETMAXTIPWIDTH
 (0x418)
最大宽度

字体、边框、图标改不了 —— 那是 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
元素的描述文本(UDA)
重量
NWEI
净重(UDA)
参考号
REF
描述(DESC)
DESC
PDMS 自带的 DESC
所有者
#OWNER
父级元素名
修改人 / 修改时间
USERM
 / LASTM
占用用户
USERC
创建人 / 创建日期
CREANC
 / DATECR
锁定
LOCK

取值逻辑这里有个坑:REF、DESC、USERM 这些系统属性在 DbAttributeInstance 上有同名的静态字段,取起来很快;而 DTXR、NWEI 是用户自定义属性(UDA),没有字段,得走 DbAttribute.GetDbAttribute()。

// 先找系统属性的静态字段(每次重新取值, 避免缓存到失效对象)FieldInfo fi = typeof(DbAttributeInstance).GetField(key, BF | BindingFlags.Static);if (fi != nullreturn fi.GetValue(nullas 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 次记一条汇总—— 既不刷爆磁盘,又能看出趋势。

几个典型判据:

现象
多半是什么原因
事件触发次数一直是 0
AM 没走托管通道,看看是不是视图没开 / 版本结构不同
有触发,已改写次数一直 0
AM 只给了说明文字没给元素名,需要放宽解析规则
沿用上次次数一直涨
AM 原生拾取经常给不出文字(鼠标压在边界、被遮挡、正在重绘)
已上色次数一直 0
没拿到原生提示框句柄

另外 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(00, 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.dllAveva.Pdms.Presentation.dllDruidNet.dllInfragistics.*.dll 等;
  • 目标框架 3.5(用 C:\Windows\Microsoft.NET\Framework\v3.5\csc.exe 编译最省事)。

部署完重启 AVEVA Marine,菜单栏出现 [悬停信息],Design 工具栏出现放大镜按钮,就装好了。


十三、源码结构

文件
作用
Addin.cs
插件入口(IAddin),注册菜单 / 工具栏 / 四个命令
ExplorerHook.cs
模型树:找 UltraTree、挂鼠标事件、延迟弹出自绘提示框、屏蔽 AM 自带树提示
TipRenderer.cs
提示框的画法:字体缓存、四种样式、渐变底色、边框、图标、两列排版、自动折行、贴边摆放
HoverTipForm.cs
承载提示框的无边框窗口(置顶 + WS_EX_NOACTIVATE,不抢焦点)
ViewTooltipHook.cs
3D/2D 视图:反射挂 Druid 画布的 Tooltip,接管原生悬停文字
InfoBuilder.cs
DbElement → 提示框内容(标题 + 属性行;也能拼成纯文本给原生 tooltip)
HoverSettings.cs
配置模型 + XML 读写(%APPDATA%\ModelHoverInfo\settings.xml)
SettingsForm.cs
设置窗口:延迟 / 显示层级 / 样式 / 颜色 / 字号 / 显示哪些属性 + 实时预览
DiagnosticsForm.cs
诊断窗口:模型树 + 视图画布两节,列找到的对象、最近原始提示、改写次数
Log.cs
日志:%APPDATA%\ModelHoverInfo\hover.log
build.ps1
 / deploy.ps1
编译 / 部署(复制 DLL + 注册到 DesignAddins.xml,自动备份)

最后:几条心得

  1. 先找“现成的轮子”再动手。3D 拾取这种活,能复用原生就复用,插件只做“显示什么”这一层;
  2. 界面效果不生效,先怀疑框架主题,别急着怀疑自己的代码 —— 反编译一遍 IL,答案比猜快得多;
  3. 反射访问 internal 成员是这类工业软件二次开发的常规操作,但一定要留诊断出口(诊断窗口 + 日志),否则出了问题只能靠猜;
  4. 平台能力的边界要写进文档。3D 那条提示只能改颜色和宽度,是 comctl32 的限制,写清楚了用户就不会当成 bug;
  5. 设置窗口的作用域 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

相关学习资料