Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Интерактивная 3D-система мониторинга строительных объектов

Прототип внутренней корпоративной платформы по ТЗ: 3D-модели объекта (.glb из Revit) слоями, разметка объёмных зон/секторов кликами, назначение бригад перетаскиванием, задачи и проблемы по секторам, расчёт процента выполнения на бэкенде и обновление цифр на модели без перезагрузки страницы.

Вторая итерация добавила: двухэтапную разметку с выдавливанием объёма, перетаскивание вершин уже созданной зоны, несколько .glb-слоёв с панелью «Слои», мультивыделение зон и бригад с массовыми действиями, переключатель прозрачности, окна подтверждения удаления, атомарную отмену последнего действия, несколько бригад на одной зоне и роль «Читатель».

Слой Стек
Бэкенд FastAPI · SQLAlchemy 2.0 · SQLite · PyJWT · bcrypt
Фронтенд Vite · Vue 3 · TypeScript · Pinia · vue-router · TresJS (@tresjs/core) · three.js

1. Быстрый старт

Нужны Python 3.11+ и Node.js 20+.

Бэкенд

cd backend
python -m venv .venv && source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

python tools/make_demo_model.py storage/models/demo_building.glb   # необязательно
python seed.py                                                     # демо-данные + админ
uvicorn app.main:app --reload --port 8000

Документация API поднимется на http://127.0.0.1:8000/docs.

Фронтенд

cd frontend
npm install
npm run dev        # http://localhost:5173

Vite проксирует /api, /media и /ws на http://127.0.0.1:8000, поэтому CORS в разработке не мешает. Другой адрес бэкенда задаётся переменной VITE_BACKEND_URL.

Учётные записи после seed.py

Логин Пароль Роль
admin admin123 администратор (все проекты)
prorab prorab123 подрядчик (только демо-проект)
inspector inspector123 читатель — смотрит, но ничего не меняет

Почта (уведомления и подтверждение адреса)

Настройки лежат в backend/.env — образец в backend/.env.example. Без них приложение работает как обычно, а письма (включая код подтверждения) пишутся в журнал сервера: поднять систему можно без почтового сервера.

Рабочая связка — Mail.ru, на ней всё и проверено:

APP_SMTP_HOST=smtp.mail.ru
APP_SMTP_PORT=465
APP_SMTP_USE_SSL=true
APP_SMTP_USERNAME=ящик@mail.ru
APP_SMTP_PASSWORD=пароль-для-внешних-приложений
APP_SMTP_SENDER=ящик@mail.ru

Пароль берётся в Почта → Настройки → «Пароли для внешних приложений»; пароль от аккаунта SMTP отклоняет (535 Invalid user or password). Больше ничего включать не нужно — ящик готов к отправке сразу после создания пароля.

Почему не Яндекс. На нём та же настройка требует двух условий сразу: пароль приложения из Яндекс ID и отдельно включённый в ящике доступ почтовым программам (Почта → Настройки → «Почтовые программы» → IMAP). Пока второе выключено, даже верный пароль приложения даёт 535 This user does not have access rights to this service.

Но главное препятствие дальше: на свежем ящике первое письмо уходит, а все последующие отбиваются с 554 Message rejected under suspicion of SPAM — это репутация отправителя, а не ошибка настроек, и кодом она не лечится. Именно поэтому основным вариантом выбран Mail.ru.

Признак «почта не настроена» — строка SMTP не настроен — письмо не отправлено в журнале сервера и "skipped": true в ответе API. Значит, backend/.env нет или в нём пусты APP_SMTP_USERNAME / APP_SMTP_PASSWORD: приложение продолжает работать, а письма уходят в лог.

Если запустить uvicorn без seed.py, администратор всё равно создастся при первом старте — с теми же логином и паролем из app/config.py.

Смените пароль и APP_SECRET_KEY перед любым использованием вне localhost.


2. Модели из Revit: несколько слоёв на одну сцену

  1. Войти администратором → «Администрирование».
  2. Создать проект (или взять демо-проект).
  3. В строке проекта — «+ Добавить .glb». Можно выбрать сразу несколько файлов: каждый станет отдельным слоем сцены (АР, КЖ, ОВ и т. д.).
  4. Открыть проект: камера сама впишет модели в кадр.

Слоями управляет панель «Слои» прямо в 3D-виде (кнопка «Слои» в тулбаре):

  • клик по строке выбирает слой, Ctrl/Cmd — добавляет к выбору, Shift — диапазон;
  • «глаз» переключает слой по кругу: обычный вид → полупрозрачный → скрытый;
  • «Прозрачность» применяет то же к нескольким выбранным слоям сразу;
  • переименование и удаление слоя доступны администратору.

Видимость и прозрачность слоёв — состояние клиента, а не базы: два прораба смотрят на объект по-разному и не должны переключать слои друг другу. Скрытый слой не просто прячется, а не монтируется вовсе — его геометрия уходит из видеопамяти.

Принимаются .glb и .gltf до 512 МБ (APP_MAX_MODEL_MB). Расширение и сигнатура файла проверяются, файл сохраняется в backend/storage/models/.

Модели раздаются не как открытая статика: GET /media/models/{файл} проверяет токен и доступ пользователя к проекту, которому принадлежит слой. Токен передаётся query-параметром — GLTFLoader из three.js не умеет добавлять заголовок Authorization к своим запросам.

Модели со сжатием Draco или KTX2 потребуют подключения соответствующих декодеров в frontend/src/components/scene/ModelLayer.vue. Обычный экспорт из Revit сжатия не использует.


3. Как ТЗ ложится на код

3.1 Управление 3D-моделью

Настройки камеры вынесены в frontend/src/three/controls.ts и покрыты тестами (tests/controls.test.ts): «включён ли Pan» — требование, которое иначе проверяется только руками, а ломается одной строкой.

