Как стать автором
Обновить

Создание плагинов для AutoCAD с помощью .NET API (часть 3 – работа со слоями)

Время на прочтение 14 мин
Количество просмотров 12K
Это очередная статья из цикла, посвященного разработке плагинов для AutoCAD. Речь в ней будет идти о базовых операциях со слоями в документе.

public static string disclaimer = "Автор не является профессиональным разработчиком и не обладает глубокими знаниями AutoCAD. Этот пост – просто небольшой рассказ о создании плагина.";

1. Общая информация


Все читатели этой статьи наверняка знакомы с тем, что представляет из себя слой. На примитивном уровне его можно считать контейнером для элементов чертежа, обладающих каким-то общим признаком, важным для разработчика.

Слои могут сильно упростить жизнь не только инженеру, создающему чертеж, но и программисту. Например, все расположенные на одном слое элементы можно при необходимости очень быстро скрыть, а затем вновь отобразить на рабочей области.

В рамках этой статьи нам потребуются некоторые базовые сведения, которые в основном можно почерпнуть отсюда (зеркало)

1.1. Слой номер ноль

Любой документ AutoCAD всегда содержит в себе как минимум один слой — нулевой (с именем «0»), который невозможно удалить. Этот слой часто оказывается очень полезным — например, при удалении других слоев.

1.2. Текущий слой

Один из слоев документа обязательно должен являться текущим (current). На него помещаются все добавляемые на чертеж объекты (по крайней мере примитивные — с блоками дела обстоят несколько сложнее).

1.3. Свойства слоев

Любой (в том числе нулевой) слой в AutoCAD имеет три свойства:
  • включен ("on");
  • заморожен ("freeze");
  • заблокирован ("lock").

Все эти свойства бинарные (каждое из них можно считать флагом «да/нет»).

Флаг "включен" отвечает за видимость слоя. Если этот флаг сброшен, то все элементы, расположенные на слое, перестают отображаться на чертеже.

Флаг "заморожен" также отвечает за видимость слоя. По своему действию он аналогичен флагу «включен». Как указано в документации, это свойство позволяет повысить производительность на очень больших чертежах.
NB:
Лично я никогда не работал с большими чертежами и никогда этим свойством не пользовался.

Флаг "заблокирован" отвечает за возможность редактирования слоя. Если этот флаг установлен, то все элементы, расположенные на слое, становятся недоступными для любых изменений (перемещение, удаление и т. п.). Кроме того, на заблокированный слой нельзя добавлять новые элементы.

Свойствами слоев можно управлять из AutoCAD. На расположенном ниже рисунке ядовито-зеленым выделена панель «Слои» (Layers), а фиолетовым выделен выпадающий список слоев, в левой части которого находятся переключатели свойств (лампочка, солнышко, открытый замок — «on», «freeze» и «lock» соответственно).


NB:
На эти переключатели действительно стоит обратить внимание: при написании плагина не всегда всё получается с первого раза, и порой приходится применять «ручное управление».
NB:
Некоторые сайты утверждают, что на этой картинке изображены не зеленый и фиолетовый цвета, а лайм и гелиотроп.

Наверное, я — собака.

1.4. Управление слоями

Самые простые действия со слоями были рассмотрены в предыдущем пункте. Для более сложных операций (например, создание или удаление слоя) нужно воспользоваться панелью «Свойства слоев» (Layer Properties), которая вызывается командой LAYER или щелчком по соответствующей иконке на панели «Слои» (обозначена оранжевым на рисунке в предыдущем подразделе). Вот как выглядит эта панель:


Особый интерес представляет выделенная рамкой секция кнопок. Первая кнопка в ней создает новый слой, третья — удаляет существующий, если это возможно.

Не может быть удален:
  • нулевой слой;
  • текущий слой;
  • слой, содержащий хотя бы один объект;
  • слой, на который имеется ссылка в определении блока.

