简介TerraExplorer是基于Skyline平台的三维地球可视化软件广泛应用于GIS与虚拟现实等场景。这份C#二次开发示例代码面向需要掌握TerraExplorer API的GIS开发人员通过完整的Visual Studio解决方案演示了SDK调用、事件驱动编程、地图对象控制与空间数据集成等关键开发环节。资源包共21个文件以C#源程序和VS工程文件sln、csproj为主体另含Shapefile矢量数据shp、shx、dbf、prj、qix、fly三维工程文件、resx资源文件与项目配置文件压缩包整体仅42KB结构紧凑、目录清晰便于快速查阅学习。示例覆盖3D模型加载、空间查询分析、自定义界面、网络服务调用等功能的代码实现开发者可对照实际工程理解二次开发流程与API调用方式学习如何在C#中创建地图控件、加载数据图层、响应交互事件、进行空间缓冲区分析等为构建基于Skyline平台的三维GIS应用提供直接可用的参考范例。目前已有483人学习下载。1. TerraExplorer二次开发C#示例代码先看清这个三维GIS黑匣子的入口当你第一次拿到TerraExplorer的SDK并四处找C#示例代码时大概率会看到两个极端一端是官方SDK里包装好的各种interface另一端是论坛里贴的几句“玄学”调用。TerraExplorer的二次开发不是从写第一行代码开始的而是先要理解C#能驱动它的那扇门——COM自动化对象。下面这段路会从“最小可跑的控制台程序”入手逐步补上加载数据、事件交互和进程/线程生命周期这几块最后给出一套能放进正式项目的Helper封装。适合已经会C#和WinForms、但第一次接三维GIS场景或者被示例代码反复“翻车”的开发者。2. C#为什么能驱动TerraExplorerCOM互操作基础与第一个Hello场景2.1 TerraExplorer二次开发C#示例代码绕不开的COM互操作本质TerraExplorer从很早就提供了一套基于COM的自动化接口C#开发者在Visual Studio里通过添加COM引用或ActiveX控件的方式接入。它不是一个.NET原生库所以C#调用时实际上在走COM互操作层项目生成的Interop程序集把COM接口转成C#可识别的接口底层由运行时创建RCWRuntime Callable Wrapper来管理对象引用。理解这个本质重要在哪它决定了三件直接影响编写示例代码的事。第一线程模型COM对象一般要求在STASingle-Threaded Apartment里创建尤其在WinForms里主线程本来就是STA但如果你在后台Task里构造SGWorld很可能等到对象返回后一切正常数据加载却没有任何反应。第二生命周期COM对象不是纯托管对象C#的垃圾回收无法感知它的原生资源如果你只是在一个方法里局部使用而没持有根引用后面再访问某个属性可能抛出0x80010108这类让人摸不着头脑的COMException。第三类型命名不同大版本的SDK接口名和命名空间可能有变化但对象模型的主线通常是围绕SGWorld入口展开的。所以我一般会先不急着写业务代码先建一个最小控制台程序确认TerraExplorer能被C#创建起来、能加载场景。这一步跑通了后面的数据加载和事件交互才有基础。下面就从这一步开始。2.2 最小Hello场景用SGWorld加载一个自定义工程第一步新建一个C#控制台项目项目名随便但建议把目标平台从AnyCPU改成x86。原因是TerraExplorer的COM组件在很多安装环境下是32位的AnyCPU在64位进程里加载时会直接拒绝你连代码都不用看。第二步在项目右键“添加”→“COM引用”在列表里找到TerraExplorer相关的类型库勾上。如果列表里没有说明TerraExplorer没装好或者你需要到其安装目录手动浏览dll。接着写下面的代码using System; using System.Threading; namespace TerraExplorerDemo { class Program { [STAThread] static void Main(string[] args) { // 使用 STA 线程模型创建 COM 对象 TerraExplorerX.SGWorld sgWorld new TerraExplorerX.SGWorld(); // 加载场景文件Load 的参数一般是服务器或本地的 .terra 工程文件 sgWorld.Load(D:\Working\example_scene.terra); Console.WriteLine(场景已打开按回车退出); Console.ReadLine(); } } }这段代码的逻辑并不复杂但有三处要解释清楚。[STAThread]标注在第一行它让Main线程声明为STA这是TerraExplorer这类COM控件能正常工作的前提很多示例代码没有这行运行时就只能看到控制台而看不到三维窗口。TerraExplorerX.SGWorld是COM类型库生成的CoClass你可以直接new但前提是你引用的互操作程序集确实生成这个命名空间如果你的SDK版本生成的是TerraExplorerXLib或者接口名不同请先在对象浏览器里搜SGWorld搜不到就说明该版本主入口换了名字把类名替换掉即可。Load是SGWorld提供的加载场景方法参数用绝对路径避免相对路径受当前工作目录影响。2.3 用WinForms和ActiveX控件做一个有窗体的三维视图控制台程序适合验证API但实际业务系统通常要把三维可视化嵌到自己界面的某个面板里。TerraExplorer提供了ActiveX控件在WinForms工具箱里右键“选择项”勾选TerraExplorer相关控件VS会自动生成一个AxTE3DWindow或类似名字的包装类。把它拖到窗体上然后配合一个按钮加载场景private void btnLoad_Click(object sender, EventArgs e) { // 控件拖入窗体后AxTE3DWindow 已经是被托管的 ActiveX 包装 axTE3DWindow.Load(D:\Working\example_scene.terra); // 设置初始视野这里的参数顺序和 SGWorld.Navigate 一样 axTE3DWindow.Navigate.SetPosition(114.123, 30.456, 2000.0, 0.0, -60.0, 0.0); }这段代码有几个值得注意的地方axTE3DWindow.Load和之前SGWorld的Load作用相同但因为是控件实例它直接渲染在当前窗体上不需要额外弹窗Navigate属性从控件上暴露出来调用方式和SGWorld保持一致。如果你发现axTE3DWindow下面没有Navigate大概率是这个版本把导航能力放到了另一个接口里比如axTE3DWindow.Scene.Navigate。这个问题不用死记按下F12或打开对象浏览器顺着属性找就行。还有一点要提醒ActiveX控件在窗体上创建时WinForms的设计器会帮你设置好线程模型但如果你在Load事件里异步创建窗体或者在BackgroundWorker里调用控件方法依然会出问题。所以控件嵌入后对控件的所有操作都尽量放在UI线程的事件处理器里后台线程最多做数据准备。2.4 第一个示例代码常见的三个“静默失败”点按上面的代码跑起来后最常见的翻车表现是控制台输出正常但TerraExplorer窗口不出现或者出现白屏。第一个原因是线程模型问题检查是否漏了[STAThread]如果调试器里看到OleInitialize失败多半就是线程问题。第二个原因是场景文件路径不存在或格式不被当前SDK支持TerraExplorer的Load对.terra工程、.tif影像和地形文件处理方式不一样先用File.Exists检查路径。第三个原因是LICENSE问题很多TerraExplorer示例代码本身可以编译但在没有授权模块的开发机上只能加载部分数据类型白屏时先看TerraExplorer自带日志。如果你用的是WinForms嵌入方式还有一个常见现象设计器里控件显示正常运行起来却是黑色或空白。这通常不是因为Load失败而是因为窗口句柄在控件真正的三维渲染表面创建之前就被读取了。解决办法是把Load动作放到Form.Shown事件里而不是Form.Load事件里给ActiveX控件留出完成初始化的时间。这一点在旧版本SDK上尤其明显。3. 加载数据与场景定位C#操作TerraExplorer的三种常见代码骨架3.1 加载本地地形和影像从Load方法开始第2章的最小程序已经能打开场景但实际业务里不可能只有一个固定的.terra。更多时候你手头有一堆.terra地形、.tif影像或者.3dml模型要在运行时把它们加载进当前场景。这时候用SGWorld的Load方法依然够用只要注意数据类型。dynamic sg new TerraExplorerX.SGWorld(); // 加载一个地形缓存文件 bool ok sg.Load(D:\Data\terrain.terra); if (!ok) Console.WriteLine(地形加载失败); // 叠加一张影像缓存部分版本把影像导入称为 AddLayer 而不是 Load sg.Load(D:\Data\dom.tif); // 加载一个矢量三维模型 sg.Load(D:\Data\buildings.3dml);这段代码里的dynamic是我故意用的。TerraExplorer的COM接口在C#里虽然可以强类型访问但不同版本的接口名称容易变用dynamic可以让代码先跑通再验证API名而且后续封装时可以把这些松散调用集中在一个门面里。Load的返回值并不是所有版本都是bool所以bool ok sg.Load(...)并不是永远安全更稳妥的做法是先假设返回void等实际SDK返回后再调整。如果你担心dynamic丢失编译期检查可以在确认API稳定后把这一层的dynamic调用替换成具体接口类型。这里有两个参数容易踩坑。一是文件路径里的中文目录某些旧版本TerraExplorer内部使用ANSI解析路径中文目录会导致Load明明返回true却什么都没显示所以推荐把数据盘路径保持为纯英文。二是格式问题Load加载影像时如果遇到不带投影信息的tif它可能无法把影像放到正确经纬度而是堆在地球原点附近这通常要在数据生产阶段把坐标信息写入tif或者用TerraExplorer的工具先做预处理。3.2 用ProjectTree管理图层和分组业务数据一多就不能每次都把文件直接Load到底层。TerraExplorer的图层树ProjectTree是场景组织的核心你可以通过它创建分组、把图层归入属于自己的组再统一控制显隐。下面的代码演示了如何拿到图层树、创建一个分组并把一个图层挂进去dynamic sg new TerraExplorerX.SGWorld(); // 获取图层树根节点 dynamic root sg.ProjectTree.Root; // 创建一个分组第二个参数在不同版本可能从整数类型变成枚举类型 dynamic group root.CreateGroup(Guid.NewGuid().ToString(), 我的业务图层); // 把本地文件加载到该分组下 dynamic layer group.LoadLayer(D:\Data\roads.shp); layer.Visible true;在真正的SDK文档里CreateGroup或InsertGroup的签名可能和我上面写的有出入有的版本要求第一个参数传GroupID第二个传名称有的版本则直接在Node上提供AddChild方法。我在这里想表达的是一个固定套路先到ProjectTree再基于节点创建子节点最后把数据文件交给节点加载。只要你按这个套路去对象浏览器里找肯定能找到对应API。这一节的难点不在于把API背下来而在于理解图层树是一棵有父子关系的树不要把所有图层都扔到根节点下否则后面做显隐控制、权限隔离时会非常痛苦。在业务系统里我还会给分组的名称加上能反查业务单元的前缀比如MODU_A_001。这样即使以后场景文件被用户在TerraExplorer客户端里手工改过程序依然能靠名称前缀把图层归位。不要用GUID作为显示名称用户看不懂调试时也难认。3.3 场景定位把相机飞到目标坐标加载完数据用户第一眼看到的通常不是目标区域而是整个地球。因此二次开发里最常用的一类代码就是视角定位。TerraExplorer的导航接口在Navigate对象下一般提供SetPosition方法或Position属性。我习惯用前者因为它一次把经纬度、高度、俯仰角都设置干净。dynamic sg new TerraExplorerX.SGWorld(); // 传参顺序经度、纬度、高度、航向角、俯仰角、翻滚角 sg.Navigate.SetPosition(114.123, 30.456, 1200.0, 0.0, -60.0, 0.0); // 定位后读取当前相机位置验证是否设置成功 var current sg.Navigate.Position; Console.WriteLine(当前高度 current.Altitude);这段代码的注释已经说明了参数顺序但实际使用时还要注意两件事。第一高度单位是米指相机相对水平面的高度如果你加载的坐标系是当地独立坐标系经纬度可能不是WGS84SetPosition的经纬度维度也要随之调整否则相机会飞到海上去。第二俯仰角为负值表示俯视0度表示平视业务中做漫游一般设置在-45到-90度之间。航向角0度指向正北顺时针为正如果发现飞过去后看到的不是目标侧面先检查航向角而不是数据位置。TerraExplorer的导航参数在不同版本还有ViewingDistance、Fov等概念在写示例代码时不必一开始全部设置先把SetPosition用起来等性能调优阶段再去操作视角范围。判断视角定位是否成功可以用sg.Navigate.Position读回当前值和设置值比较。注意读回的Position里可能带出SDK默认的四元数你会看到很多额外字段不要因此怀疑代码只要经纬度和高度在误差范围内就行。3.4 三种代码骨架怎么选现在可以总结一下TerraExplorer二次开发C#示例代码里加载数据的骨架基本有三种对应三个不同诉求。第一种是“直接Load”适合应急验证代码量最少但图层树关系不清晰。第二种是“ProjectTree分组加载”适合业务系统把数据组织权和显隐控制交给图层树。第三种是“通过Creator创建空对象再灌入数据”适合你需要完全控制对象属性的场景例如把数据库里的点位变成三维标注。我一般会把第二种作为主骨架因为后续做查询、联动、控制显隐都依赖图层树节点。第三种在业务里使用时要小心空的Group插入到场景后必须马上填充数据否则用户会看到空的图层出现又消失这种体验在演示时会显得很不专业。数据量超过一定规模后也不要一个要素一个要素去创建对象应该先把数据整理成文件或数据源再用批量加载方式一次进场景。4. 对象交互与事件回调让C#示例代码响应鼠标拾取4.1 挂接鼠标事件从窗口通知到C#委托只做到加载和飞行的三维GIS本质上还像一个“看图器”。要让场景真正接进业务最起码要支持鼠标点选对象——用户点一个房子或一根管线系统能告诉你这个对象的ID和属性。TerraExplorer的COM接口把鼠标通知暴露为事件C#里可以通过委托订阅。dynamic sg new TerraExplorerX.SGWorld(); // 订阅鼠标左键按下事件事件名以你的SDK生成的接口为准 sg.OnLButtonDown (int flags, int x, int y) { // 把屏幕坐标转换成语义拾取请求 dynamic obj sg.ObjectManager.QueryObject(x, y); if (obj ! null) { Console.WriteLine(拾取到对象ID: obj.ID); Console.WriteLine(对象名称: obj.Name); } };这段代码里OnLButtonDown是TerraExplorer窗口向外部发送的COM事件事件签名一般包含鼠标状态和屏幕坐标。ObjectManager则负责把屏幕坐标变成场景对象这是三维GIS里最核心的“命中查询”过程。需要注意事件名在不同版本很容易有差异比如有的是OnLButtonDown有的是OnMouseClick还有的版本要求你先实现一个事件接收类而不是直接加委托。好在C#里订阅COM事件和订阅普通事件语法一致你唯一要做的是在xxx_Event接口上找名单把方法名替换掉。在实际项目里鼠标拾取会有连续触发的问题用户按住左键拖拽视角也会触发OnLButtonDown业务上却把这次拖拽误当成点击。我的处理办法是用一个简单的时间戳过滤两次事件间隔小于300毫秒并且鼠标坐标变化小于5个像素才认为是有效点击。这个过滤逻辑和TerraExplorer本身无关属于交互设计层面的基本功。4.2 拾取回调线程引发的UI更新“翻车”第一次把上面的代码放进WinForms里很多开发者会直接在回调里写this.label.Text obj.Name;然后程序就卡死了。这不是TerraExplorer的问题而是COM事件回调线程与UI线程的经典死锁回调线程并不是UI线程你试图从后台线程同步访问UI控件UI线程又在处理消息循环等待COM结果两边互相等程序就像被冻住了一样。解决办法是不要在回调里直接操作UI把结果先放到一个队列或日志再用BeginInvoke切回UI线程// 在事件回调里收集数据 string pickedName obj.Name; // 用异步方式更新UI避免同步等待造成死锁 this.BeginInvoke((Action)(() { label1.Text 当前选中 pickedName; }));这里有一个细节值得记住BeginInvoke只负责把委托排队到UI线程它不会等待UI线程执行完所以不会反向阻塞回调线程。如果你确实需要回调结束后立刻拿到UI处理结果那就要重新设计交互流程而不是强行同步等待。在TerraExplorer这类COM应用里“事件回调里只收集数据、不碰UI”是基本卫生习惯。除了死锁还有另一个容易忽视的问题回调里拿到的坐标x、y是控件客户区坐标如果你要把这个坐标传给HTML或WPF等其他技术栈必须先做坐标换算。TerraExplorer很多接口用的坐标原点是窗口左上角而浏览器里的坐标原点可能不同直接混用会出现拾取偏移。4.3 用FeatureID和属性表把业务数据连起来点到了对象还不够业务系统真正想要的是关联到数据库里那一条记录。TerraExplorer里的矢量对象通常带有FeatureID这个ID是它和原始矢量属性表之间的桥。示例代码可以这样读属性dynamic feature obj.GetFeature(); string assetID feature[asset_id]; // 拿到业务ID后调用自己的业务服务查询详细信息 var assetInfo MyAssetService.GetByID(assetID); MessageBox.Show($资产编码{assetInfo.Code});这段代码里GetFeature()的具体名称在不同SDK版本中可能是GetAttributes或GetProperties但思路是一样的先取Feature对象再按字段名取属性值。属性字段名通常由数据源决定如果加载的是shapefile字段名就和dbf里的列名一致如果是3DML模型字段可能为空。我在实际调试时最常遇到的情况是拾取对象成功但feature[asset_id]取出来是null。那多半不是代码问题而是这个图层没有挂业务属性或者拾取到的是三维模型内部的子对象而不是顶层要素。如果你想在拾取时区分“顶层要素”和“模型子对象”可以检查一下返回对象的类型字段。TerraExplorer的对象模型里通常会有Type或Class属性先判断它是不是你关心的要素类型再往下取属性。否则你可能会看到同一个地理要素拆成了几十个三角形面业务层去重逻辑反而变得复杂。4.4 让拾取结果直接弹信息窗有些场景不需要自己做UI直接调用TerraExplorer自带的信息窗口会更省事。这个能力在SDK里一般叫InformationWindow它可以显示要素属性列表支持HTML内容。示例写法大致是dynamic infoWnd sg.InfoWindow; infoWnd.TopMost true; infoWnd.Width 320; infoWnd.NavigateTo(asset_id, 123456);InfoWindow的最终接口名在不同版本差异比较大有的版本叫PopupWindow有的版本需要通过WindowManager获取。我会把它当成一个可选项如果业务只要求快速查看属性用自带窗口能省一半开发量如果业务要求属性面板和主界面风格统一就一定不要用自带窗口自己做一个WinForms或WPF浮层嵌入到主界面的事件流里更可控。5. TerraExplorer二次开发C#避坑指南接口、线程和生命周期5.1 32位与64位进程不匹配启动即崩溃现象代码在开发机上编译通过双击运行后立刻弹BadImageFormatException或者TerraExplorer的窗口完全不出来。查看任务管理器进程是64位的而TerraExplorer的COM组件只注册了32位版本。原因COM组件的位数和宿主进程位数不一致时运行时加载不到本地DLL。解决把C#项目平台改成x86并且在x64机器上确认“首选32位”是打开的如果是AnyCPUCLR会偏向64位一样会踩坑。这个坑几乎每个刚上手的人都会遇到所以我把它放在避坑指南的第一条。5.2 事件回调里同步操作UI造成死锁现象单击场景里的对象WinForms界面卡住标题栏显示“无响应”过十几秒后恢复或直接退出。原因回调线程在等待UI线程释放控件访问权限UI线程又在等待COM事件处理完成返回形成相互等待。解决回调方法里一律不直接更新任何UI控件只把数据保存到成员变量然后用BeginInvoke异步切回UI线程。如果业务逻辑必须在UI线程里执行就在构造函数里持有一个SynchronizationContext用它来回调而不是直接Control.Invoke。5.3 SGWorld对象被GC提前释放最典型的“玄学”异常现象程序运行一段时间后原先正常的Load突然不触发或者某个属性访问时报COMException (0x80010108)而这个代码刚才还在跑。原因你用一个局部变量创建SGWorld方法结束后虽然COM对象还活着但C#侧的RCW被垃圾回收器认为不再使用底层代理被释放后续调用自然失败。解决把SGWorld实例保存在窗体或服务类的字段里形成强引用每次调用前可以检查if (_sgWorld null)。释放时要先取消事件订阅再置空字段让COM对象自己走Release流程不要在Dispose里试图调用Marshal.ReleaseComObject把外部窗口拖死。补充一点dynamic调用会放大这个问题的迷惑性。因为dynamic在每次调用时会重新做COM绑定如果对象的RCW已经释放错误信息往往出现在一个看起来完全不相关的属性访问上比如sg.Visible但真实原因却是你上一个局部作用域里的SGWorld被回收了。排查时先全局搜索new SGWorld检查对象是否都被某个类持有。5.4 含中文和空格的路径导致加载无显示现象代码完全照抄示例Load返回true但场景里就是没有图层。换到英文路径的同一份数据问题消失。原因部分TerraExplorer版本在处理路径时没有正确处理中文编码内部把路径交给原生模块后变成了乱码。解决数据路径统一使用纯英文文件命名不要带空格启动参数和配置项里的相对路径也不要依赖当前目录。遇到这种问题时可以写一个File.Exists的前置检查同时把路径用Path.GetFullPath规范化一次降低工作目录带来的不确定性。5.5 SDK版本升级后接口“搬家”现象老项目能编译换到新版本的TerraExplorer后SGWorld或ProjectTree直接找不到整个示例代码红了一半。原因TerraExplorer在较大版本升级时会重构对象模型例如把图层树移到新的ProjectTree接口下或者把创建对象的Creator合并进主入口。解决不要急着改散落在各处的调用先用对象浏览器打开新SDK生成的互操作程序集搜索SGWorld、Navigate、ProjectTree、Creator这几个关键词确认新的层级关系然后基于第6章的封装思路做一层适配。只要你把和TerraExplorer相关的调用集中在一个类里升级时只需要改这个类的内部实现。6. 把示例代码封装成Helper类工程化落地的验证技巧前面所有的示例代码都建立在直接调用SGWorld的基础上这在Demo和验证阶段没问题但到了正式工程里就太散了。我建议把所有TerraExplorer相关操作收敛到一个TerraExplorerHelper类里外接业务代码不直接接触COM对象这样既方便做单元测试替身也降低版本升级时的维护成本。public sealed class TerraExplorerHelper : IDisposable { private dynamic _sgWorld; public TerraExplorerHelper() { _sgWorld new TerraExplorerX.SGWorld(); } public void LoadScene(string path) _sgWorld.Load(path); public void FlyTo(double lon, double lat, double height) { _sgWorld.Navigate.SetPosition(lon, lat, height, 0, -60, 0); } public void Dispose() { _sgWorld null; GC.Collect(); } }这个Helper类看起来简单但已经包含了避坑指南里说的两个关键点SGWorld用实例字段持有强引用避免被GC提前释放Dispose里通过置空和GC.Collect尽快回收COM代理。在实际业务中你还需要补一个“是否已初始化”的判断以及在所有公开方法入口处把COM异常包装成业务异常避免COMException直接抛到界面层。封装好之后的验证我通常会写一个冒烟测试脚本连续跑三个用例加载一个.terra场景并断言图层树根节点存在调用FlyTo飞到指定坐标读取当前Position判断误差小于1米模拟一次鼠标拾取断言返回对象非空。这三个用例覆盖了创建、加载、导航、交互四条主链路只要能跑通说明Helper的封装是健康的。最后给你一个实用习惯每次拿到新版本的TerraExplorer先不要跑业务代码只看对象浏览器里SGWorld的成员列表凡是我示例代码里用到的名字变了就说明这一版的接口有调整其余没变的调用基本可以放心。我在某模拟项目X里就是靠这个习惯把一次SDK大版本升级的改动从几十处压缩到只改Helper类一处。希望帮到你。本文还有配套的精品资源点击获取