Жест Действие
левая кнопка вращение вокруг цели
правая кнопка перемещение картинки (Pan)
средняя кнопка перемещение картинки (Pan) — как в Revit и AutoCAD
колесо зум к точке под курсором, а не к центру экрана
два пальца сдвиг и щипковый зум одновременно

Зум к курсору. Луч из курсора даёт точку на модели или зоне, и вся связка «камера + цель вращения» масштабируется относительно неё (controls.ts → zoomTowardPoint). Точка под курсором остаётся на том же месте экрана — это и проверяет тест. Цель вращения едет вместе с камерой намеренно: если оставить её на месте, при наезде на край модели орбита продолжила бы крутиться вокруг точки, которой уже нет в кадре.

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

Штатный зум OrbitControls не отключается (на нём держится щипковый зум двумя пальцами) — вместо этого колесо перехватывается на родителе канваса в фазе перехвата. Так свой обработчик гарантированно срабатывает раньше штатного независимо от порядка регистрации, и зум не применяется дважды — к курсору и к центру.

Панорамирование экранное (screenSpacePanning): картинка едет параллельно экрану, а не по плоскости земли — иначе на виде сверху сдвиг «вбок» уводил бы камеру вниз. Демпфирование включено, камера не проваливается под землю. «Сбросить вид» вписывает в кадр все слои сразу (three/ghosting.ts → fitCameraToObjects).

3.2 Фокусировка (режим рентгена)

frontend/src/three/batching.ts. Клик по элементу модели делает всю остальную геометрию полупрозрачной (opacity 0.1, depthWrite: false), а выбранный элемент получает подсвеченный материал. Клик по сектору приглушает всю модель целиком, чтобы зона читалась насквозь. Исходные материалы запоминаются на слитых мешах и возвращаются при сбросе (Esc или «Сбросить»).

Материалы переключаются не на исходных мешах, а на рендер-прокси — слитой копии геометрии слоя (см. п. 3.9): рисуется именно она, оригиналы остаются невидимыми целями для луча.

Клик разрешается сравнением дистанций raycast'а: если элемент модели ближе зоны, выделяется он, а не спрятанная за ним зона.

3.3 Разметка зон в два шага: площадь → объём

SceneBridge.vue кидает луч по модели и отдаёт точку попадания; lib/drafting.ts ведёт состояние черновика; three/geometry.ts строит из него геометрию.

Шаг 1 — площадь. Кликами обводится контур зоны.

  • Количество точек не ограничено.
  • Триангуляция — отсечением ушей (ear clipping) по проекции на плоскость, найденную методом Ньюэлла. Невыпуклые контуры поддерживаются, вырожденные не зацикливают алгоритм.
  • Зоны можно размечать не только на перекрытиях, но и на вертикальных поверхностях — нормаль считается по фактическому положению точек.

Шаг 2 — объём. Кнопка «Задать объём →» переводит черновик в режим выдавливания: ползунок и поле задают высоту, в сцене сразу виден будущий объём с рёбрами. Кнопка «Закрепить плоской» оставляет зону без объёма — так выглядят все зоны, созданные до этой доработки (height = 0).

Выдавливание строго вертикальное (geometry.ts → buildPrismGeometry). Получается замкнутая призма: низ, верх и боковины. Обход граней согласован — низ смотрит вниз, верх вверх, стенки наружу, — иначе освещение зоны выворачивается наизнанку. Направление боковых граней зависит от того, в какую сторону пользователь обходил контур, поэтому знак площади основания в плоскости XZ учитывается отдельно; это закреплено тестами (tests/volume.test.ts проверяет объём меша, его замкнутость и то, что все нормали смотрят наружу при любом порядке кликов).

3.2.1 Этажи (уровни) и сечение модели

Снятие отметок живёт в отдельном режиме: кнопка «⇱ Выделение этажей» в панели «Этажи». Пока режим выключен, клик по детали ничего об уровнях не сообщает. Раньше отметка снималась при ЛЮБОМ выборе детали, и форма закрепления уровня выскакивала каждый раз, когда человек просто рассматривал модель.

В режиме отметка берётся с низа габаритного ящика детали. Именно низ, а не центр и не точка попадания: колонна стоит на перекрытии, и её основание — это и есть уровень, на котором она смонтирована. Значение можно поправить руками перед закреплением.

Плоскость (scene/LevelPlanes.vue) рисуется только у выбранных в панели уровней и у ещё не закреплённой черновой отметки. Постоянная сетка полупрозрачных плит на объекте с десятком этажей закрывала бы саму модель, ради которой всё и затевается: плоскость — это подсветка выбора, а не элемент сцены.

Фильтрация видимости сделана плоскостями отсечения рендерера, а не скрытием мешей целиком: требование «показывать только объём между отметками» иначе не выполнить — перекрытие, пересекающее границу, должно обрезаться, а не исчезать вместе с половиной этажа. Плоскости глобальные, поэтому режутся и зоны, и сечение выглядит цельным.

Кнопка Что показывает
▲ Выше только то, что выше выбранной отметки
▼ Ниже только то, что ниже выбранной отметки
⇕ Между объём между двумя выбранными уровнями

Выбор уровня сам режим НЕ включает: иначе первое нажатие на «Выше» выключало бы уже проставленный режим, и кнопка казалась бы сломанной. Больше двух уровней не держим — «между» осмысленно ровно для пары, а третий выбор вытесняет самый старый.

Луч уважает сечение. Отсечение — операция чисто визуальная: плоскости живут в рендерере, а Raycaster о них не знает и продолжает попадать в срезанную геометрию. Из-за этого разметка между этажами уезжала на крышу: на экране её нет, но луч упирался в неё первой. Теперь из списка попаданий берётся первое в видимом диапазоне (three/clipping.ts → pickWithinClip, покрыт тестами). Функция встроена один раз в SceneBridge.castAgainst(), поэтому сечение уважают сразу все пути: постановка точки, выбор зоны и детали, снятие отметки, якорь зума, захват маркера вершины и приём перетаскиваемой бригады.