Перед удалением слоя необходимо обеспечить выполнение этих условий.

NB:
На самом деле список запрещенных к удалению слоев несколько длиннее:



К сожалению, автору данного поста не приходилось сталкиваться ни с Defpoints, ни с Xref-dependent layers, поэтому проработку этих элементарных вопросов оставим читателям в качестве несложного домашнего задания. ^__^

На этом с теорией все, можно приступать к практике.

2. Написание плагина


2.1. Создание нового проекта плагина

Этому была посвящена первая статья цикла. В качестве требуемой версии .NET Framework в приведенных ниже примерах указана .NET Framework 3.5.

Можно сразу добавить каркас кода:
using System;
using Autodesk.AutoCAD.Runtime;
using Autodesk.Windows;
 
namespace MyAutoCADDll
{
    public class Commands : IExtensionApplication
    {
        // эта функция будет вызываться при выполнении в AutoCAD команды "TestCommand"
        [CommandMethod("TestCommand")]
        public void MyCommand()
        {
 
        }
 
        // Функции Initialize() и Terminate() необходимы, чтобы реализовать интерфейс IExtensionApplication
        public void Initialize()
        {
 
        }
 
        public void Terminate()
        {
 
        }
    }
}

2.2. Добавление ссылок на необходимые библиотеки

В этом примере нам потребуются две библиотеки AutoCAD .NET API: AcMgd.dll и AcDbMgd.dll (как всегда, не забываем отключать CopyLocal).

2.3. Добавление нового слоя

Все присутствующие в документе слои хранятся в списке слоев. Для добавления на чертеж нового слоя достаточно добавить новый элемент в этот список.

Однако несмотря на простоту операции, кода получается довольно много.

Код:
using System;
using System.IO;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
 
namespace HabrPlug_Layers
{
    public class Commands : IExtensionApplication
    {
        // эта функция будет вызываться при выполнении в AutoCAD команды "TestCommand"
        [CommandMethod("TestCommand")]
        public void MyCommand()
        {
            // получаем текущий документ и его БД
            Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
            Database acCurDb = acDoc.Database;
 
            // блокируем документ
            using (DocumentLock docloc = acDoc.LockDocument())
            {
                // начинаем транзакцию
                using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
                {
                    // открываем таблицу слоев документа
                    LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
 
                    // создаем новый слой и задаем ему имя
                    LayerTableRecord acLyrTblRec = new LayerTableRecord();
                    acLyrTblRec.Name = "HabrLayer";
 
                    // заносим созданный слой в таблицу слоев
                    acLyrTbl.Add(acLyrTblRec);
 
                    // добавляем созданный слой в документ
                    tr.AddNewlyCreatedDBObject(acLyrTblRec, true);
 
                    // фиксируем транзакцию
                    tr.Commit();
                }
            }
        }
 
        // Функции Initialize() и Terminate() необходимы, чтобы реализовать интерфейс IExtensionApplication
        public void Initialize()
        {
 
        }
 
        public void Terminate()
        {
 
        }
    }
}

После загрузки плагина и выполнения команды «TestCommand» на чертеже должен появиться новый слой:



В приведенном выше коде использовались две важные конструкции, которые часто встречаются при работе с объектами чертежа:
а) блокировка документа;
using (DocumentLock docloc = acDoc.LockDocument())

Документация Autocad говорит, что запросы на изменение объектов или на доступ к AutoCAD могут выполняться в любом контексте и от любого количества приложений. Чтобы предотвратить конфликты с другими запросами, программист должен сам обеспечить блокировку документа перед его изменением. Невыполнение этого требования в некоторых случаях приводит к возникновению ошибки при изменении базы данных документа.

После выполнения всех необходимых изменений в базе данных её следует разблокировать, используя метод Dispose() объекта DocumentLock. Также можно использовать блок using с объявлением в нём объекта DocumentLock — в этом случае при выходе из блока база данных разблокируется автоматически. (источник, зеркало)

