Программирование. Этот заковыристый TIN…
Сегодня я решил рассказать о своем недавнем опыте по обузданию формата TIN, который используется в TerraModeler.
Какие задачи стояли:
- Разобраться в самом формате, опираясь на документацию TerraModeler;
- Написать простую читалку файлов данного формата;
- Организовать взаимодействие написанной читалки с VBA.
Стоит конечно дать небольшой ответ на вопрос, который может возникнуть во время прочтения данной статьи: “Зачем городить огород из C# и VBA, если можно чтение бинарного файла и дальнейшую работу с данными из него организовать на одном только VBA?”.
Вот лично мой ответ:
В случае, если вы и вся ваша компания ведете разработку вспомогательных инструментов исключительно на VBA, то вы так и должны были бы сделать. Однако, если разработка ведется на нескольких языках, то намного проще один раз написать универсальный класс, который можно без особых трудностей использовать там где будет нужно в любое время.
Итак, поехали.
Формат не формат
Для начала мы с вами пройдемся на официальный ресурс компании TerraSolid и возьмем оттуда свежую документацию к программе TerraModeler. В моем случае это была версия для TerraModeler x32 от 08.07.2016.
Открываем скаченный файл в нашем любимом читальщике PDF и оперативно переходим на главу “22 TIN File Format Specification”, в которой создатели TerraModeler и данного формата раскрывают перед нами его секреты. Учитывая, что лично у меня уже был опыт работы с их документацией в плане формата данных BIN, который используется в TerraScan, то я был в полной уверенности, что работа выльется в простой копи-паст структур и приведение их к виду, принятому в C#.
Итак, давайте быстренько разберемся с тем, что нам предлагает документация.
Структура заголовка представлена так:
typedef struct { char RecogStr[4] ; // Recognition "TTIN" UINT RecogVal ; // Recognition 20101221 UINT Version ; // Version 1 UINT HdrSize ; // Header size = sizeof(SurfHdr) UINT PntCnt ; // Number of points UINT PntSize ; // Size of each point 12 UINT TriCnt ; // Number of triangles UINT TriSize ; // Size of each triangle 28 char Desc[40] ; // Descriptive name for surface char Software[40] ; // Software which generated the file UINT Type ; // Surface type (0=ground,1=design,2=bedrock,..) UINT CoordSize ; // Number of integer steps per real world unit double OrgX ; // Origin of coordinate system double OrgY ; double OrgZ ; UINT64 PntPos ; // File position where point data starts UINT64 TriPos ; // File position where triangle data starts } TinHdr ;
Структура точек модели куда проще:
typedef struct { long X ; long Y ; long Z ; BYTE Break ; // Break line edge with previous point BYTE Type ; // Point type TINPT_xxxx } TinPnt ;
Структура треугольников модели немного сложнее точек:
typedef struct { UINT Vertex[3] ; // Triangle vertices in clockwise order UINT Neigbour[3]; // Neighbour triangle indexes BYTE Flags ; // Bits 0-1:excluded,2-3:edge0,4-5:edge1,6-7:edge2 BYTE Domain ; // Region or land type } TinTri ;
Ну и несколько констант, которые нам вообще без надобности:
#define TINPT_RANDOM 0 #define TINPT_SOFTBRK 1 #define TINPT_HARDBRK 2 #define TINPT_CONTOUR 3 #define TINPT_INFERRED 4 #define TINPT_OUTBND 5 #define TINPT_INTBND 6
Дабы сразу открыть занавес без долгих красочных рассказов – документация нам врет! И врет достаточно сильно… Для того, что бы понять это мне пришлось сначала убедиться в том, что написанный мною читальщик бинарных файлов вообще корректно работает. Проверял я его на файле TerraScan BIN, документация которого гарантированно точная.
Ну ладно, про сильное вранье я конечно преувеличил. В общем проблема заключается в первой части файла, которую занимает заголовок, длина которого должна быть 160 байт.
Для следующих действий нам понадобится Hex Editor Neo или любой другой редактор бинарных файлов. Давайте откроем любой файл модели, которую создает TerraModeler, с помощью этой замечательной программы и посмотрим на первые байты, которые покажут на сколько точна документация.
А теперь вспомним, что же нам говорит документация? А она говорит, что первые 4 байта занимает сочетание из 4 символов, которые вместе образуют “TTIN“. А что на самом деле? А на самом деле – там нолик, квадратик и дважды ничего. В общем-то если хотите, то можете потрошить формат дальше, но мне достаточно просто знать, что заголовок не такой какой он должен быть. Для нас самое главное – это вытащить из заголовка количество точек и количество треугольников и сделать это мы можем просто опытным путем (оба значения для самой модели нам известны просто из данных TerraModeler).
Находятся оба искомых значения не очень далеко: количество точек занимает байты 48-51 (на картинке выше дают значение 0000ff53), а для количества треугольников отведены байты с 52 по 55 (в примере имеют значение 0001feb8). Ах да, чуть не забыл – байты мы считываем в обратном порядке!
Ну и конечно давайте определим длину всего заголовка, что бы мы могли безошибочно пропускать его в ходе чтения файлов. Для этого можем просто промотать вниз окошко редактора и вы сами увидите как заканчиваются тысячи нулей и начинаются какие-то осмысленные данные. В общем 6708-ой байт уже относится к точкам модели.
Итак, подведем итог копания в документации и самом файле:
- Мы знаем, что заголовок TinHdr не соответствует документации, но его длина нам известна;
- Мы знаем структуру данных точки модели TinPnt и мы можем получить их количество из заголовка;
- Мы знаем структуру данных треугольника модели TinTri и мы можем получить их количество из заголовка.
Т.е. мы знаем все, что нам нужно для того, что бы написать непосредственно читальщик.
C# рвется в бой!
Разобрались мы с документацией, знаем все о формате и можем теперь приступить к созданию кода!
Для создания библиотеки я воспользуюсь Microsoft Visua Studi Community 2015 и возможностями языка C#. Вы же можете пользоваться тем, что лично вам удобно как в плане среды разработки, так и в плане языка.
Если создание проекта на C# вам не интересно, то можете пропустить эту часть, остальным же предлагаю ознакомиться.
Создаем и настраиваем проект
Давайте для начала создадим новый проект, который будет иметь тип “Библиотека классов”. Как вы его назовете – решать только вам, ну а я дам ему название TINVBA. Теперь даже не приступив в написанию кода давайте сразу сделаем его видимым для COM. Для этого выполним простые шаги:
- Откроем свойства проекта;
- На вкладке “Приложение” нажмем на кнопку “Сведения о сборке” и в открывшемся окне в самом низу поставим галочку “Сделать сборку доступной для COM”;
- Выберем конечную платформу x86;
- Установим события после сборки. Это позволит нам автоматические регистрировать сборку в системе;
- И последний шаг – подпишем нашу сборку!
Кстати, вместо того, что бы как я указывать событие после сборки, вы можете воспользоваться другой возможностью: на вкладке “Сборка”, где мы выбирали конечную платформу, в самом низу есть галочка “Регистрация для COM-взаимодействия”, установив которую вы избавляетесь от необходимости вбивать команды руками.
Создаем класс TModel
Теперь давайте создадим в нашем проекте новый класс, который назовем TModel. В нем мы опишем нужные нам структуры, а также создадим ряд функций, который будут заниматься считываем нужных нам данных.
using System; using System.IO; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Linq; using System.Text; namespace TINVBA { // TIN file triangle record [StructLayout(LayoutKind.Sequential)] public struct TinTri { //В исходнике должно быть UINT, но в VBA 6.0 нет аналога такого числа //UINT - это От 0 до 4 294 967 295 //В VBA 6.0 Long - это -2,147,483,648 to 2,147,483,647. Т.е. мы можем оперировать только int [MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.I4, SizeConst = 3)] public int [] Vertex; [MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.I4, SizeConst = 3)] public int [] Neigbour; [MarshalAs(UnmanagedType.I1)] public byte Flags; // Bits 0-1:excluded,2-3:edge0,4-5:edge1,6-7:edge2 [MarshalAs(UnmanagedType.I1)] public byte Domain; // Region or land type } // TIN file point record [StructLayout(LayoutKind.Sequential)] public struct TinPnt { [MarshalAs(UnmanagedType.I4)] public int X; [MarshalAs(UnmanagedType.I4)] public int Y; [MarshalAs(UnmanagedType.I4)] public int Z; [MarshalAs(UnmanagedType.I1)] public byte Break; // Break line edge with previous point [MarshalAs(UnmanagedType.I1)] public byte Type; // Point type TINPT_xxxx } class TModel { public const byte TINPT_RANDOM = 0; public const byte TINPT_SOFTBRK = 1; public const byte TINPT_HARDBRK = 2; public const byte TINPT_CONTOUR = 3; public const byte TINPT_INFERRED = 4; public const byte TINPT_OUTBND = 5; public const byte TINPT_INTBND = 6; [StructLayout(LayoutKind.Sequential)] public struct TinHdr { [MarshalAs(UnmanagedType.ByValArray, SizeConst = 28)] public char[] RecogStr; // Recognition "TTIN" Размер 4 public int RecogVal; // Recognition 20101221 public int Version; // Version 1 public int HdrSize; // Header size = sizeof(SurfHdr) public int PntSize; // Size of each point 12 public int TriSize; // Size of each triangle 28 //Начиная с 48 байта public int PntCnt; // Number of points public int TriCnt; // Number of triangles [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1024)] public char[] Desc; // Descriptive name for surface Размер 40 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 360)] public char[] Software; // Software which generated the file Размер 40 public int Type; // Surface type (0=ground,1=design,2=bedrock,..) public int CoordSize; // Number of integer steps per real world unit public double OrgX; // Origin of coordinate system public double OrgY; public double OrgZ; public UInt64 PntPos; // File position where point data starts public UInt64 TriPos; // File position where triangle data startsx } public static T ReadStruct2<T>(BinaryReader binary_reader) where T : struct { byte[] buffer = new byte[Marshal.SizeOf(typeof(T))]; binary_reader.Read(buffer, 0, buffer.Count()); GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); T result = (T)Marshal.PtrToStructure(handle.AddrOfPinnedObject(), typeof(T)); handle.Free(); return result; } public int ReadModelHeader(string FileName, ref TinHdr Hdr) { try { using (BinaryReader reader = new BinaryReader(File.Open(FileName, FileMode.Open))) { Hdr = ReadStruct2<TinHdr>(reader); reader.Close(); } return 1; } catch { return 0; } } public int ReadModelPoints(string FileName, ref List<TinPnt> TinPoints) { TinHdr thisHeader = new TinHdr(); if (ReadModelHeader(FileName, ref thisHeader) == 0) return -1; if (thisHeader.PntCnt == 0) return -2; try { using (BinaryReader reader = new BinaryReader(File.Open(FileName, FileMode.Open))) { //Сдвигаемся на длину хэдера reader.BaseStream.Seek(6708, SeekOrigin.Begin); for (int i = 0; i < thisHeader.PntCnt; i++) TinPoints.Add (ReadStruct2<TinPnt>(reader)); reader.Close(); } return thisHeader.PntCnt; } catch { return -3; } } public int ReadModelTriangles(string FileName, ref List<TinTri> TinTriangles) { TinHdr thisHeader = new TinHdr(); if (ReadModelHeader(FileName, ref thisHeader) == 0) return -1; if (thisHeader.TriCnt == 0) return -2; try { using (BinaryReader reader = new BinaryReader(File.Open(FileName, FileMode.Open))) { //Сдвигаемся на длину хэдера + длину всех точек reader.BaseStream.Seek(6708 + thisHeader.PntCnt * Marshal.SizeOf(typeof(TinPnt)), SeekOrigin.Begin); for (int i = 0; i < thisHeader.TriCnt; i++) TinTriangles.Add(ReadStruct2<TinTri>(reader)); reader.Close(); } return thisHeader.TriCnt; } catch { return -3; } } } }
На чем стоит сконцентрировать внимание:
- Структуры TinPnt и TinTri мы вынесли за пределы класса TModel, а вот TinHdr оставили внутри самого класса. На мой взгляд такой подход наиболее логичен и дает возможность в дальнейшем использовать типы данных TinPnt и TinTri непосредственно в VBA.
- В структурах TinPnt и TinTri мы активно используем возможности класса Marshal. Именно это позволяет нам без каких-либо проблем использовать одни и те же структуры данных как в C#, так и в VBA;
- В структуре TinTri мы вынуждены отказаться от использования UINT в пользу обычного int. Связано это с тем, что на стороне VBA 6.0 нам доступен только 4-х байтовый long.
Конечно вы можете организовать класс и его методы своим способом, однако предложенный подход проверен и работает.
Где твой обещанный VBA!?
Теперь мы с вами создадим еще один класс, который назовем TTIN и который фактически станет для нас с вами основным. Давайте я как и ранее приведу его исходный код, а потом поясню основные моменты:
using System; using System.Collections.Generic; using System.ComponentModel; using System.Runtime.InteropServices; using System.Linq; using System.Text; namespace TINVBA { [Guid("B3D98E28-8C5F-4269-88DD-6214EEE62A11")] [ComVisible(true)] public interface ITTIN { [ComVisible(true), Description("Return points count from tin")] int GetPointCount(string FileName); [ComVisible(true), Description("Return triangles count from tin")] int GetTriCount(string FileName); [ComVisible(true), Description("Read all points from tin")] int ReadModelPoints(string FileName); [ComVisible(true), Description("Read all triangles from tin")] int ReadModelTriangles(string FileName); [ComVisible(true), Description("Return point from list")] TinPnt GetPointFromList(int Index); [ComVisible(true) Description("Return triangle from list")] TinTri GetTriangleFromList(int Index); [ComVisible(true) Description("Clear")] int Clear(); } [Guid("35D20318-FDD4-46B3-838D-645EE7F4B824")] [ClassInterface(ClassInterfaceType.None)] [ComVisible(true)] public class TTIN : ITTIN { private List<TinPnt> listPoints; private List<TinTri> listTriangles; public TTIN() { listPoints = new List<TinPnt>(0); listTriangles = new List<TinTri>(0); } [ComVisible(true) Description("Return points count from tin")] public int GetPointCount(string FileName) { TModel.TinHdr thisHdr = new TModel.TinHdr(); TModel thisModel = new TModel(); if (thisModel.ReadModelHeader(FileName, ref thisHdr) == 1) return thisHdr.PntCnt; return -1; } [ComVisible(true) Description("Return triangles count from tin")] public int GetTriCount(string FileName) { TModel.TinHdr thisHdr = new TModel.TinHdr(); TModel thisModel = new TModel(); if (thisModel.ReadModelHeader(FileName, ref thisHdr) == 1) return thisHdr.TriCnt; return -1; } [ComVisible(true) Description("Read all points from tin")] public int ReadModelPoints(string FileName) { TModel thisModel = new TModel(); listPoints = new List<TinPnt>(0); return thisModel.ReadModelPoints(FileName, ref listPoints); } [ComVisible(true), Description("Read all triangles from tin")] public int ReadModelTriangles(string FileName) { TModel thisModel = new TModel(); listTriangles = new List<TinTri>(0); return thisModel.ReadModelTriangles(FileName, ref listTriangles); } [ComVisible(true) Description("Return point from list")] public TinPnt GetPointFromList(int Index) { if (listPoints.Count > 0 && Index >=0 && Index < listPoints.Count) return listPoints[Index]; TinPnt NullPnt = new TinPnt(); return NullPnt; } [ComVisible(true) Description("Return triangle from list")] public TinTri GetTriangleFromList(int Index) { if (listTriangles.Count > 0 && Index >= 0 && Index < listTriangles.Count) return listTriangles[Index]; TinTri NullPnt = new TinTri(); return NullPnt; } [ComVisible(true) Description("Clear")] public int Clear() { listPoints.Clear(); listTriangles.Clear(); return 1; } } }
Что и для чего тут нужно:
- Перед самим классом мы объявляем его интерфейс ITTIN, внутри которого мы объявляем наши функции, которые будут доступны в макросах VBA;
- И класс, и его интерфейс мы помечаем специальными атрибутами: GUID, ComVisible(true). Сам класс помечаем также с помощью атрибута [ClassInterface(ClassInterfaceType.None)];
- listPoints и listTriangles мы будем использовать для хранения данных, которые вернут нам наши функции класса TModel.
Компилируем!
Что мы уже сделали:
- Создали проект TINVBA;
- Сделал проект видимым для COM и настроили его;
- Создали класс TModel, который отвечает непосредственно за работу с TIN-файлами;
- Создали класс TTIN, который отвечает за взаимодействие с VBA.
Остался последний шаг – компиляция!
Запускаем сборку и в случае успеха мы увидим вот такую информацию в закладке “Вывод”:
Если у вас возникают какие-либо ошибки, то придется разбираться самостоятельно, но могу дать одну подсказку – если вы пересобираете проект, то необходимо закрыть все программы, которые используют вашу библиотеку!
Допинг TINVBA для Microstation VBA
Ну что же, остается только проверить как все работает. Тут можете делать как вам угодно – пост все-таки не про написание макросов на VBA. Я покажу простое взаимодействие и еще расскажу кое-что полезное.
Итак, запускаем Microstation, открываем какой-нибудь DGN-файл, запускаем наши TerraScan и TerraModeler, создаем модель и пишем тестовый макрос.
Function FnSurfaceFile(ByVal i As Long, ByRef FileName As String) As Long Dim bytArr(65535) As Byte FnSurfaceFile = GetCExpressionValue("FnSurfaceFile(" & VarPtr(bytArr(0)) & "," & i & ")") FileName = "" Dim j As Long For j = LBound(bytArr) To UBound(bytArr) If bytArr(j) <> 0 Then FileName = FileName + Chr(bytArr(j)) End If Next j End Function Function TinPntToPoint3d(p As TinPnt) As Point3d TinPntToPoint3d.X = p.X / 100 TinPntToPoint3d.Y = p.Y / 100 TinPntToPoint3d.Z = p.Z / 100 End Function Function CreateShapeFromTinPnt(a As TinPnt, b As TinPnt, c As TinPnt, Flag As Byte) As Element Dim pp(2) As Point3d pp(0) = TinPntToPoint3d(a) pp(1) = TinPntToPoint3d(b) pp(2) = TinPntToPoint3d(c) Set CreateShapeFromTinPnt = Application.CreateShapeElement1(Nothing, pp) Set CreateShapeFromTinPnt.Level = ActiveSettings.Level CreateShapeFromTinPnt.color = Flag End Function Private Sub DrawTrianglesButton_Click() Dim myTin As New TTIN, RetTriangle As TinTri, TRCount As Long, FileName As String Dim PTCount As Long, RetPoint As TinPnt, i As Long Dim ModelIndex As Long ModelIndex = CLng(ModelBox.Text) If FnSurfaceFile(ModelIndex, FileName) = 0 Then Application.ShowError "Ошибка получения имени файла из модели" Exit Sub End If PTCount = myTin.ReadModelPoints(FileName) TRCount = myTin.ReadModelTriangles(FileName) If PTCount <= 0 Then Application.ShowError "Ошибка считывания точек из файла модели" Exit Sub End If If TRCount <= 0 Then Application.ShowError "Ошибка считывания треугольников из файла модели" Exit Sub End If Dim Triangles As New Collection Dim Points() As Point3d, PointsCount As Long Dim Triangle As Element PointsCount = 0 Dim Inside As Boolean For i = 0 To TRCount - 1 RetTriangle = myTin.GetTriangleFromList(i) Dim Tri As Element Set Tri = CreateShapeFromTinPnt(myTin.GetPointFromList(RetTriangle.Vertex(0)), _ myTin.GetPointFromList(RetTriangle.Vertex(1)), _ myTin.GetPointFromList(RetTriangle.Vertex(2)), _ RetTriangle.flags) Application.ActiveModelReference.AddElement Tri Next i End Sub
Что же тут может быть интересного:
- Функция FnSurfaceFile заполняет строковую переменную имени файла модели с заданным индексом;
- Функция TinPntToPoint3d переводит TinPnt в Point3d;
- Функция CreateShapeFromTinPnt создает замкнутый контур на основании данных TinTri. При этом в качестве цвета используется информация из байтового поля Flag. Дело в том, что на все треугольники, хранящиеся в файле модели, используются в работе самим TerraModeler. Как выяснилось – треугольники с флагами 80 и 216 являются технологическими и реальной ценности не представляют.
- В результате работы макроса в активный слой будут записаны все треугольники, которые присутствуют в файле модели. Проверку можно провести используя треугольники, которые может записать в слой сам TerraModeler. У вас треугольников будет больше, но если вы перенесете в другой слой или удалите те самые треугольники с флагом 80 и 216, то убедитесь в правильности работы вашей библиотеки.
Завершение
Вот и закончился небольшой экскурс в создание COM DLL, которую можно использовать для работы с данными файла TIN.
Надеюсь изложенная тут информация будет полезна в работе и учебе.
Всем всего!