Отдельно обрабатывается клик по пустоте разреза: между этажами часть площади — это проём на месте срезанного перекрытия. Точка кладётся на саму плоскость среза, иначе контур рвался бы на проёмах. Два ограничения, без которых запасной вариант вредит больше, чем помогает:

  • он включается, только если луч реально упёрся в срезанную геометрию (были попадания, но все вне диапазона). Плоскость среза бесконечна, и клик мимо здания — по фону у горизонта — иначе ставил бы опорную точку в сотнях метров от объекта, где её не видно даже маркером;
  • в режиме «Ниже» запасного варианта нет вовсе: там срез идёт сверху, и его плоскость — это потолок над размечаемой поверхностью. Точка на нём оказалась бы выше остальных и перекосила бы зону.

3.2.2 Выделение по деталям

Кнопка «✨ Выделение по деталям». Пользователь кликает по деталям модели — каждая подсвечивается сразу, повторный клик снимает; затем «Задать объём →» переводит на шаг высоты, «Закрепить зону» создаёт зону.

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

Как считается (lib/smartSelect.ts, покрыт тестами):

  • граница зоны — выпуклая оболочка следов выбранных деталей: плотнее общего прямоугольника и не тянет зону на пустое место между разнесёнными объектами;
  • основание кладётся на низ самой низкой детали, предлагаемая высота — до верха самой высокой, округляется до сантиметров и правится на шаге объёма;
  • вырожденный набор (одна деталь нулевой площади, детали строго по одной линии) зоны не образует — вместо пустой зоны возвращается null.

Подсветка набора рисуется мешами, которые делят геометрию с оригиналами (three/batching.ts → setHighlight принимает список), поэтому выбор десятка деталей ничего не копирует.

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

Есть исключение из общего правила «повторный клик снимает выбор»: в режиме правки границ обычный клик по единственной выбранной зоне выбор не снимает. Именно в эту зону и целятся, промахнувшись мимо маркера, — и маркеры вершин исчезали бы прямо из-под курсора. Выйти можно Esc или кнопкой режима.

3.3.1 Правка границ готовой зоны

Кнопка «Правка границ» (в тулбаре и в карточке зоны) показывает у выбранной зоны жёлтые маркеры вершин — их можно перетаскивать мышью (scene/VertexHandles.vue + обработчики в SceneBridge.vue).

  • Вершина удерживается в плоскости своей зоны: экранный луч курсора пересекается с этой плоскостью (geometry.ts → rayPlaneIntersection). Иначе полигон перестал бы быть плоским и триангуляция поплыла бы.
  • Пока идёт перетаскивание, орбита камеры отключена, а указатель захвачен (setPointerCapture) — курсор может уйти за пределы канваса.
  • На сервер уходит только результат: в сцене координаты меняются покадрово, запрос уходит один, при отпускании кнопки.
  • Маркеры рисуются без depthTest: вершина за стеной остаётся доступной, иначе часть границы нельзя было бы поправить, не облетев здание.

Размер маркера считается от площади зоны: фиксированный радиус на объекте в сотню метров превращается в невидимую точку, а на зоне 2×2 м закрывает её целиком.

Маркеры стоят на двух кольцах. Жёлтые — основание, голубые — верхняя грань объёма (у плоской зоны верхнего кольца нет). Разный цвет обязателен: на виде сверху кольца иначе сливаются, и непонятно, за какую точку тянешь.

Верхние точки правятся независимо от нижних — этого и требует ТЗ, чтобы сектор огибал сложные элементы здания. Первое движение верхней вершины «материализует» грань: до него верх хранится как «основание + высота» (top_coordinates = NULL), и все прежние зоны остаются ровными призмами без единой записи в данных. Верхняя вершина ходит в горизонтальной плоскости своей грани — так же, как нижняя ходит в плоскости основания.

Число вершин верха обязано совпадать с основанием: боковины строятся парами «вершина низа — вершина верха». Если основание переразметили, правка верха сбрасывается к ровному выдавливанию — иначе зона развалилась бы.

3.3.2 «Шаг назад» отменяет ровно одно действие

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

Теперь черновик разметки имеет собственную отмену (lib/drafting.ts, покрыт тестами), а стек действий сцены — свою. Порядок строго обратен действиям пользователя:

Последнее действие Что делает «Шаг назад» (, Ctrl+Z)
поставлена опорная точка убирает одну последнюю точку
выполнено выдавливание возвращает к правке контура, не теряя точек
зона закреплена удаляет созданную зону целиком (запрос к API)
перетащена вершина возвращает прежние координаты
зона переименована возвращает прежнее название
бригады назначены/сняты возвращает прежний состав бригад
массовое назначение бригад возвращает состав всем зонам за один шаг

Удаление зоны в стек отмены не попадает намеренно: восстановить её вместе с задачами, проблемами и их историей уже нельзя, поэтому удаление защищено окном подтверждения, а не отменой. Записи об удалённой зоне вычищаются из стека — иначе «Шаг назад» иногда молча ничего не делал бы.

Esc выходит из режима разметки, отбрасывая черновик.

3.4 Бригады, задачи, проблемы

  • Drag-and-drop. Панель слева (BrigadePanel.vue), карточки перетаскиваются мышью прямо на полигон в 3D (dragover ведёт raycast и подсвечивает цель) или на 3D-виджет сектора. Бригаду можно перетащить и с одного виджета на другой — это переназначение между секторами.
  • Несколько бригад на одной зоне. Связь хранится массивом brigade_ids. Перетаскивание бригады на зону добавляет её к уже назначенным, а не заменяет их; снять можно как одну бригаду (крестик на чипе в карточке), так и все сразу.
  • Контекстная панель сектора (SectorSidebar.vue): название, список бригад с бригадирами и численностью, процент выполнения, площадь и объём зоны, ползунок высоты, кнопки «Добавить задачу» и «Добавить проблему», списки задач (статус + ползунок процента) и проблем (чекбокс «решена»), явная кнопка «🗑 Удалить зону».