NB:
По моему опыту, использование конструкции using гораздо удобнее, так как сводит к нулю вероятность забыть вызвать метод Dispose().
б) вызов транзакции.
using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
…
tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
…
tr.AddNewlyCreatedDBObject(acLyrTblRec, true);
…
tr.Commit();

В документации (зеркало) указано, что при работе с объектами, такими как отрезки, окружности и полилинии, или с таблицей идентификаторов и ее записями необходимо открывать объект для чтения или записи.

Попытки игнорировать это требование ни к чему хорошему не приведут — могут возникнуть весьма неприятные (в том числе с точки зрения отладки) ошибки.

Можно выделить несколько основных этапов работы с транзакцией:
  1. начало новой транзакции;
  2. открытие объекта на чтение или запись;
  3. если объект создается впервые (его еще нет в документе) — добавление его в базу данных документа;
  4. фиксация транзакции;
  5. завершение транзакции.

Все эти пункты подробно рассмотрены в документации по ссылкам выше.

Как и в случае с DocumentLock, необходимо или вызывать метод Dispose() после окончания работы с транзакцией, или использовать конструкцию using.

Теперь, когда мы разобрались с блокировкой документа и транзакциями, можно перейти к разбору остальной части кода.

Во-первых, мы подключаем необходимые пространства имен:

Autodesk.AutoCAD.Runtime — чтобы использовать тип IExtensionApplication и конструкцию CommandMethod;
Autodesk.AutoCAD.DatabaseServices — чтобы использовать типы Document и DocumentLock;
Autodesk.AutoCAD.Runtime — чтобы использовать типы Database, Transaction, LayerTable, OpenMode и LayerTableRecord.

Во-вторых, мы блокируем документ и начинаем транзакцию:

// получаем текущий документ и его БД
Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
Database acCurDb = acDoc.Database;
 
// блокируем документ
using (DocumentLock docloc = acDoc.LockDocument())
{
    // начинаем транзакцию
    using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
   {

В-третьих, мы получаем ссылку на таблицу слоев документа:

LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;

Метод GetObject() принимает на вход два параметра: Id объекта, который необходимо открыть, и уровень доступа. Узнать Id таблицы слоев документа можно с помощью свойства LayerTableId базы данных этого документа. Поскольку нам необходимо изменить таблицу слоев (добавить в нее новый слой), мы используем уровень доступа «запись» (OpenMode.ForWrite). Наконец, метод GetObject() возвращает значение типа «объект» (object), которое необходимо явно привести к нужному нам типу (LayerTable).

В-четвертых, мы создаем новый слой:

LayerTableRecord acLyrTblRec = new LayerTableRecord();
acLyrTblRec.Name = "HabrLayer";

Слой (а точнее, соответствующая ему запись в таблице слоев документа) имеет тип LayerTableRecord. Его конструктор не принимает никаких значений, поэтому всю инициализацию приходится проводить уже после создания.

В этом примере мы изменили только имя нового слоя, однако при необходимости можно задать и другие параметры, например видимость (свойство IsOff) или доступность для редактирования (свойство IsLocked).

В-пятых, мы заносим новый слой в таблицу слоев документа:

acLyrTbl.Add(acLyrTblRec);

Таблица слоев — это класс, для которого реализован интерфейс IEnumerable. Для добавления нового элемента используется метод Add().

В-шестых, мы добавляем новый слой в БД документа:

tr.AddNewlyCreatedDBObject(acLyrTblRec, true);

Поскольку до начала транзакции этого слоя не существовало, мы должны явно указать, что его необходимо добавить в БД документа. Если этого не сделать, запись добавлена не будет.

Важная деталь: функция AddNewlyCreatedDBObject должна вызываться не до, а после добавления слоя в таблицу слоев документа. Если попытаться поменять эти операции местами, то слой не будет добавлен. Стройного логического объяснения этому у меня нет; буду рад, если знающие люди поделятся разгадкой в комментариях.

В-седьмых, мы фиксируем транзакцию:

tr.Commit();

Если какие-либо элементы, открытые во время транзакции с помощью метода GetObject(), были изменены, то для сохранения этих изменений в БД документа необходимо зафиксировать транзакцию, вызвав метод Commit(). В противном случае никакие изменения сохранены не будут, и документ останется в состоянии, которое существовало на момент начала транзакции.

Наконец, мы заканчиваем транзакцию и снимаем блокировку документа:

    }
}

