简介:面向Skyline TerraExplorer二次开发者的C#示例代码包,适合具备C#与.NET基础、希望借助SDK构建三维地球与GIS应用的中级开发者参考。示例覆盖地图对象与控件调用、事件驱动响应、常见矢量数据集成、三维模型加载、空间查询与缓冲区分析、自定义界面以及网络服务接入等要点,并涉及图层管理、标注与视角控制等常见场景,可帮助读者理解API调用方式、工程组织与功能实现路径,也可作为SDK接口速查与二次封装样例。压缩包共21个文件,约42KB,以C#源码和资源文件为主,配有解决方案与项目配置,并附带Shapefile示例数据、飞行路径等辅助文件,便于打开工程对照调试。已有484人学习下载,适合作为TerraExplorer定制开发的入门参照与功能片段查阅。
1. 从“能转的地球”到“能用的客户端”:TerraExplorer 二次开发 C# 示例代码适合解决什么问题
前阵子接手一个 Skyline 体系的三维场景项目,甲方给的环境里只有一个能转地球的空壳程序,菜单全灰,业务数据一条没挂。真正的门槛从来不是 C# 语法,而是 TerraExplorer 那套 COM 对象模型长什么样、接口怎么串、坐标从哪一步开始偏。这份二次开发 C# 示例代码做的就是这件事:把加载工程、图层管理、三维标注、视角漫游、鼠标拾取这些常用动作拆成一个个能独立运行的 .cs 文件,改两个路径就能编译,跑通一个再看下一个。
它适合两类人:已经被三维客户端需求顶到脸上、需要一周内出原型的开发者;以及手上有个半成品、想先摸清对象模型再决定怎么分层的人。下面按工程骨架、核心模块、事件交互、问题排查、封装改造五段推一遍,每段都能对应到具体代码。
2. 工程骨架与环境准备:COM 引用、目标框架与第一个能跑起来的窗口
2.1 技术选型:.NET Framework、WinForms 与 COM 互操作
这类示例几乎清一色落在 .NET Framework 4.6 以上 + WinForms 的组合里,原因很实在:TerraExplorer 客户端暴露的是一组 ActiveX 控件和 COM 类型库,能把这套东西优雅放进设计器、还能直接拖拽布局的,只有 WinForms。换成 .NET 6/8 之后,ActiveX 控件宿主这条路基本断了,即便用 Windows 兼容包把 COM 调通,三维窗口那部分也得自己写宿主容器,示例代码里的设计器文件全部作废。
平台目标同样不能随便选。客户端装的是 32 位还是 64 位,你的进程就必须对齐,否则运行时会在创建控件的一瞬间抛REGDB_E_CLASSNOTREG或者干脆闪退,错误信息里还看不出是位数问题。我的习惯是先把示例工程的“生成 → 目标平台”固定成和本机客户端一致的那一档,再去动任何代码,后面省掉一堆玄学排查。
还有一点容易被忽略:COM 对象是有生命周期的,Marshal.ReleaseComObject该在窗体关闭时调,但别在每次操作后都调,否则控件内部持有的接口会被提前释放,表现是“刚还好好的,点第二下就报对象已断开连接”。
2.2 引用怎么加:从 COM 组件到 Interop 程序集
工程里那几个Interop.TerraExplorerLib.dll、AxInterop.xxxLib.dll不是手写的,是 Visual Studio 从注册表里的类型库生成的。正确姿势是右键引用 → 添加引用 → COM 选项卡,找到客户端注册的那组类型库勾上,让 IDE 生成互操作程序集;而不是网上随便下一个 dll 丢进 bin 目录,那种做法在换机器时必然翻车。
生成出来之后有两件事必须改:
- 把互操作程序集的“嵌入互操作类型(Embed Interop Types)”设为
False。.NET 4 默认是True,它会把 COM 类型内嵌成项目内的等价类型,跨程序集使用时类型对不上,强转会静默失败返回 null。 - 控件引用加进工具箱时优先用“选择项 → COM 组件”,而不是直接拷
AxInterop文件,工具箱里那一份带了设计期元数据,拖出来才会自动生成InitializeComponent里的宿主代码。
接口名普遍带版本后缀,像ITerraExplorer66、IProjectTree66、IObjectManager66这种形式,后面的数字随安装版本变化,别照抄,装完 SDK 后在对象浏览器里搜 TerraExplorer 就能看到本机实际是哪个。
2.3 最小可运行窗口:一个地球加一个按钮
示例里最短的一个文件,干的事就是初始化三个核心对象,然后加载一个飞览文件。这段是整个项目的地基,后面所有模块都是从这三个引用长出来的。
public partial class MainForm : Form { // 三个核心对象:应用入口、项目树、对象管理器 private ITerraExplorer66 te = null; private IProjectTree66 projectTree = null; private IObjectManager66 objectManager = null; public MainForm() { InitializeComponent(); } private void MainForm_Load(object sender, EventArgs e) { try { // 从三维窗口控件上拿应用入口,不同版本方法名略有差异 te = (ITerraExplorer66)axTE3DWindow.GetInterface(); // 项目树和对象管理器都从应用入口派生 projectTree = te.ProjectTree; objectManager = te.ObjectManager; // 加载工程文件,路径换成自己的 .fly te.Open(@"D:\data\scene.fly"); } catch (COMException ex) { // COM 层错误码比异常消息有用,先把它打到日志里 MessageBox.Show($"初始化失败,HRESULT=0x{ex.HResult:X8}"); } } }这段代码的关键在于初始化顺序。GetInterface()返回的是 object,必须显式转成强类型接口才能点出属性;ProjectTree和ObjectManager都依赖底层应用实例已经就绪,所以只能放在GetInterface()之后。Open()的参数在有版本差异时可能还带一个布尔开关控制是否走提示流程,编译不过就按智能提示补齐默认值,不影响主流程。
COMException一定要单独捕获。普通Exception拿不到HResult,而三维客户端的问题九成以上是靠这个十六进制错误码定位的,日志里没有它,后面只能靠猜。
2.4 示例代码的目录结构与运行方式
一份组织得比较清楚的示例包,目录大致是这样分工的:
| 目录 / 文件 | 作用 | 上手顺序 |
|---|---|---|
FormMain.cs | 主窗体,承载三维窗口与工具条 | 第一个看 |
Modules/ProjectTreeDemo.cs | 项目树增删改查、分组管理 | 第二个看 |
Modules/LabelDemo.cs | 三维标注、文字与图标 | 第三个看 |
Modules/FlyDemo.cs | 视点跳转、飞行路径 | 第四个看 |
Modules/PickDemo.cs | 鼠标事件与对象拾取 | 最后看 |
Config/app.config | 工程路径、初始视点 | 改这里 |
lib/ | 互操作程序集 | 别动 |
跑起来的步骤不复杂,但顺序别乱:先确认本机装了客户端并且能独立打开 .fly 文件,再打开解决方案把 COM 引用重新指一遍(换机器必然要重指),然后改app.config里的工程路径,最后 F5。第一次运行卡在“正在加载地形”是正常的,地形数据大的工程首次加载几十秒很常见,别急着以为死循环。
提示:示例代码里的路径基本都写死成作者本机目录,
app.config里那份是少数做了外置的,优先改配置文件而不是改代码,后面迁移成本低很多。
3. 核心模块拆解:从加载数据到图层管理的完整链路
3.1 加载工程与飞览文件:项目树的第一层
三维客户端的“工程”概念落在文件上就是一个 .fly,里面记录了地形数据库路径、图层引用、初始视点、以及一堆渲染参数。示例里加载分两步:先Open打开工程,再按名字取根节点,所有后续对象都挂在根节点下面。
// 打开工程,第二个参数控制是否复用已有窗口,按本机 SDK 签名补齐 te.Open(flyPath); // 拿到项目树根节点 ID,后面建分组、建对象都靠它定位 string rootId = projectTree.GetRootItemID(); // 按名字判断某个图层是否已经存在,避免重复加载 string existing = projectTree.GetItemIdByName("业务图层", 0); if (string.IsNullOrEmpty(existing)) { // 建一个分组,返回值就是新节点的 ID string groupId = projectTree.CreateGroup("业务图层", rootId); }GetRootItemID()返回的是字符串 ID 而不是对象引用,这是这套 API 的一贯风格——所有节点用 ID 寻址,接口只是操作入口。好处是跨模块传递方便,坏处是 ID 失效时不会抛异常,只会在下一次操作时返回空,所以每个GetItem后面都该跟一次判空。
CreateGroup的可选参数比较多,C# 里能省则省,编译报参数不足时再按提示补。分组名建议带业务前缀,比如“业务图层-告警点”,因为项目树是全局平铺的,命名撞车之后在客户端里排查会非常痛苦。
一个实际经验:不要在循环里反复调用Open去加载同一份工程。示例中有人图省事,切业务线的时候重新Open一次,结果每次都要重新解压地形缓存,切换延迟从两百毫秒涨到十几秒。正确做法是工程只开一次,业务数据用分组做隔离,切业务线时只切分组的可见性。
3.2 图层与对象管理:项目树的增删改查
项目树这套 API 的本质是一个树形容器,节点分两类:分组节点和对象节点。分组本身不渲染,只做组织;对象节点背后对应一个真实的几何体或标注。示例里封了几个方法,增删改查各一个,写得比较直白。
// 增:往指定分组里塞一个对象,groupId 来自上一步 public string AddObjectToGroup(string groupId, string name) { // 建对象时先传名字和父节点,返回对象 ID string objId = objectManager.CreateLabel(0, 0, 0, groupId); // 再用 ID 取回接口设置具体属性 ILabel66 label = (ILabel66)objectManager.GetObject(objId); label.Text = name; return objId; } // 删:从项目树移除,注意这只删节点不删几何体 public void RemoveNode(string objId) { if (projectTree.GetItem(objId) != null) { projectTree.DeleteItem(objId, 0); } }这里有个容易误解的点:DeleteItem删的是项目树节点,几何对象本身在某些版本里会跟着释放,某些版本里不会。判断方法是删完之后再用GetObject试一次,返回 null 才算真释放。如果发现内存一路涨,基本就是对象没回收干净,得改成先objectManager.DeleteObject(objId)再DeleteItem。
GetItem和GetObject返回值不同:前者拿节点信息(名字、类型、父节点),后者拿几何接口(位置、样式、可见性)。写代码时经常有人混用,编译期看不出来,运行时强转才炸。我一般在封装层里按用途拆成两个方法名,GetNodeInfo和GetGeometry,看见名字就不会转错。
图层可见性切换用projectTree.GetItem(objId).Visible = false这种写法最省事,比一个个对象去设渲染开关快得多。批量操作上百个节点时,先把项目树事件监听摘掉再批量改,改完再挂回去,否则每次改动都会触发一次重绘,肉眼可见地卡。
3.3 三维标注与动态对象:把业务数据画到地球上
业务落地最常用的就是标注和线面。标注分两类,贴地的和悬空的,贴地的会随地形起伏自动调整高度,悬空的要自己算高程。示例里两种都写了,区别只在创建方法。
// 贴地标注:不传高度,由地形决定 string id = objectManager.CreateTerrainLabel(0, "", groupId); ITerrainLabel66 label = (ITerrainLabel66)objectManager.GetObject(id); label.Text = "监测点 A"; // 位置用工程坐标系,不是经纬度,单位随工程设置走 label.Position = (IPosition66)new PositionClass();Position这一行的写法要看版本,有的版本是直接给label.Position.X/Y/Z赋值,有的是塞一个位置对象进去。我给的建议是先写前半段,编译报错就按智能提示改后半段,别硬背 API 形状,这类接口版本间改动不小。
动态对象(比如实时更新的轨迹线)有两条路:一是每次数据更新都删了重建,代码简单但闪烁明显;二是建一次然后改IPolyline66的顶点集合,性能好但要注意顶点数量变化时得整体重设而不是增量补。数据量在几百点以内,第一条路完全够用,别过度设计。
一个血泪经验:标注文本里别直接塞长段 HTML 或特殊字符,某些版本的文本渲染会在遇到换行符时整个标注消失,不报错,就是看不见。需要富文本就走信息窗体的 HTML 弹窗,那条链路是安全的。
3.4 相机与漫游:视点控制与飞行路径
视角控制这部分示例写得比较克制,只保留了三件事:跳到指定点、沿路径飞行、拿到当前视点。
// 直接跳视点,不带动画 te.SetCameraPos(x, y, z, 0, -45, 0, 0); // 沿飞行路径做动画,第二个参数是总时长(秒) te.FlyTo(flyPathId, 8.0);SetCameraPos这七个参数依次是位置三轴、航向、俯仰、翻滚、还有一档速度控制。翻滚角保持 0 就行,除非要做特技镜头。俯仰角是负值代表往下看,这个符号搞反了会出现“镜头朝上对着天空”的经典翻车画面。
飞行路径用FlyTo走的时候,回调事件是按帧触发的,别在里面做重活。示例里有人在飞行过程中每帧查一次数据库刷新标注,帧率从 60 掉到 8。正确做法是飞行结束的回调里统一刷一次,中间只需要更新进度条。
注意:视点跳转前建议先
projectTree.GetItem(...).Visible检查目标图层是否存在,跳到空区域的表现是镜头卡住不动,看起来像 API 没响应,实际上是指定了不存在的坐标。
4. 交互与事件:把 C# 的业务逻辑接到三维场景上
4.1 事件绑定:鼠标点击拾取与坐标反查
三维窗口控件会把鼠标事件抛给 WinForms,示例里挂了三个:左键点击、鼠标移动、帧渲染完成。左键点击最有用,用来做对象拾取。
// 在设计器属性面板里挂,或者构造函数里手动挂 axTE3DWindow.OnLButtonClicked += AxTE3DWindow_OnLButtonClicked; private void AxTE3DWindow_OnLButtonClicked(object sender, EventArgs e) { // 事件参数里带屏幕坐标,转成对象 ID 再查详细信息 var info = objectManager.GetObjectInfo(screenX, screenY); if (info == null) return; ShowDetailPanel(info.ID); }拾取这步在不同版本里 API 名字不完全一致,思路是一样的:拿屏幕坐标问对象管理器要 ID,要到了查详情,要不到就退一步用坐标反查地形高程,判断是不是点在了地表。这两条路都要写,只写第一条的话,用户点空地会以为程序卡了。
事件里做事要克制。控件的事件回调是在 UI 线程上同步执行的,耗时超过几十毫秒就能感觉到拖动卡顿。查数据库、发请求这类操作一律丢到Task.Run里,回来再Invoke更新 UI,别在回调里直接等。
还有一个隐蔽的坑:事件挂上之后要记得在窗体关闭时解绑,否则控件销毁顺序不确定时,回调里访问已释放的窗体组件会抛ObjectDisposedException,而且堆栈看不出是哪一步。
4.2 信息窗体与 HTML 弹窗:详情面板的两种实现
详情展示有两条路。轻量的用信息窗体控件,塞纯文本或者简单 HTML;重一点的直接开一个 WinForms 面板,里面嵌 WebBrowser 或第三方浏览器控件。示例两种都给了,选择标准很简单:内容固定、交互少就走信息窗体,需要图表、需要调前端组件就走独立面板。
// 信息窗体:位置跟着三维点走,内容支持有限 HTML axTEInformationWindow.Show(htmlContent, x, y, 400, 300);Show的坐标是屏幕坐标不是地理坐标,所以必须先做一次投影转换。很多人在这里直接把经纬度塞进去,表现是弹窗跑到屏幕外面,还以为控件坏了。
内容里的资源路径要注意,信息窗体的 HTML 是从临时目录加载的,相对路径的图片和脚本会 404。稳妥做法是全用绝对路径,或者干脆把样式内联进去。示例里那份template.html就是按内联样式写的,直接改文字就行,别指望它去加载外部 js。
4.3 与 WinForms 工具条协同:状态同步的正确姿势
三维客户端一般需要在右侧或顶部挂一个 WinForms 工具条,做图层勾选、搜索、视图切换。示例里用了一个CheckedListBox绑定图层列表,勾选状态和项目树可见性双向同步。
同步的关键是加一个“正在同步”的布尔开关。用户勾选触发ItemCheck事件,代码去改项目树;项目树变化又触发自己的事件,回头再改勾选框,来回弹两次,界面上就会出现勾选闪烁。加个_syncing标志位,进事件第一行判断,能省掉这类小毛病。
另一个细节是勾选框的显示名和项目树节点名要解耦。项目树里为了唯一性常带前缀,界面上给用户看的必须是干净名字,示例里用了一个Dictionary<string,string>做映射,ID 做 key,显示名做 value,改起来很省事。
5. 排查清单:COM 引用、线程与坐标这三类问题
5.1 引用与版本错位:换台机器就编译不过
现象:本地能跑,拷到同事机器上编译报“类型或命名空间不存在”,或者能编译但运行到初始化就抛InvalidCastException。
原因:互操作程序集是从本机注册表生成的,两台机器装的客户端版本不同,接口 GUID 和成员签名都对不上;另一种情况是“嵌入互操作类型”没关,编译器内嵌了一份类型,和运行时加载的那份冲突。
解决:删掉obj、bin和所有Interop.*.dll,在目标机器上重新添加 COM 引用,重新生成;确认每个互操作引用的“嵌入互操作类型”都是False;把生成的 dll 随项目一起提交,而不是指望每台机器现生成。
5.2 线程与消息泵:后台线程一碰控件就炸
现象:在Task.Run里调用te.ProjectTree抛InvalidCastException或直接进程退出,没有任何有用堆栈。
原因:ActiveX 控件是 STA 模型,创建它的线程之外访问就是未定义行为。COM 层不会给你友好提示,表现出来就是各种莫名其妙的转换失败。
解决:所有对te、projectTree、objectManager的调用都收敛到 UI 线程,后台线程只负责取数据,回到 UI 线程再操作场景。窗体构造函数里加一句[STAThread]检查,别把主入口的Main改成 MTA。
5.3 坐标与高程:标注总是浮在半空或埋进地里
现象:标注位置整体偏移几百米,或者悬空飘着,地形改了之后更明显。
原因:工程坐标系和经纬度混用了。工程配置里如果是投影坐标系,那Position的三轴单位是米,直接塞经纬度会跑到地球另一端;高程取自地形接口的瞬时值,地形数据没加载完时返回 0,看起来就是埋进地里。
解决:统一走坐标系接口做转换,不要手写换算公式;高程取值前先判断地形是否加载完成,没完成就延后到OnFrame里再取一次;调试时把转换前后的坐标都打到日志里,对一次就知道哪步错了。
5.4 部署与运行环境:开发机好好的,交付就白屏
现象:客户现场部署后三维窗口一片白,或者只显示网格没有地形。
原因:客户端没装、装了但位数不匹配、地形数据库路径在配置文件里还是开发机的绝对路径、或者运行账户对数据目录没有读权限。
解决:交付前跑一遍清单——客户端版本和位数、配置文件里的路径改成相对路径或安装时替换、数据目录给Users组读权限、日志目录可写。这四项占现场问题的大头。
注意:现场排查时优先看 Windows 事件查看器里的应用程序日志,COM 组件的注册失败会在这里留下带 CLSID 的记录,比在代码里加一堆 try-catch 有用得多。
6. 二次开发改造:把示例拆成能复用的业务组件
示例代码最大的问题不是写得不好,而是所有调用都散在窗体里,业务一多就变成两千行的MainForm.cs。我的改法是先抽一层门面,把ITerraExplorer66、IProjectTree66、IObjectManager66这三个引用关在一个类里,外部只跟业务语义打交道。
public interface ISceneService { void LoadProject(string flyPath); string AddMarker(double x, double y, string title); void FocusOn(string objectId); void SetLayerVisible(string groupName, bool visible); } public class SceneService : ISceneService { private readonly ITerraExplorer66 _te; private readonly IProjectTree66 _tree; private readonly IObjectManager66 _obj; // 构造时统一初始化,失败直接抛,别让半初始化状态流出去 public SceneService(ITerraExplorer66 te) { _te = te; _tree = te.ProjectTree; _obj = te.ObjectManager; } public string AddMarker(double x, double y, string title) { var id = _obj.CreateTerrainLabel(0, "", CurrentGroupId); var label = (ITerrainLabel66)_obj.GetObject(id); label.Text = title; return id; } }抽完这层之后,窗体里只剩事件转发和 UI 状态,替换三维引擎或者做单元测试都不会被 COM 卡死。第二件事是把工程路径、初始视点、图层白名单挪进配置文件,现场调参不用重新编译。第三件事是给每个对外方法加一次幂等判断——同名图层已存在就复用,不重建,这一条挡掉了大半重复加载的投诉。
验证方式我一般按三步走:先用示例自带的 .fly 跑通全部按钮,这一步验证环境;再换成客户的真实工程,只开一个业务图层,看加载耗时和内存占用;最后用脚本模拟连续点击两百次标注刷新,观察句柄数是否稳定。第三步最能暴露回收问题,句柄一路涨就是对象没释放。
从那以后我每次接这类三维客户端改造,都强制先抽出服务层再动业务代码,哪怕只有三个方法也照抽不误,因为后面一定会加第十个。希望帮到你。
本文还有配套的精品资源,点击获取