3.5 Мультивыделение и массовые действия

Зоны, бригады и слои выделяются одинаково (lib/selection.ts, покрыт тестами):

Действие Результат
обычный клик оставить только эту строку
повторный обычный клик по ней же снять выбор
Ctrl/Cmd + клик добавить или убрать одну строку
Shift + клик выбрать диапазон от точки опоры
Ctrl/Cmd + A выбрать все зоны

Зоны выбираются и кликом по модели, и строкой в списке «Зоны» — модификаторы работают одинаково в обоих местах. Серия Shift-кликов отсчитывается от одной опоры (как в файловых менеджерах), а исчезнувшая опора не выделяет случайный кусок списка.

Повторный клик снимает выбор — так же, как в панели этажей. Иначе выделение нечем было сбросить, кроме клика по пустому месту сцены, которого в списках просто нет. Условие именно «ровно эта одна строка»: если выбрано несколько, обычный клик по одной из них сводит выбор к ней, а не обнуляет всё. Из этого следуют две поправки: поп-ап зоны не реагирует на обычный клик (он висит над выбранной зоной и убирал бы сам себя из-под курсора), а кнопка «⤢» по-прежнему только ОТКРЫВАЕТ карточку — она ходит через openSectorCard, а не через обычный выбор.

Когда выбрано больше одной зоны, правая панель превращается в панель массовых действий:

  • галочками отмечается, к каким именно из выделенных зон применить действие (по умолчанию — ко всем, заголовок показывает «в 2 из 5»);
  • «Добавить задачу» / «Добавить проблему» заводят запись сразу в отмеченных зонах. В каждой зоне создаётся своя запись: прогресс у зон разный, а общая запись означала бы, что отметка «готово» в одной зоне закрывает работу во всех остальных;
  • «Бригады на все выбранные» задаёт одинаковый состав бригад;
  • «Удалить выбранные зоны» — одно окно подтверждения на весь набор.

Бригады выделяются так же: их зоны подсвечиваются в сцене, а над списком стоит тот же набор кнопок, что и у зон, — «Выбрать все» / «Снять выбор» и «Удалить выбранные» (одним запросом, сняв со всех зон). Одинаковые списки — одинаковые действия на одинаковых местах.

Назначение бригады перетаскиванием работает в трёх местах: на зону в 3D-сцене, на её поп-ап и на строку в списке зон. Последнее — потому что в плотной застройке попасть курсором в нужную зону тем труднее, чем она мельче, а в списке зона всегда на виду и названа. Подсветка строки-приёмника идёт через ту же шину сцены (three/sceneBus.ts), что и подсветка зоны в 3D: тащат одну карточку — подсвечиваться должна одна цель, где бы курсор ни был.

3.6 Окна подтверждения удаления

ConfirmDialog.vue заменил window.confirm: он перечисляет, что именно удаляется, оформляет кнопку как опасную и закрывается по Esc. Подтверждение запрашивается при удалении зоны, бригады и слоя модели — и по одному, и пакетом.

3.7 Мобильный интерфейс

Раскладка переключается по ШИРИНЕ экрана (lib/viewport.ts, порог 900 px), а не по User-Agent: на планшете в альбомной ориентации колонки помещаются и полезны, а в портретной — нет. User-Agent о доступном месте не говорит ничего.

На телефоне сцена занимает весь экран, а панели уходят в нижние шторки (BottomSheet.vue) — так 3D-модель остаётся видимой над шторкой, ради чего приложение и открывают.

Десктоп Телефон
верхняя панель на ~10 кнопок компактная шапка + вкладки внизу
колонки «Слои», «Бригады», «Зоны» шторки по вкладкам
боковая карточка зоны высокая шторка, открывается теми же путями
кнопки вида в тулбаре плавающие кнопки над сценой (вписать, прозрачность)
остальные действия в тулбаре шторка «Ещё»

Что осталось прежним: панели — те же компоненты с теми же пропсами и событиями. Привязки описаны один раз объектами и раздаются через v-bind/v-on в обе ветки шаблона: расписывать их дважды значило бы рано или поздно получить обработчик, добавленный только в одну раскладку.

Разметка зоны на телефоне забирает нижнюю панель целиком: на шаге 1 — счётчик точек и кнопки «Отмена / ↶ / Плоской / Объём →», на шаге 2 — ползунок высоты и «Закрепить зону». Переключаться между вкладками и разметкой посреди обвода контура было бы неудобно, поэтому вкладки на это время прячутся.

Мелочи, без которых мобильный вид ломается:

  • viewport-fit=cover + env(safe-area-inset-*) — сцена уходит под «чёлку», а панели от неё отступают;
  • maximum-scale=1 — двойной тап по сцене не масштабирует страницу: масштабом модели управляет сама сцена;
  • 100dvh вместо 100vh — иначе исчезающая адресная строка оставляет полосу под нижней панелью;
  • минимальная площадь касания кнопок — 44 px.

Перетаскивание бригады на зону пальцем не работает — это HTML5 drag-and-drop, которого на тач-устройствах нет. Функция при этом доступна: бригады назначаются из карточки зоны («+ Добавить бригаду») и массово из панели выбранных зон.

3.8 Прозрачность и поп-апы зон

  • Поп-ап зоны показывается только когда зона выбрана. На объекте с полусотней зон постоянные подписи перекрывают саму модель и друг друга; выбранных же зон единицы. Открыть карточку зоны, не выбирая её в сцене, можно кнопкой в списке зон.
  • Прозрачность переключает по кругу: обычный вид → полупрозрачный → скрытый. Из верхней панели кнопка убрана: там ей приходилось дописывать область действия (слои (2), зоны (1), всю модель (3)), потому что иначе было непонятно, на что нажимаешь. Управление осталось там, где виден сам объект — кнопкой в панели «Слои» (действует на выбранные слои, а без выбора на все) и значком-глазом в каждой строке списков слоёв и зон.
  • Смешанное выделение первым нажатием приводится к одному состоянию — иначе кнопка работала бы как рассинхронизация: часть объектов гасла бы, часть проявлялась.