Вот он, важный плюс использования конструкции using! Без нее очень легко забыть вызвать метод Dispose(), тем более что сделать это нужно дважды — для фиксации транзакции и для разблокировки документа. А при использовании конструкции using метод Dispose() вызывается автоматически.

2.4. Установка текущего слоя

Получить текущий слой документа и установить новый можно с помощью свойства Clayer базы данных документа.

Код:
acCurDb .Clayer = acLyrTbl["HabrLayer"];

Достаточно вставить эту строку в предыдущий пример, прямо перед фиксацией транзакции.

2.5. Изменение свойств и переименование слоя

Для изменения свойств слоя нужно открыть соответствующую запись в таблице слоев.

Код:
using System;
using System.IO;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
 
namespace HabrPlug_Layers
{
    public class Commands : IExtensionApplication
    {
        // эта функция будет вызываться при выполнении в AutoCAD команды "TestCommand"
        [CommandMethod("TestCommand")]
        public void MyCommand()
        {
            // получаем текущий документ и его БД
            Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
            Database acCurDb = acDoc.Database;
 
            // блокируем документ
            using (DocumentLock docloc = acDoc.LockDocument())
            {
                // начинаем транзакцию
                using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
                {
                    // открываем таблицу слоев документа
                    LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
 
                    // создаем новый слой и задаем ему имя
                    LayerTableRecord acLyrTblRec = new LayerTableRecord();
                    acLyrTblRec.Name = "HabrLayer";
 
                    // заносим созданный слой в таблицу слоев
                    acLyrTbl.Add(acLyrTblRec);
 
                    // добавляем созданный слой в документ
                    tr.AddNewlyCreatedDBObject(acLyrTblRec, true);
 
                    // фиксируем транзакцию
                    tr.Commit();
                }
            }
        }
 
        // эта функция будет вызываться при выполнении в AutoCAD команды "NewCommand"
        [CommandMethod("NewCommand")]
        public void NewCommand()
        {
            // получаем текущий документ и его БД
            Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
            Database acCurDb = acDoc.Database;
 
            // блокируем документ
            using (DocumentLock docloc = acDoc.LockDocument())
            {
                // начинаем транзакцию
                using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
                {
                    // открываем таблицу слоев документа
                    LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
 
                    // если в таблице слоев нет нашего слоя - прекращаем выполнение команды
                    if (acLyrTbl.Has("HabrLayer") == false)
                    {
                        return;
                    }
 
                    // получаем запись слоя для изменения
                    LayerTableRecord acLyrTblRec = tr.GetObject(acLyrTbl["HabrLayer"], OpenMode.ForWrite) as LayerTableRecord;
 
                    // скрываем и блокируем слой
                    acLyrTblRec.Name = "test";
                    acLyrTblRec.IsOff = true;
                    acLyrTblRec.IsLocked = true;
 
                    // фиксируем транзакцию
                    tr.Commit();
                }
            }
        }
 
        // Функции Initialize() и Terminate() необходимы, чтобы реализовать интерфейс IExtensionApplication
        public void Initialize()
        {
 
        }
 
        public void Terminate()
        {
 
        }
    }
}

Перед операцией со слоем нелишним будет убедиться в его существовании с помощью метода Has() таблицы слоев документа.

Если мы загрузим плагин, выполним команду «TestCommand», а затем — команду «NewCommand», то увидим, что созданный нами слой стал скрытым, заблокированным и сменил имя. Теперь никто его не найдет.



2.6. Удаление слоя

Удаление слоя происходит во многом аналогично изменению. Для соответствующей записи в таблице слоев вызывается метод Erase() следующим образом:

acLyrTblRec.Erase(true);

Однако здесь важно помнить об ограничениях, которые были рассмотрены в первом разделе. Не получится удалить:
  • нулевой слой;
  • текущий слой;
  • слой, содержащий хотя бы один объект;
  • слой, на который имеется ссылка в определении блока.

Если необходимо удалить слой, который в данный момент является текущим, то перед удалением необходимо сделать текущим какой-нибудь другой слой — например, нулевой.

Если необходимо удалить слой, на котором присутствуют какие-либо объекты, то перед удалением слоя необходимо удалить все эти объекты или же перенести их на другой слой.

Более подробно про удаление слоев можно почитать в документации (раздел "Create and Edit AutoCAD Entities > Use Layers, Colors, and Linetypes > Work with Layers > Erase Layers").

А в блоге Kean Walmsley есть шаблон для поиска всех размещенных на слое объектов. На базе этого примера можно написать функцию, удаляющую со слоя все объекты.

В любом случае, перед удалением слоя не лишним будет убедиться в корректности операции, а само удаление обрамить конструкцией try ... catch.

Код:
using System;
using System.IO;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
 
namespace HabrPlug_Layers
{
    public class Commands : IExtensionApplication
    {
        // эта функция будет вызываться при выполнении в AutoCAD команды "TestCommand"
        [CommandMethod("TestCommand")]
        public void MyCommand()
        {
            // получаем текущий документ и его БД
            Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
            Database acCurDb = acDoc.Database;
 
            // блокируем документ
            using (DocumentLock docloc = acDoc.LockDocument())
            {
                // начинаем транзакцию
                using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
                {
                    // открываем таблицу слоев документа
                    LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
 
                    // создаем новый слой и задаем ему имя
                    LayerTableRecord acLyrTblRec = new LayerTableRecord();
                    acLyrTblRec.Name = "HabrLayer";
 
                    // заносим созданный слой в таблицу слоев
                    acLyrTbl.Add(acLyrTblRec);
 
                    // добавляем созданный слой в документ
                    tr.AddNewlyCreatedDBObject(acLyrTblRec, true);
 
                    // фиксируем транзакцию
                    tr.Commit();
                }
            }
        }
 
        // эта функция будет вызываться при выполнении в AutoCAD команды "TestCommand"
        [CommandMethod("NewCommand")]
        public void NewCommand()
        {
            // получаем текущий документ и его БД
            Document acDoc = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument;
            Database acCurDb = acDoc.Database;
 
            // блокируем документ
            using (DocumentLock docloc = acDoc.LockDocument())
            {
                // начинаем транзакцию
                using (Transaction tr = acCurDb.TransactionManager.StartTransaction())
                {
                    // открываем таблицу слоев документа
                    LayerTable acLyrTbl = tr.GetObject(acCurDb.LayerTableId, OpenMode.ForWrite) as LayerTable;
 
                    // если в таблице слоев нет нашего слоя - прекращаем выполнение команды
                    if (acLyrTbl.Has("HabrLayer") == false)
                    {
                        return;
                    }
 
                    // устанавливаем нулевой слой в качестве текущего
                    acCurDb.Clayer = acLyrTbl["0"];
 
                    // убеждаемся, что на удаляемый слой не ссылаются другие объекты
                    ObjectIdCollection acObjIdColl = new ObjectIdCollection();
                    acObjIdColl.Add(acLyrTbl["HabrLayer"]);
                    acCurDb.Purge(acObjIdColl);
 
                    if (acObjIdColl.Count > 0)
                    {
                        // получаем запись слоя для изменения
                        LayerTableRecord acLyrTblRec = tr.GetObject(acObjIdColl[0], OpenMode.ForWrite) as LayerTableRecord;
 
                        try
                        {
                            // удаляем слой
                            acLyrTblRec.Erase(true);
 
                            // фиксируем транзакцию
                            tr.Commit();
                        }
                        catch (Autodesk.AutoCAD.Runtime.Exception Ex)
                        {
                            // если произошла ошибка - значит, слой удалить нельзя
                            Application.ShowAlertDialog("Ошибка:\n" + Ex.Message);
                        }
                    }
                }
            }
        }
 
        // Функции Initialize() и Terminate() необходимы, чтобы реализовать интерфейс IExtensionApplication
        public void Initialize()
        {
 
        }
 
        public void Terminate()
        {
 
        }
    }
}

В этом коде нам уже знакомо все, кроме метода Purge(). Принцип его действия описан в документации: он анализирует коллекцию объектов и на выходе оставляет в ней только те объекты, на которые никто не ссылается.

В рассмотренном примере метод Purge() вызывается для коллекции, в которой находится только один объект — удаляемый слой. Если на этот слой никто не ссылается, то после вызова метода Purge() он останется в коллекции, и тогда можно приступать к удалению. Если же вызов метода Purge() вернет пустую коллекцию, то на удаляемый слой имеются ссылки — вероятно, это размещенные на нем объекты.

Если мы загрузим плагин и выполним команду «TestCommand», а затем — команду «NewCommand», то увидим, что созданный нами слой удалится с чертежа.



2.7. Запрет затемнения заблокированных слоев

В завершение хотелось бы рассказать еще об одном параметре, связанном со слоями.

По умолчанию заблокированные слои в AutoCAD отображаются затемненными. Максимально возможное затемнение составляет 90% (как у прямоугольника на рисунке ниже).



Для изменения степени затемнения служит переменная LAYLOCKFADECTL. Ее значение устанавливается соответствующим ползунком на панели управления слоями (обведен на рисунке зеленым цветом).

Для изменения значения переменной LAYLOCKFADECTL внутри плагина можно воспользоваться функцией SetSystemVariable:

Autodesk.AutoCAD.ApplicationServices.Application.SetSystemVariable("LAYLOCKFADECTL", 0);

В результате выполнения этого кода затемнение заблокированных слоев будет отключено.

Перед изменением переменной LAYLOCKFADECTL стоит сохранить ее текущее значение, чтобы вернуть переменную в исходное состояние после того, как плагин закончит работу.

Код:
using System;
using System.IO;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;

namespace HabrPlug_Layers
{
    public class Commands : IExtensionApplication
    {
        // переменная для хранения старого значения затемнения заблокированных слоев
        int old_LAYLOCKFADECTL = 0;
        
        // действия при загрузке плагина
        public void Initialize()
        {
            // запоминаем текущее значение затемнения заблокированных слоев
            old_LAYLOCKFADECTL = System.Convert.ToInt32(Autodesk.AutoCAD.ApplicationServices.Application.GetSystemVariable("LAYLOCKFADECTL"));
            // устанавливаем новое значение затемнения заблокированных слоев равным 0
            Autodesk.AutoCAD.ApplicationServices.Application.SetSystemVariable("LAYLOCKFADECTL", 0);
        }

        // действия при закрытии AutoCAD
        public void Terminate()
        {
            // возвращаем то значение затемнения заблокированных слоев, которое было установлено до загрузки плагина
            Autodesk.AutoCAD.ApplicationServices.Application.SetSystemVariable("LAYLOCKFADECTL", old_LAYLOCKFADECTL);
        }
    }
} 

К сожалению, метод Terminate() не всегда отрабатывает корректно, и исходное значение может не восстановиться. Но мы хотя бы попытались.)

На этом пока все. В следующий раз напишу о создании простых объектов и блоков.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+7
Комментарии 5
Комментарии Комментарии 5

Публикации

Истории

Работа

.NET разработчик
65 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн