C# 二次开发 TerraExplorer 三维 GIS 宿主嵌入与避坑

发布时间:2026/11/2 8:50:30
C# 二次开发 TerraExplorer 三维 GIS 宿主嵌入与避坑 简介面向具备一定C#与GIS基础、希望基于Skyline TerraExplorer进行三维地球二次开发的开发者这份示例代码包提供可运行的WinForms工程骨架与接口调用范例帮助理解地图控件、事件响应、图层与三维模型加载、空间查询及自定义界面等常见任务的落地方式。压缩包共21个文件、约42KB以C#源码.cs和窗体资源.resx为主体并附带Shapefile.shp/.dbf/.prj/.shx等与Fly工程文件便于对照代码验证数据加载和场景组织流程工程还包含解决方案与项目配置适合直接打开调试。已有484人学习下载可作为上手TerraExplorer SDK、梳理二次开发思路和搭建演示原型的参考材料。1. 从一块怎么也没法嵌进去的三维地球说起去年帮人接手一个桌面端三维 GIS 项目难点不在业务算法而在把一个三维地球塞进自己的 WinForms 窗体地球能弹出来却像一个独立窗口浮在面板上拖动主窗体时它就停在原地。这类需求在三维 GIS 圈子里很典型业内常笼统地把这套产品线叫作 Skyline 三维客户端核心组件就是 TerraExplorer。这份 C# 二次开发示例代码拆开的是宿主嵌入、工程加载、视点飞行、对象标绘、插件消息这几条主线。适合两类人要把三维场景接进已有桌面业务系统的开发者以及刚拿到 TE 组件、对着接口文档不知从哪下手的人。2. 项目定位与目录结构这套 C# 示例给的是哪几块拼图TerraExplorer 本质是一个 COM 服务器。它自己有一个完整的桌面客户端能打开.fly工程文件工程里装着图层树、三维对象、视点、场景参数和渲染设置。二次开发做的不是重写渲染而是通过 COM 接口去驱动这个已经做好的客户端让它在你的窗口里显示让它飞到某个坐标让它画一个点让它在你点击时回调你。C# 示例代码的价值就在这儿——它把“怎么驱动”翻译成了可以直接编译运行的小工程。2.1 一份示例包通常长什么样宿主、插件、脚本三条支线从这类示例包的常见组织方式看代码一般分成三条支线各管一件事宿主支线WinForms 或 WPF 工程负责创建进程内的 TerraExplorer 实例、把渲染窗口挂到自己的 Panel 上、管理工程打开与关闭。这条线是绝大多数人真正要抄的部分。插件支线编成一个 DLL由 TE 客户端在启动时加载跑在客户端进程里。适合做“客户端里加一个自己的按钮/面板”的需求也适合做和宿主通信的功能扩展。脚本/批处理支线控制台小工具不显示界面只做无人值守的活比如批量把一堆坐标导成三维标注、批量把工程里的对象导出成表。目录上这三条线通常平铺在一个解决方案下公共的接口封装放在一个类库工程里被前两者引用。核心目录大致是这种结构TeDemo.sln ├── TeDemo.Host/ WinForms 宿主主入口 │ ├── MainForm.cs 创建实例、SetParent、打开工程 │ └── ViewerPanel.cs 渲染窗口的容器与尺寸同步 ├── TeDemo.Plugin/ TE 插件 DLL │ ├── PluginEntry.cs 实现插件接口接收工程事件 │ └── MessageBridge.cs 与宿主之间的自定义消息收发 ├── TeDemo.Core/ 公共封装 │ ├── SceneService.cs 对 ITerraExplorer/IProject/INavigate 等的门面 │ └── CoordHelper.cs 经纬度与本地坐标互转 └── TeDemo.Tools/ 控制台批处理 └── BatchExport.cs值得先说清楚的是.fly工程文件本身。它是整个项目的“配置文件 数据索引”既存了场景设置也存了每个三维对象的描述、样式和挂载的业务数据。很多人第一次接手时以为工程文件可以随便换机器拷结果拷过去一片空白——因为里面引用的图层源地址往往是绝对路径或者某个服务地址这部分后面在避坑章会专门说。2.2 为什么是 C# 加 COM 互操作而不是别的组合TE 对外只暴露 COM。这意味着可选路线其实就三条C#/VB.NET 走 COM 互操作、C 直接吃 COM、以及网页里嵌 ActiveX 控件。C# 被选得多原因很实际COM 互操作由运行时兜底Visual Studio 生成 interop 程序集之后接口能直接被智能提示认出来事件也能用挂写起来比 C 少一大截样板代码而 ActiveX 那条路绑死在旧浏览器内核上早就不是新项目该考虑的方向。代价是COM 带来的坑一个不少线程模型是 STA对象释放必须严格按顺序位数必须对齐。这也是示例代码里为什么频繁出现Marshal.ReleaseComObject和[STAThread]——它们承担的作用是“别让程序崩在退出那一步”不是为了好看。2.3 一次会话里只有一个实例接口聚合模型这是 TE 二次开发里最容易懵、也最该早点想明白的一点你new出来的那个TerraExplorer对象其实是同一个 COM 对象的多套接口视图。工程加载、视点飞行、对象创建、坐标转换用的是同一个对象、不同的接口而不是四个独立的东西。// 一个 COM 实例多套接口全部从同一个对象上转出来 var te new TerraExplorerX.TerraExplorer(); var project (IProject86)te; // 工程打开/关闭/保存 var navigate (INavigate66)te; // 导航飞行/取当前位置 var creator (ICreator66)te; // 创建点/线/面/模型 var coordSys (ICoordSys66)te; // 坐标经纬度与本地坐标互转 var terrain (ITerrain66)te; // 地形取地面高度 var infoTree (IInformationTree66)te;// 信息树图层树显隐与操作接口名后面那串数字是版本号后缀56 对应 5.666 对应 6.676、86 依次类推。同一套逻辑在不同大版本之间方法名和参数顺序可能差一两个这不是你写错了而是 interop 跟着组件版本变了。我一般的做法是先把版本对应的接口号在一个地方统一定义成别名业务代码只用别名将来换版本只改一处。另外一个隐含约束是这些接口背后是进程内的一个渲染引擎。所有对它的调用默认都该在 UI 线程上发生别丢到Task.Run里去。后面讲事件回调时会看到这条约束决定了你怎么把回调数据送回界面。3. 跑通第一帧环境准备与宿主窗体的最小闭环示例代码里最值得先跑通的就是“宿主窗体”这一条线因为它一次性把环境、位数、嵌入、加载、释放五个问题全暴露出来。跑通了它后面所有功能都只是在这上面加调用。3.1 装什么、注册什么、引什么前置条件其实不多但要一一对上本机装好 TE 客户端运行时示例包依赖它注册的 COM 组件和一堆 DLL。只装运行库也够跑宿主但要打开别人做的工程最好装同版本的完整客户端。确认组件已注册。注册表里能查到对应的 ProgID才说明new TerraExplorer()不会抛异常。在 Visual Studio 里加 COM 引用或者直接引用安装目录下的 interop 程序集生成TerraExplorerX这个命名空间。把工程的目标平台从 AnyCPU 改成 x86。第 4 条是血泪经验。TE 的 COM 组件长期是 32 位的如果你的进程跑在 64 位下new那一步就会直接抛0x80040154类未注册。报错信息说的是“类未注册”实际原因却是位数不匹配很容易被带偏。改法就一行项目属性 → 生成 → 平台目标选 x86别用 AnyCPU。!-- csproj 里把平台钉死避免同事拉下来又改回 AnyCPU -- PropertyGroup PlatformTargetx86/PlatformTarget Prefer32Bitfalse/Prefer32Bit /PropertyGroupPrefer32Bit保持 false 是为了让 x86 的语义明确不要出现“看着是 x86、跑起来还是 64 位宿主进程”的玄学情况。3.2 把三维窗口塞进 WinForms 面板嵌入的原理只有一句TE 在创建时会生成一个自己的窗口你把它的父窗口改成你的 Panel它就落在你的界面里了。using System; using System.Windows.Forms; using System.Runtime.InteropServices; using TerraExplorerX; public partial class MainForm : Form { private TerraExplorer te; // COM 聚合对象全进程只留一个 [DllImport(user32.dll, SetLastError true)] private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); [DllImport(user32.dll)] private static extern bool MoveWindow(IntPtr hWnd, int x, int y, int width, int height, bool repaint); private void InitViewer() { te new TerraExplorer(); // 起渲染引擎 var viewer (ITerraExplorer56)te; IntPtr child (IntPtr)viewer.GetWindowHandle(); // 拿到它自己的窗口句柄 SetParent(child, panelViewer.Handle); // 改父窗口落进面板 ResizeViewer(); // 尺寸对齐 } private void ResizeViewer() { var viewer (ITerraExplorer56)te; IntPtr child (IntPtr)viewer.GetWindowHandle(); MoveWindow(child, 0, 0, panelViewer.Width, panelViewer.Height, true); } // 面板尺寸变化时同步否则会出现黑边或画面被裁 private void panelViewer_Resize(object sender, EventArgs e) ResizeViewer(); }这段代码有三个要点。new TerraExplorer()会启动渲染引擎并创建窗口这一步耗时明显别放在窗体构造里放到Load里更稳。GetWindowHandle()返回的是原生句柄转成IntPtr才能给SetParent用。MoveWindow必须跟着 Panel 的尺寸走否则窗口只在首次嵌入时正确用户一拉大窗体三维画面就停在小尺寸上看起来像“卡住了”其实只是没人通知它改大小。3.3 打开工程与退出时的释放顺序打开工程和关闭工程是这套代码里顺序性最强的一段。顺序错了轻则退出时报“COM 对象与其基础 RCW 分开后就不能再使用”重则进程残留任务管理器里挂着一个不退的进程。private IProject86 project; private void OpenProject(string flyPath) { project (IProject86)te; // 第二个参数是工程密码没有就传空串 project.Open(flyPath, ); } private void CloseProject() { if (project ! null) { project.Close(); // 1. 先关工程 Marshal.ReleaseComObject(project); // 2. 释放接口引用 project null; } if (te ! null) { Marshal.ReleaseComObject(te); // 3. 最后放主对象 te null; } GC.Collect(); // 4. 促一把回收 GC.WaitForPendingFinalizers(); }为什么是这个顺序te是所有接口的源头project是从它上面转出来的视图。先放源头再放视图运行时就会认为你还在用这个已经拆掉的对象。反过来先关工程、再放视图、最后放源头引用计数才是一路降到底。GC.Collect那两行不是迷信是给 COM 的终结器一个执行机会尤其在窗体关闭事件里调用时能明显减少残留。还有一条容易被漏掉如果挂了事件下一章会讲关闭前必须先解绑。事件不解绑等于对象还被你引用着引用计数降不下去进程自然退不掉。4. 核心模块拆解视点、对象、事件、坐标四条主线的实现4.1 视点飞行FlyTo 的参数与漫游节奏视点控制是示例里调用频率最高的功能。业务上的说法是“飞到某栋楼”“飞到某个点位”落到接口上就是一次FlyTo。var navigate (INavigate66)te; // FlyTo(高度参考方式, 经度, 纬度, 高度, 偏航角, 俯仰角, 飞行秒数) // 高度参考方式决定“高度”是相对地面还是绝对海拔 navigate.FlyTo(AltitudeTypeCode.ATC_TERRAIN_RELATIVE, 116.397, 39.908, // 目标经纬度 800, // 相对地面 800 米 0, // 偏航 0 度正北 -45, // 俯仰 -45 度向下看 3.0); // 3 秒飞到位参数里最容易被误用的是第四个和第七个。高度值配合高度参考方式才有意义用相对地面却填了海拔数值飞过去会发现镜头扎进地里或者飘在半空。飞行秒数填 0 是瞬移填太小会有明显的“甩镜头”感一般 24 秒观感比较自然。俯仰角为负是俯视为正是仰视很多人第一次填正数结果镜头对着天空。批量漫游不要用Thread.Sleep串起来那样界面会僵住。常见做法是用一个Timer按固定间隔推进到下一个点或者监听渲染帧事件等上一次飞行结束后再发下一次。这个细节决定了漫游看起来是顺滑的还是抽搐的。取当前位置反而是同步的double x, y, height, yaw, pitch; navigate.GetPosition(out x, out y, out height, out yaw, out pitch);注意这里拿出来的x、y是工程当前坐标系下的坐标不一定是经纬度。工程如果配的是平面坐标你得靠下一节的坐标转换接口再换一次。这是新手最容易踩的一个坑拿着平面坐标当经纬度去填FlyTo飞到一个完全无关的地方。4.2 对象创建与业务 ID 挂载三维标绘的价值不在于画出那个点而在于“画出来的东西能跟业务数据对上”。TE 的对象接口提供了一个专门挂业务数据的位置习惯上叫 ClientData。var creator (ICreator66)te; ITerraExplorerObject66 obj; // CreatePoint(经度, 纬度, 高度, 样式ID, 分组, 描述, 业务数据, 插入位置, out 对象) creator.CreatePoint(116.397, 39.908, 0, 0, // 点样式 ID0 用默认样式 monitor-group, // 分组方便按组批量删 监控点位A, // 鼠标悬停时显示的描述 P0001, // 业务主键关键就在这一位 -1, // 插入到信息树的末尾 out obj); // 之后可以按业务主键反查、改样式、隐藏 obj.Visible true;ClientData那一位填业务主键是整个二次开发里最划算的一个约定。因为三维对象本身只有内部 ID业务系统要的是自己的主键两边对不上后续所有交互都得靠坐标比对极其脆弱。把主键塞进ClientData之后无论是点击回调还是批量更新都能一步定位到业务实体。分组字段也值得用起来按业务类型分组删除时整组清掉比遍历删除快得多也不容易漏。创建模型走的是另一个方法传的是模型文件路径而不是经纬度creator.Create3DObject(D:\GisData\models\tower.xpl, 116.397, 39.908, 0, 0, tower-group, 信号塔, T0007, -1, out obj);模型格式和贴图路径的兼容性这里有个隐形坑从别的机器拷过来的模型贴图经常丢表现为模型一片白。这类问题不是代码问题检查模型的贴图引用是不是用的相对路径就行。4.3 事件回调与 Popup 消息中转没有事件的二次开发只能单向驱动用户点了什么你全不知道。TE 的鼠标事件挂在工程接口上点击之后拿到的还是屏幕坐标得再转成世界坐标。private void HookEvents() { project.OnLButtonClick Project_OnLButtonClick; } private void Project_OnLButtonClick(int screenX, int screenY) { // 屏幕坐标转经纬度方法名在不同版本里略有差异认准带 Screen 的那个 double lon, lat, alt; ((ITerraExplorer56)te).GetWorldPointFromScreen(screenX, screenY, out lon, out lat, out alt); // 回调发生在渲染线程侧改界面必须投递回 UI 线程 BeginInvoke(new Action(() { lblCoord.Text ${lon:F6}, {lat:F6}, {alt:F1}; })); }两点必须强调。第一屏幕坐标转世界坐标这个方法5.x 和 6.x 的命名不一样别硬背在对象浏览器里搜带 Screen 的方法最快。第二回调里直接操作控件很容易翻车——看起来能跑偶尔卡死而且极难复现。统一用BeginInvoke投回主线程这条规矩越早定下来越好。宿主和插件之间的通信常见做法是走自定义消息通道两边约定好消息类型和字符串载荷格式// 插件侧往宿主发一条消息类型码自己约定载荷建议用 JSON var msg (ITerraExplorerMessage66)te; msg.AddMessage(1001, {\cmd\:\locate\,\id\:\P0001\}); // 宿主侧接收并解析 ((ITerraExplorerMessage66)te).OnMessage (type, payload) { if (type 1001) { // 解析 payload定位到对应业务对象 } };消息载荷用 JSON 而不是拼字符串看起来多写几行实际上省下了后面所有的解析兼容工作。4.4 坐标转换与拾取高度坐标系是三维项目里最不显眼、出错最难查的一块。工程可能配的是地理坐标也可能是某个平面坐标系而业务系统给的往往是经纬度。var coordSys (ICoordSys66)te; // 经纬度 - 工程本地坐标 double x, y; coordSys.WGS84ToLocal(116.397, 39.908, out x, out y); // 工程本地坐标 - 经纬度 double lon, lat; coordSys.LocalToWGS84(x, y, out lon, out lat); // 取某个位置的地面高度用于把对象贴地 var terrain (ITerrain66)te; double groundHeight; terrain.GetGroundHeight(lon, lat, out groundHeight);GetGroundHeight的返回值依赖地形数据是否已经加载完。工程刚打开时立刻调用拿到的可能是 0 或者上一次的缓存值。稳妥的做法是等渲染事件跑过一两帧之后再取或者对结果为 0 的情况做一次兜底。5. 避坑与排查TE 二次开发里最耗时间的五种现场5.1 现场一类未注册报 0x80040154现象代码编译通过运行时在new TerraExplorer()抛异常提示类未注册。原因九成是位数不匹配。TE 的 COM 组件是 32 位的而你的进程在 AnyCPU 下默认跑成了 64 位。少数情况是真的没装运行库注册表里查不到 ProgID。解决先把平台目标改成 x86 再跑一次这一步能解决绝大多数同类报错。改完还不行就去注册表确认组件是否注册再确认安装的是不是同一大版本。别一上来就怀疑代码这个错跟代码基本无关。5.2 现场二三维画面浮在面板上拖动主窗体时原地不动现象初始化时画面位置看着是对的但主窗体一移动三维画面就脱离面板留在屏幕原位。原因只调了一次SetParent没有处理后续的窗口位置同步。TE 渲染窗口有自己的一套位置管理逻辑父窗口移动不会自动传导过去。解决在播放器面板的Resize和窗体的Move事件里都调一次MoveWindow把位置和尺寸重新对齐。如果画面还有黑边检查是不是把SetParent的目标设成了窗体本身而不是面板挂错父窗口时布局系统管不到它。5.3 现场三退出后进程残留或报 RCW 分离错误现象关闭窗体后任务管理器里还挂着一个进程或者反过来退出时抛“COM 对象与其基础 RCW 分开后就不能再使用”。原因释放顺序错了或者挂了事件没解绑。前者是对象依赖关系被倒着拆后者是引用计数始终降不到零。解决按“解绑事件 → 关工程 → 放接口引用 → 放主对象 → 促回收”这个顺序走。事件的解绑容易被忘写释放逻辑时把它当成第一步固定下来之后就不用每次想。5.4 现场四工程换台机器打开就空白现象同样的.fly文件在开发机上一切正常拷到另一台机器打开图层树在但画面空或者直接报加载失败。原因工程里引用的数据源地址是绝对路径或固定服务地址换机器后路径不存在。另一种情况是两边的运行库版本不一致部分图层格式读不出来。解决把工程用到的数据源统一放到相对路径下工程和数据一起拷图层走服务的确认目标机器能访问到服务。版本上也别凑合开发机和部署机用同一个大版本能省掉大量“看起来是代码问题其实是环境问题”的排查。5.5 现场五点击回调里改界面偶发卡死现象点三维场景界面偶尔僵住几秒日志里没有异常重启后又正常。原因回调发生在渲染线程侧你在里面直接碰了 WinForms 控件。跨线程操作控件在某些时序下会死锁表现就是偶发卡顿而不是必崩所以特别难查。解决回调里只做数据提取所有界面更新一律BeginInvoke投回主线程。这条规矩建议写进团队的代码规范里比事后一个个查便宜得多。顺带一提FlyTo之后立刻GetPosition拿到的是旧值渲染是异步的想拿新位置就等一帧再读。6. 二次开发改造把散落的调用收进一层服务门面示例代码为了把接口讲清楚通常会在窗体里直接(INavigate66)te这么转来转去。真做项目时这么写会有两个后果窗体代码越滚越大以及换版本时满项目找56、66这些后缀。我的做法是先抽一层接口业务只认这层。public interface ISceneService { void OpenProject(string flyPath); void FlyToPoint(double lon, double lat, double height, double seconds); string AddMarker(double lon, double lat, string title, string bizId); void Close(); }实现类内部把IProject86、INavigate66、ICreator66这些一次性转好存成字段对外只暴露经纬度和业务 ID。好处很实际换大版本时只改这一个类里的接口号单元测试时可以拿一个假实现顶替不用起渲染引擎就能测业务分支窗体那边只剩事件绑定和界面更新两件事读起来清爽。改造完拿一份清单回归一遍比凭感觉点几下靠谱验证项判据常见失败原因实例创建不抛 0x80040154平台目标不是 x86窗口嵌入拖动窗体画面跟随未在 Move/Resize 里同步位置工程加载图层树与画面一致数据源路径或版本不一致视点飞行目标点居中且高度合理高度参考方式与数值不匹配标绘回查按业务 ID 能定位到对象ClientData 未写入或写入被覆盖退出释放进程完全退出事件未解绑或释放顺序错还有一个小技巧值得单独说把ClientData的写入和读取封装成两个方法读的时候做一次空值校验。因为对象一旦被外部工程改动过业务数据可能被清空不校验的话反查会静默失败表现为“点了没反应”查起来非常费时间。从那以后我每接一个 TE 项目都先把平台目标锁成 x86、把释放顺序写进基类、把所有界面更新强制走BeginInvoke这三件事做完再动业务代码。吃过一次退出时进程不退的亏排查了两天才发现是事件忘了解绑现在这套流程已经成了肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询