Порядок панелей в левой колонке — «Зоны → Бригады → Этажи»: зоны главный объект работы, бригады назначаются на них, этажи нужны эпизодически, при разборе по отметкам.

Режимы взаимодействия — их четыре, и они взаимоисключающие, потому что все четыре претендуют на один и тот же клик по модели: разметка, правка границ, выделение по деталям, выделение этажей. Гасит их единственный помощник stores/project.ts → exitModesExcept(keep); «Просмотр» — не режим, а главный выключатель, он гасит все и запрещает включение. Без общего помощника каждый новый режим приходилось бы дописывать в четыре чужие функции, и на пятом это забыли бы. Esc снимает ровно один слой: окно → шторка → режим → выделение.

3.9 Производительность 3D-сцены

Выгрузка из Revit — это тысячи отдельных мешей. На реальной модели объекта (5 543 детали, 924 тыс. треугольников) страница просмотра тормозила целиком, а не только 3D. Замеры делались на M4 Max в Chrome; ниже — что именно было причиной и что сделано.

Замер до и после (медиана серии прогретых кадров, тот же ракурс «Сбросить вид», dpr 1.5):

было стало
вызовов отрисовки на кадр 5 554 11
рисуемых мешей 5 543 8
мс на кадр 26.5 2.1
из них на саму модель 22.9 0.6
кадров в секунду при простое 60 0

Три независимые причины, в порядке вклада:

1. Сцена рисовалась непрерывно. TresCanvas по умолчанию гонит кадры 60 раз в секунду, даже когда на экране ничего не меняется. Кадр стоил десятки миллисекунд процессора, и этот расход шёл постоянно — отсюда «тормозит весь сайт», а не только вращение камеры. Включён режим render-mode="on-demand": кадр рисуется только по запросу. Запрос шлёт invalidateScene() из three/sceneBus.ts — его зовут все императивные изменения сцены (загрузка слоя, перестройка зон, маркеры вершин, плоскости этажей, отсечение, наведение), плюс один сторож в SceneCanvas.vue следит сразу за всеми пропсами сцены, чтобы нельзя было забыть источник изменений.

Счётчик кадров frameTick, по которому HTML-виджеты зон пересчитывают свои позиции, теперь двигается только когда камера действительно сдвинулась (controls.update() вернул true), а не каждый второй кадр безусловно.

2. Каждая деталь — свой вызов отрисовки. Замер показал, что дело не в видеокарте: при снижении разрешения в десять раз время кадра почти не менялось (25.3 → 21.2 мс), то есть упиралось в накладные расходы на вызов. Введён рендер-прокси (frontend/src/three/batching.ts): геометрия слоя сливается в несколько крупных мешей — по одному на материал и набор атрибутов, с бюджетом 262 144 вершины на пачку, чтобы отсечение по пирамиде видимости продолжало работать.

Ключевое решение — исходные меши остаются в сцене невидимыми. Луч выбора в three.js не проверяет visible, поэтому по ним по-прежнему работают:

  • выбор детали кликом и её подсветка,
  • снятие отметки Y для этажа,
  • «умное выделение» по габаритам деталей,
  • вписывание камеры в габариты модели.

А рендерер невидимую ветку пропускает целиком, так что стоит она ноль. Подсветка выбранной детали рисуется отдельным мешем, который делит геометрию с оригиналом (данные не копируются) и вынесен вперёд по глубине (polygonOffset), иначе полупрозрачный «призрак» ложился бы поверх неё.

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

3. Матрицы неподвижной модели пересчитывались каждый кадр. Модель из САПР не двигается, но three.js по умолчанию обновляет матрицу каждого узла. После загрузки матрицы считаются один раз, затем matrixAutoUpdate = false (freezeStaticTransforms в ModelLayer.vue). Зон, маркеров и плоскостей этажей это не касается — они живут отдельными объектами и продолжают двигаться.

Дополнительно:

  • плотность пикселей ограничена сверху (dpr = [1, 1.5]): на экране с плотностью 2 рендер идёт вчетверо большем числе пикселей, а на схематичной модели разницы не видно;
  • точка, к которой зумит колесо мыши, кэшируется на 400 мс и 4 px — луч по модели на каждое деление колеса стоил 4.02 мс, стало 0.15 мс;
  • список мешей слоя кэшируется в WeakMap (three/ghosting.ts), чтобы переключение режимов не обходило дерево из тысяч узлов заново.

Проверить на своей машине можно так — сгенерировать заведомо тяжёлую модель и посмотреть, что происходит:

python tools/make_demo_model.py --grid 4 --floors 12 --out heavy.glb

Поведение прокси покрыто тестами: frontend/tests/batching.test.ts — 13 проверок, среди них совпадение габаритов до и после слияния, запекание преобразований родителя, корректность нормалей при неравномерном масштабе и то, что луч по-прежнему попадает в невидимые оригиналы.

4. Схема БД

По разделу 4 ТЗ: Project, User, Sector, Brigade, Task, Problem плюс ProjectModel (слои .glb). Sector.coordinates, Sector.task_ids, Sector.problem_ids, Sector.brigade_ids и User.allowed_project_ids — JSON-массивы (backend/app/models.py).

Связь «сектор → задачи/проблемы/бригады» хранится массивами ID, а не внешним ключом, как и требует ТЗ. Плата за это: каскады БД тут не работают, поэтому удаление сектора и проекта чистит задачи и проблемы явно, а битые ID вычищаются из массивов при пересчёте (services.prune_missing_ids — он же снимает бригаду, уехавшую в другой проект).

Что изменилось во второй итерации:

Было Стало Зачем
Sector.brigade_id (одна бригада) Sector.brigade_ids (массив) на зоне работает несколько бригад
Sector.height объём зоны; 0 — прежняя плоская зона
Project.model_url (одна модель) таблица project_models несколько .glb-слоёв на сцену
роли admin / user admin / contractor / reader «Подрядчик» и «Читатель»
User.email, email_verified, поля кода привязка и подтверждение почты
таблица attachments файлы задач и проблем
таблица levels этажи (уровни) объекта
Sector.top_coordinates правленая верхняя грань; NULL — верх повторяет основание

Вложение связано с карточкой парой (card_kind, card_id), а не двумя внешними ключами: задачи и проблемы — разные таблицы, и одна нулевая колонка из двух в каждой строке читалась бы хуже, чем явный вид карточки. Удаление задачи или проблемы уносит её вложения вместе с файлами — иначе rowid, который SQLite переиспользует, подсунул бы новой карточке чужие файлы.

Project.model_url сохранена и поддерживается равной первому слою: по ней список проектов показывает, загружена ли модель. Правило её значения одно — первый слой или NULL (services.sync_primary_model).

Миграция существующей базы. create_all создаёт только отсутствующие таблицы и никогда не меняет существующие, а база у заказчика уже с данными. Поэтому изменения схемы выполняет app/migrate.py — вручную, идемпотентно и без потери данных, при каждом старте приложения:

  • добавляет sectors.brigade_ids и переносит в него прежний brigade_id;
  • добавляет sectors.height со значением 0;
  • переносит projects.model_url в project_models;
  • переименовывает роль user в contractor;
  • добавляет поля почты в users.

Старая колонка brigade_id намеренно не удаляется: DROP COLUMN в SQLite перестраивает таблицу, а выгода нулевая — колонка просто остаётся пустой и ORM её больше не знает. Повторный запуск ничего не перезаписывает, в том числе если бригады уже поменяли после первой миграции (проверено тестами tests/test_migrate.py, в том числе на копии боевой базы).

5. API и реальное время

Расчёт живёт на бэкенде (app/services.py, app/progress.py). На запрос сектора FastAPI достаёт задачи и проблемы по массивам ID, считает progress_percent, определяет задачи в статусе in_progress, подставляет бригаду по brigade_id и отдаёт готовую сводку.

Правило расчёта: статус приоритетнее процента — done это всегда 100 %, todo всегда 0 %, промежуточное значение читается только у in_progress. Процент сектора — среднее по задачам, округлённое до 0,1.

Основные эндпоинты:

Метод Путь Назначение
POST /api/auth/login вход, выдача JWT
GET /api/auth/me текущий пользователь
GET/POST /api/users пользователи (админ)
PATCH/DELETE /api/users/{id} роль, пароль, доступы к проектам
GET/POST /api/projects проекты (список фильтруется по доступам)
GET/POST /api/projects/{id}/models слои .glb: список и загрузка
PATCH/DELETE /api/projects/{id}/models/{mid} переименование и удаление слоя
GET /api/projects/{id}/snapshot весь слепок сцены одним запросом
GET/POST /api/projects/{id}/brigades бригады
POST /api/projects/{id}/brigades/bulk/delete массовое удаление бригад
GET/POST /api/projects/{id}/sectors зоны (в теле — height и brigade_ids)
POST .../sectors/{sid}/brigades добавить бригаду — сюда приходит drag-and-drop
PUT .../sectors/{sid}/brigades заменить весь состав бригад
DELETE .../sectors/{sid}/brigades/{bid} снять одну бригаду
PATCH .../sectors/{sid} название, координаты, высота
POST .../sectors/bulk/tasks одна задача сразу в нескольких зонах
POST .../sectors/bulk/problems одна проблема сразу в нескольких зонах
PUT .../sectors/bulk/brigades один состав бригад на несколько зон
POST .../sectors/bulk/delete массовое удаление зон
POST/PATCH/DELETE .../sectors/{sid}/tasks[/{tid}] задачи
POST/PATCH/DELETE .../sectors/{sid}/problems[/{pid}] проблемы
GET/POST /api/account, /api/account/password профиль и смена пароля
POST/DELETE /api/account/email[/confirm] привязка и подтверждение почты
GET/POST .../cards/{kind}/{id}/attachments вложения задачи или проблемы
DELETE /api/projects/{id}/attachments/{aid} удалить вложение
GET /api/projects/{id}/recipients кому можно слать письма
POST .../cards/{kind}/{id}/notify разослать письмо о карточке
GET/POST /api/projects/{id}/levels этажи (уровни)
GET /api/projects/{id}/export.xlsx выгрузка задач и проблем
WS /ws/projects/{id}?token=… канал обновлений
GET /media/models/{file}?token=… .glb с проверкой доступа
GET /media/attachments/{file}?token=… вложение с проверкой доступа

Добавление бригады — точечная операция (POST), а не PUT со всем списком: список дочитывается и меняется на сервере, поэтому две одновременные пересадки бригад на одну зону не затирают друг друга.

Массовые маршруты объявлены до маршрутов с /{sector_id}: путь bulk не проходит проверку типа int, а FastAPI на неудачной валидации возвращает 422, не пробуя следующий маршрут. Всё массовое действие выполняется одной транзакцией, а события рассылаются после коммита — иначе половина зон осталась бы изменённой при сбое, а зрители увидели бы данные, которых ещё нет в базе.

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

WebSocket. Любое изменение рассылается всем, кто смотрит на проект: изменил один прораб — цифры и цвета обновились у остальных без перезагрузки. Если сокет не поднимается (прокси, firewall), клиент автоматически падает на резервный опрос раз в 10 секунд — требование ТЗ выполняется в обоих случаях. Индикатор режима виден в верхней панели.

6. Роли, личный кабинет и доступы

Роль Что может
admin всё: проекты, пользователи, слои моделей, доступы
contractor «Подрядчик» — работа внутри разрешённых проектов: зоны, бригады, задачи, проблемы
reader только чтение: камера, выбор объектов, карточки, слои и прозрачность

Роль user переименована в contractor; существующие записи переводит миграция. Значение хранится строкой (Enum(native_enum=False)), поэтому менять тип колонки не понадобилось.

Личный кабинет (/account) доступен всем ролям, включая «Читателя»: это управление своей учётной записью, а не данными объекта, поэтому роутер account намеренно не закрыт EditorGuard. Отдельной кнопки «Администрирование» в шапках больше нет — этот раздел открывается из ЛК.

В кабинете: смена пароля (обязательно со вводом текущего — иначе оставленная без присмотра сессия позволяла бы увести учётную запись) и привязка почты с подтверждением шестизначным кодом. Код хранится хешем: доступ к базе не должен давать возможности подтвердить чужой адрес. Срок жизни — 30 мин, не больше 5 попыток ввода.

6.1 Вложения, таймер и рассылка

  • Файлы карточек. К задаче и проблеме прикладываются файлы — и при создании, и при редактировании. В форме создания файлы копятся на клиенте и уходят на сервер сразу после создания карточки: пока карточки нет, привязать их не к чему, а «ничьи» загрузки пришлось бы убирать сборщиком мусора. Исполняемые расширения не принимаются, размер ограничен APP_MAX_ATTACHMENT_MB. Раздача — через /media/attachments/{файл}?token= с той же проверкой доступа, что и у моделей.
  • Таймер активности (lib/elapsed.ts) показывает, сколько времени прошло с создания, в часах и минутах, и обновляется сам раз в 30 с — один общий таймер на приложение, а не по одному на карточку. Метку времени из SQLite разбираем как UTC явно: без суффикса Z браузер считает её локальной, и отсчёт уезжал бы на величину часового пояса.
  • Рассылка. В форме карточки есть поле адресатов со списком пользователей, у которых подтверждена почта, и поиском по логину. После сохранения на выбранные адреса уходит письмо со всеми данными карточки. Отправка отделена от создания: письмо — уведомление, а не транзакция, и его сбой не должен отменять заведённую задачу.
  • Выгрузка в Excel — кнопка «⤓ Excel» в панели (на телефоне — в «Ещё»). Бэкенд собирает задачи и проблемы проекта в книгу с двумя листами (openpyxl), включая зону, бригады, статус, время создания и «сколько висит». Книга скачивается через fetch с заголовком авторизации, чтобы токен не попадал в адресную строку и логи прокси.

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

Роль «Читатель» запрещена к записи на бэкенде: зависимость deps.deny_reader_writes навешана на роутеры целиком и отклоняет любой POST, PUT, PATCH и DELETE с кодом 403. Проверка построена на методе запроса, а не на имени обработчика, — новый маршрут получает защиту автоматически, а забыть её на одном из десятка мутаций было бы вопросом времени. Роль читается из БД, а не из JWT: токен живёт 12 часов, и снятая администратором роль иначе действовала бы до конца этого срока.

На фронтенде читателю не показываются кнопки изменения (auth.canEdit), а в тулбаре висит отметка «только чтение». Это удобство, а не защита — запрет обеспечивает бэкенд.


4. Структура проекта

backend/
  app/
    main.py          точка входа, CORS, WebSocket
    config.py        настройки (переменные окружения с префиксом APP_)
    database.py      подключение SQLite, PRAGMA foreign_keys/WAL
    models.py        таблицы по разделу 4 ТЗ
    schemas.py       контракт API (Pydantic v2)
    deps.py          текущий пользователь, роль, доступ к проекту
    security.py      bcrypt + JWT
    progress.py      чистый расчёт прогресса (без ORM, покрыт тестами)
    services.py      сборка сводок, пакетная загрузка для слепка
    migrate.py       идемпотентная доводка схемы существующей базы
    realtime.py      WebSocket-шина
    routers/         auth, users, projects, brigades, sectors, media
  tools/
    make_demo_model.py   генератор демо-.glb (только stdlib, есть параметры)
    validate_glb.py      проверка структуры .glb
    smoke_api.py         сквозная проверка API по HTTP
  tests/
    test_progress.py     расчёт прогресса
    test_services.py     бригады-массивы, слои, слепок (БД в памяти)
    test_migrate.py      миграция на настоящем SQLite-файле
  seed.py

frontend/
  src/
    api/          HTTP-клиент, типы, WebSocket с fallback на polling
    lib/          чистая логика без Vue:
                    drafting.ts   двухэтапная разметка и её отмена
                    selection.ts  Ctrl/Shift-выделение и прозрачность
                    viewport.ts   порог мобильной раскладки
                    smartSelect.ts захват деталей целиком
                    elapsed.ts    таймер активности карточек
    stores/       Pinia: авторизация и состояние проекта (включая Undo)
    three/        геометрия полигонов и объёмов, ghosting, настройки камеры,
                  batching.ts   слияние геометрии слоя для отрисовки
                  шина сцены (общий кадровый цикл и отрисовка по требованию)
    components/   панели бригад/зон/слоёв, сайдбар зоны, 3D-виджеты, тулбар,
                  окно подтверждения, нижняя шторка и панель вкладок (телефон)
      scene/      TresCanvas, мост к three.js, слои моделей, полигоны зон,
                  маркеры вершин, черновик разметки
    views/        вход, список проектов, 3D-вид, администрирование
  tests/
    run.mjs            запуск всех *.test.ts через esbuild (без сети)
    geometry.test.ts   триангуляция и проекции
    volume.test.ts     объём зоны и математика перетаскивания вершин
    drafting.test.ts   двухэтапная разметка и атомарная отмена
    selection.test.ts  мультивыделение и прозрачность
    controls.test.ts   настройки камеры: Pan и зум к точке под курсором
    smart-select.test.ts выделение по деталям и плоские операции под него
    clipping.test.ts   выбор попадания луча с учётом сечения по этажам
    elapsed.test.ts    таймер активности карточек
    batching.test.ts   слияние геометрии: габариты, нормали, выбор деталей
    check-sfc.mjs      статическая проверка .vue без node_modules

5. Тесты и проверки

# бэкенд: 53 теста — прогресс, сервисный слой на БД в памяти, миграция схемы
cd backend && python -m unittest discover -s tests -v

# структура сгенерированной демо-модели
python tools/validate_glb.py storage/models/demo_building.glb

# сквозная проверка API (нужен запущенный сервер и заполненная база)
python tools/smoke_api.py                      # 48 проверок
python tools/smoke_api.py --base http://host:port

# фронтенд: 152 теста — геометрия, объём, разметка, выделение, камера,
# зум, выделение по деталям, сечение по этажам, таймер карточек и слияние
# геометрии
cd frontend && node tests/run.mjs

# синтаксис .vue и баланс тегов без установки зависимостей
node tests/check-sfc.mjs

# полная проверка типов
npm run typecheck

tests/run.mjs собирает каждый *.test.ts через esbuild из node_modules и запускает обычным node — отдельный тест-раннер и доступ в сеть не нужны.

Руководство пользователя

docs/tutorial.html и docs/tutorial.pdf — руководство на 17 разделов: что делает каждая кнопка и как выполняются основные задачи. Пересобирается из одного источника:

cd docs && python build_tutorial.py

Нужен weasyprint (pip install weasyprint); без него соберётся только HTML.

Особенность: фрагменты интерфейса в руководстве настоящие — та же разметка и тот же CSS, что в приложении, снятые из работающей системы в docs/capture.json. Поэтому руководство не «похоже» на интерфейс, а показывает его буквально. При изменении стилей достаточно пересобрать; при изменении самих панелей материал снимается заново.

Для печати элементы форм (button, input, select) заменяются на span с теми же классами: движок печати рисует настоящие элементы управления своей вёрсткой, из-за чего подписи кнопок съезжают с подложек. В PDF оглавление кликабельное, с номерами страниц, плюс закладки в боковой панели.

Проверено на живом приложении (бэкенд + собранный фронтенд в браузере): двухэтапная разметка с выдавливанием, атомарная отмена по Ctrl+Z, мультивыделение Ctrl/Shift, массовое заведение задачи в части выделенных зон, переключение прозрачности и скрытие слоёв, окна подтверждения удаления, открытие карточки зоны, вход читателем и отказ 403 на любые изменения, миграция на копии боевой базы.

Мобильный вид проверен в браузере на размере 390×844 (iPhone): шторки вкладок, карточка зоны, полный цикл разметки с выдавливанием и созданием зоны, окно подтверждения удаления, меню «Ещё». Десктопная раскладка проверена там же на 1440×900 — она не изменилась.

Оптимизация 3D-сцены (п. 3.9) проверена на реальной модели объекта (5 543 детали, 924 тыс. треугольников) и на сгенерированной сетке из 16 корпусов (15 697 узлов). После переделки прогонялись заново: выбор детали и её подсветка, режим рентгена, цикл прозрачности всех трёх состояний, скрытие отдельного слоя, две модели в одной сцене, «Сбросить вид», разметка зоны, перетаскивание вершины с сохранением, снятие отметки этажа, отсечение «Выше»/«Ниже», умное выделение (захвачено 999 деталей) и поп-ап зоны, следующий за камерой.

Не проверено: сенсорные жесты (вращение одним пальцем, щипковый зум) на настоящем тач-устройстве — в браузерной проверке доступны только эмулированные клики; модели со сжатием Draco/KTX2 — декодеры не подключены.


6. Настройки окружения

Переменные читаются из окружения или файла backend/.env, префикс APP_:

Переменная По умолчанию Смысл
APP_SECRET_KEY небезопасное значение подпись JWT — обязательно заменить
APP_DATABASE_URL sqlite:///backend/data.db строка подключения
APP_SEED_ADMIN_USERNAME / _PASSWORD admin / admin123 первичный администратор
APP_ACCESS_TOKEN_TTL_MINUTES 720 время жизни токена
APP_MAX_MODEL_MB 512 лимит размера модели
APP_CORS_ORIGINS localhost:5173, :4173 JSON-массив разрешённых источников

7. Что стоит доработать перед продакшеном

  • Миграции. Схема создаётся через create_all, доработки существующих таблиц — вручную в app/migrate.py. Это работает и покрыто тестами, но для дальнейшей эволюции нужен Alembic.
  • Конкурентная запись в task_ids и brigade_ids. Добавление задачи — это read-modify-write JSON-массива без блокировки. При одновременной работе нескольких пользователей в одном секторе возможна потеря записи. Лечится либо блокировкой строки сектора, либо переходом на настоящий внешний ключ Task.sector_id (но это отступление от текста ТЗ). Для бригад риск снижен: добавление и снятие — точечные операции на сервере, а не отправка всего списка с клиента.
  • Отмена не переживает перезагрузку страницы — стек живёт в памяти вкладки. Для «истории изменений» этого мало, нужен серверный журнал.
  • Массовые действия не атомарны между собой. Каждое выполняется одной транзакцией, но два одновременных массовых назначения на пересекающиеся наборы зон дадут результат «кто последний, тот и прав».
  • Тяжёлые модели. Скрытый слой выгружается из видеопамяти, но LOD и инстансинг для выгрузок в десятки тысяч мешей всё ещё нужны.
  • Refresh-токены. Сейчас один access-токен на 12 часов, без отзыва.
  • LOD и инстансинг для тяжёлых моделей из Revit (десятки тысяч мешей).
  • История изменений по задачам и проблемам — ТЗ её не требует, но на стройке она обычно нужна.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages