Прототип внутренней корпоративной платформы по ТЗ: 3D-модели объекта (.glb из
Revit) слоями, разметка объёмных зон/секторов кликами, назначение бригад
перетаскиванием, задачи и проблемы по секторам, расчёт процента выполнения на
бэкенде и обновление цифр на модели без перезагрузки страницы.
Вторая итерация добавила: двухэтапную разметку с выдавливанием объёма, перетаскивание вершин уже созданной зоны, несколько .glb-слоёв с панелью «Слои», мультивыделение зон и бригад с массовыми действиями, переключатель прозрачности, окна подтверждения удаления, атомарную отмену последнего действия, несколько бригад на одной зоне и роль «Читатель».
| Слой | Стек |
|---|---|
| Бэкенд | FastAPI · SQLAlchemy 2.0 · SQLite · PyJWT · bcrypt |
| Фронтенд | Vite · Vue 3 · TypeScript · Pinia · vue-router · TresJS (@tresjs/core) · three.js |
Нужны 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:5173Vite проксирует /api, /media и /ws на http://127.0.0.1:8000, поэтому CORS
в разработке не мешает. Другой адрес бэкенда задаётся переменной
VITE_BACKEND_URL.
| Логин | Пароль | Роль |
|---|---|---|
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.
- Войти администратором → «Администрирование».
- Создать проект (или взять демо-проект).
- В строке проекта — «+ Добавить .glb». Можно выбрать сразу несколько файлов: каждый станет отдельным слоем сцены (АР, КЖ, ОВ и т. д.).
- Открыть проект: камера сама впишет модели в кадр.
Слоями управляет панель «Слои» прямо в 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 сжатия не использует.
Настройки камеры вынесены в frontend/src/three/controls.ts и покрыты тестами
(tests/controls.test.ts): «включён ли Pan» — требование, которое иначе
проверяется только руками, а ломается одной строкой.
| Жест | Действие |
|---|---|
| левая кнопка | вращение вокруг цели |
| правая кнопка | перемещение картинки (Pan) |
| средняя кнопка | перемещение картинки (Pan) — как в Revit и AutoCAD |
| колесо | зум к точке под курсором, а не к центру экрана |
| два пальца | сдвиг и щипковый зум одновременно |
Зум к курсору. Луч из курсора даёт точку на модели или зоне, и вся связка
«камера + цель вращения» масштабируется относительно неё
(controls.ts → zoomTowardPoint). Точка под курсором остаётся на том же месте
экрана — это и проверяет тест. Цель вращения едет вместе с камерой намеренно:
если оставить её на месте, при наезде на край модели орбита продолжила бы
крутиться вокруг точки, которой уже нет в кадре.
Если под курсором пусто (небо, фон), якорем становится точка на плоскости, проходящей через текущую цель перпендикулярно взгляду, — зум ведёт себя как обычный, но в сторону курсора.
Штатный зум OrbitControls не отключается (на нём держится щипковый зум двумя
пальцами) — вместо этого колесо перехватывается на родителе канваса в фазе
перехвата. Так свой обработчик гарантированно срабатывает раньше штатного
независимо от порядка регистрации, и зум не применяется дважды — к курсору и
к центру.
Панорамирование экранное (screenSpacePanning): картинка едет параллельно
экрану, а не по плоскости земли — иначе на виде сверху сдвиг «вбок» уводил бы
камеру вниз. Демпфирование включено, камера не проваливается под землю.
«Сбросить вид» вписывает в кадр все слои сразу
(three/ghosting.ts → fitCameraToObjects).
frontend/src/three/batching.ts. Клик по элементу модели делает всю остальную
геометрию полупрозрачной (opacity 0.1, depthWrite: false), а выбранный
элемент получает подсвеченный материал. Клик по сектору приглушает всю модель
целиком, чтобы зона читалась насквозь. Исходные материалы запоминаются на
слитых мешах и возвращаются при сбросе (Esc или «Сбросить»).
Материалы переключаются не на исходных мешах, а на рендер-прокси — слитой копии геометрии слоя (см. п. 3.9): рисуется именно она, оригиналы остаются невидимыми целями для луча.
Клик разрешается сравнением дистанций raycast'а: если элемент модели ближе зоны, выделяется он, а не спрятанная за ним зона.
SceneBridge.vue кидает луч по модели и отдаёт точку попадания;
lib/drafting.ts ведёт состояние черновика; three/geometry.ts строит из
него геометрию.
Шаг 1 — площадь. Кликами обводится контур зоны.
- Количество точек не ограничено.
- Триангуляция — отсечением ушей (ear clipping) по проекции на плоскость, найденную методом Ньюэлла. Невыпуклые контуры поддерживаются, вырожденные не зацикливают алгоритм.
- Зоны можно размечать не только на перекрытиях, но и на вертикальных поверхностях — нормаль считается по фактическому положению точек.
Шаг 2 — объём. Кнопка «Задать объём →» переводит черновик в режим
выдавливания: ползунок и поле задают высоту, в сцене сразу виден будущий
объём с рёбрами. Кнопка «Закрепить плоской» оставляет зону без объёма — так
выглядят все зоны, созданные до этой доработки (height = 0).
Выдавливание строго вертикальное (geometry.ts → buildPrismGeometry).
Получается замкнутая призма: низ, верх и боковины. Обход граней согласован —
низ смотрит вниз, верх вверх, стенки наружу, — иначе освещение зоны
выворачивается наизнанку. Направление боковых граней зависит от того, в какую
сторону пользователь обходил контур, поэтому знак площади основания в
плоскости XZ учитывается отдельно; это закреплено тестами
(tests/volume.test.ts проверяет объём меша, его замкнутость и то, что все
нормали смотрят наружу при любом порядке кликов).
Снятие отметок живёт в отдельном режиме: кнопка «⇱ Выделение этажей» в панели «Этажи». Пока режим выключен, клик по детали ничего об уровнях не сообщает. Раньше отметка снималась при ЛЮБОМ выборе детали, и форма закрепления уровня выскакивала каждый раз, когда человек просто рассматривал модель.
В режиме отметка берётся с низа габаритного ящика детали. Именно низ, а не центр и не точка попадания: колонна стоит на перекрытии, и её основание — это и есть уровень, на котором она смонтирована. Значение можно поправить руками перед закреплением.
Плоскость (scene/LevelPlanes.vue) рисуется только у выбранных в панели
уровней и у ещё не закреплённой черновой отметки. Постоянная сетка
полупрозрачных плит на объекте с десятком этажей закрывала бы саму модель,
ради которой всё и затевается: плоскость — это подсветка выбора, а не
элемент сцены.
Фильтрация видимости сделана плоскостями отсечения рендерера, а не скрытием мешей целиком: требование «показывать только объём между отметками» иначе не выполнить — перекрытие, пересекающее границу, должно обрезаться, а не исчезать вместе с половиной этажа. Плоскости глобальные, поэтому режутся и зоны, и сечение выглядит цельным.
| Кнопка | Что показывает |
|---|---|
| ▲ Выше | только то, что выше выбранной отметки |
| ▼ Ниже | только то, что ниже выбранной отметки |
| ⇕ Между | объём между двумя выбранными уровнями |
Выбор уровня сам режим НЕ включает: иначе первое нажатие на «Выше» выключало бы уже проставленный режим, и кнопка казалась бы сломанной. Больше двух уровней не держим — «между» осмысленно ровно для пары, а третий выбор вытесняет самый старый.
Луч уважает сечение. Отсечение — операция чисто визуальная: плоскости
живут в рендерере, а Raycaster о них не знает и продолжает попадать в
срезанную геометрию. Из-за этого разметка между этажами уезжала на крышу:
на экране её нет, но луч упирался в неё первой. Теперь из списка попаданий
берётся первое в видимом диапазоне (three/clipping.ts → pickWithinClip, покрыт тестами). Функция встроена один раз в
SceneBridge.castAgainst(), поэтому сечение уважают сразу все пути:
постановка точки, выбор зоны и детали, снятие отметки, якорь зума, захват
маркера вершины и приём перетаскиваемой бригады.
Отдельно обрабатывается клик по пустоте разреза: между этажами часть площади — это проём на месте срезанного перекрытия. Точка кладётся на саму плоскость среза, иначе контур рвался бы на проёмах. Два ограничения, без которых запасной вариант вредит больше, чем помогает:
- он включается, только если луч реально упёрся в срезанную геометрию (были попадания, но все вне диапазона). Плоскость среза бесконечна, и клик мимо здания — по фону у горизонта — иначе ставил бы опорную точку в сотнях метров от объекта, где её не видно даже маркером;
- в режиме «Ниже» запасного варианта нет вовсе: там срез идёт сверху, и его плоскость — это потолок над размечаемой поверхностью. Точка на нём оказалась бы выше остальных и перекосила бы зону.
Кнопка «✨ Выделение по деталям». Пользователь кликает по деталям модели — каждая подсвечивается сразу, повторный клик снимает; затем «Задать объём →» переводит на шаг высоты, «Закрепить зону» создаёт зону.
Первая редакция работала иначе: контур обводился по модели, а система сама решала, какие детали он задел. Решение приходилось угадывать — контур ложился на поверхность, деталь могла уходить вверх или вниз от неё, и допуск по вертикали то захватывал лишнее, то терял нужное. Результат пользователь видел только после закрепления зоны. Теперь выбор явный и виден на каждом шаге.
Как считается (lib/smartSelect.ts, покрыт тестами):
- граница зоны — выпуклая оболочка следов выбранных деталей: плотнее общего прямоугольника и не тянет зону на пустое место между разнесёнными объектами;
- основание кладётся на низ самой низкой детали, предлагаемая высота — до верха самой высокой, округляется до сантиметров и правится на шаге объёма;
- вырожденный набор (одна деталь нулевой площади, детали строго по одной линии) зоны не образует — вместо пустой зоны возвращается null.
Подсветка набора рисуется мешами, которые делят геометрию с оригиналами
(three/batching.ts → setHighlight принимает список), поэтому выбор десятка
деталей ничего не копирует.
«Шаг назад» внутри режима откатывает по одному действию: сначала переход к объёму, потом последнюю выбранную деталь. На телефоне оба шага живут в нижней панели — теми же кнопками, что и у обычной разметки.
Есть исключение из общего правила «повторный клик снимает выбор»: в режиме правки границ обычный клик по единственной выбранной зоне выбор не снимает. Именно в эту зону и целятся, промахнувшись мимо маркера, — и маркеры вершин исчезали бы прямо из-под курсора. Выйти можно Esc или кнопкой режима.
Кнопка «Правка границ» (в тулбаре и в карточке зоны) показывает у выбранной
зоны жёлтые маркеры вершин — их можно перетаскивать мышью
(scene/VertexHandles.vue + обработчики в SceneBridge.vue).
- Вершина удерживается в плоскости своей зоны: экранный луч курсора
пересекается с этой плоскостью (
geometry.ts → rayPlaneIntersection). Иначе полигон перестал бы быть плоским и триангуляция поплыла бы. - Пока идёт перетаскивание, орбита камеры отключена, а указатель захвачен
(
setPointerCapture) — курсор может уйти за пределы канваса. - На сервер уходит только результат: в сцене координаты меняются покадрово, запрос уходит один, при отпускании кнопки.
- Маркеры рисуются без
depthTest: вершина за стеной остаётся доступной, иначе часть границы нельзя было бы поправить, не облетев здание.
Размер маркера считается от площади зоны: фиксированный радиус на объекте в сотню метров превращается в невидимую точку, а на зоне 2×2 м закрывает её целиком.
Маркеры стоят на двух кольцах. Жёлтые — основание, голубые — верхняя грань объёма (у плоской зоны верхнего кольца нет). Разный цвет обязателен: на виде сверху кольца иначе сливаются, и непонятно, за какую точку тянешь.
Верхние точки правятся независимо от нижних — этого и требует ТЗ, чтобы сектор
огибал сложные элементы здания. Первое движение верхней вершины «материализует»
грань: до него верх хранится как «основание + высота» (top_coordinates = NULL),
и все прежние зоны остаются ровными призмами без единой записи в данных.
Верхняя вершина ходит в горизонтальной плоскости своей грани — так же, как
нижняя ходит в плоскости основания.
Число вершин верха обязано совпадать с основанием: боковины строятся парами «вершина низа — вершина верха». Если основание переразметили, правка верха сбрасывается к ровному выдавливанию — иначе зона развалилась бы.
Прежняя реализация держала точки разметки в общем стеке отмены и при выходе из режима вычищала оттуда все записи о точках разом — из-за этого «последним» действием оказывалось не то, что сделал пользователь, а кнопка молча срабатывала впустую.
Теперь черновик разметки имеет собственную отмену (lib/drafting.ts,
покрыт тестами), а стек действий сцены — свою. Порядок строго обратен
действиям пользователя:
| Последнее действие | Что делает «Шаг назад» (↶, Ctrl+Z) |
|---|---|
| поставлена опорная точка | убирает одну последнюю точку |
| выполнено выдавливание | возвращает к правке контура, не теряя точек |
| зона закреплена | удаляет созданную зону целиком (запрос к API) |
| перетащена вершина | возвращает прежние координаты |
| зона переименована | возвращает прежнее название |
| бригады назначены/сняты | возвращает прежний состав бригад |
| массовое назначение бригад | возвращает состав всем зонам за один шаг |
Удаление зоны в стек отмены не попадает намеренно: восстановить её вместе с задачами, проблемами и их историей уже нельзя, поэтому удаление защищено окном подтверждения, а не отменой. Записи об удалённой зоне вычищаются из стека — иначе «Шаг назад» иногда молча ничего не делал бы.
Esc выходит из режима разметки, отбрасывая черновик.
- Drag-and-drop. Панель слева (
BrigadePanel.vue), карточки перетаскиваются мышью прямо на полигон в 3D (dragoverведёт raycast и подсвечивает цель) или на 3D-виджет сектора. Бригаду можно перетащить и с одного виджета на другой — это переназначение между секторами. - Несколько бригад на одной зоне. Связь хранится массивом
brigade_ids. Перетаскивание бригады на зону добавляет её к уже назначенным, а не заменяет их; снять можно как одну бригаду (крестик на чипе в карточке), так и все сразу. - Контекстная панель сектора (
SectorSidebar.vue): название, список бригад с бригадирами и численностью, процент выполнения, площадь и объём зоны, ползунок высоты, кнопки «Добавить задачу» и «Добавить проблему», списки задач (статус + ползунок процента) и проблем (чекбокс «решена»), явная кнопка «🗑 Удалить зону».
Зоны, бригады и слои выделяются одинаково (lib/selection.ts, покрыт
тестами):
| Действие | Результат |
|---|---|
| обычный клик | оставить только эту строку |
| повторный обычный клик по ней же | снять выбор |
| Ctrl/Cmd + клик | добавить или убрать одну строку |
| Shift + клик | выбрать диапазон от точки опоры |
| Ctrl/Cmd + A | выбрать все зоны |
Зоны выбираются и кликом по модели, и строкой в списке «Зоны» — модификаторы работают одинаково в обоих местах. Серия Shift-кликов отсчитывается от одной опоры (как в файловых менеджерах), а исчезнувшая опора не выделяет случайный кусок списка.
Повторный клик снимает выбор — так же, как в панели этажей. Иначе
выделение нечем было сбросить, кроме клика по пустому месту сцены, которого в
списках просто нет. Условие именно «ровно эта одна строка»: если выбрано
несколько, обычный клик по одной из них сводит выбор к ней, а не обнуляет всё.
Из этого следуют две поправки: поп-ап зоны не реагирует на обычный клик
(он висит над выбранной зоной и убирал бы сам себя из-под курсора), а кнопка
«⤢» по-прежнему только ОТКРЫВАЕТ карточку — она ходит через openSectorCard,
а не через обычный выбор.
Когда выбрано больше одной зоны, правая панель превращается в панель массовых действий:
- галочками отмечается, к каким именно из выделенных зон применить действие (по умолчанию — ко всем, заголовок показывает «в 2 из 5»);
- «Добавить задачу» / «Добавить проблему» заводят запись сразу в отмеченных зонах. В каждой зоне создаётся своя запись: прогресс у зон разный, а общая запись означала бы, что отметка «готово» в одной зоне закрывает работу во всех остальных;
- «Бригады на все выбранные» задаёт одинаковый состав бригад;
- «Удалить выбранные зоны» — одно окно подтверждения на весь набор.
Бригады выделяются так же: их зоны подсвечиваются в сцене, а над списком стоит тот же набор кнопок, что и у зон, — «Выбрать все» / «Снять выбор» и «Удалить выбранные» (одним запросом, сняв со всех зон). Одинаковые списки — одинаковые действия на одинаковых местах.
Назначение бригады перетаскиванием работает в трёх местах: на зону в
3D-сцене, на её поп-ап и на строку в списке зон. Последнее — потому что в
плотной застройке попасть курсором в нужную зону тем труднее, чем она мельче,
а в списке зона всегда на виду и названа. Подсветка строки-приёмника идёт
через ту же шину сцены (three/sceneBus.ts), что и подсветка зоны в 3D:
тащат одну карточку — подсвечиваться должна одна цель, где бы курсор ни был.
ConfirmDialog.vue заменил window.confirm: он перечисляет, что именно
удаляется, оформляет кнопку как опасную и закрывается по Esc. Подтверждение
запрашивается при удалении зоны, бригады и слоя модели — и по одному, и
пакетом.
Раскладка переключается по ШИРИНЕ экрана (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, которого на тач-устройствах нет. Функция при этом доступна: бригады назначаются из карточки зоны («+ Добавить бригаду») и массово из панели выбранных зон.
- Поп-ап зоны показывается только когда зона выбрана. На объекте с
полусотней зон постоянные подписи перекрывают саму модель и друг друга;
выбранных же зон единицы. Открыть карточку зоны, не выбирая её в сцене,
можно кнопкой
⤢в списке зон. - Прозрачность переключает по кругу: обычный вид → полупрозрачный →
скрытый. Из верхней панели кнопка убрана: там ей приходилось дописывать
область действия (
слои (2),зоны (1),всю модель (3)), потому что иначе было непонятно, на что нажимаешь. Управление осталось там, где виден сам объект — кнопкой в панели «Слои» (действует на выбранные слои, а без выбора на все) и значком-глазом в каждой строке списков слоёв и зон. - Смешанное выделение первым нажатием приводится к одному состоянию — иначе кнопка работала бы как рассинхронизация: часть объектов гасла бы, часть проявлялась.
Порядок панелей в левой колонке — «Зоны → Бригады → Этажи»: зоны главный объект работы, бригады назначаются на них, этажи нужны эпизодически, при разборе по отметкам.
Режимы взаимодействия — их четыре, и они взаимоисключающие, потому что все
четыре претендуют на один и тот же клик по модели: разметка, правка границ,
выделение по деталям, выделение этажей. Гасит их единственный помощник
stores/project.ts → exitModesExcept(keep); «Просмотр» — не режим, а главный
выключатель, он гасит все и запрещает включение. Без общего помощника каждый
новый режим приходилось бы дописывать в четыре чужие функции, и на пятом это
забыли бы. Esc снимает ровно один слой: окно → шторка → режим → выделение.
Выгрузка из 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 ТЗ: 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, в том числе на копии боевой базы).
Расчёт живёт на бэкенде (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 секунд — требование ТЗ выполняется в обоих случаях. Индикатор режима виден в верхней панели.
| Роль | Что может |
|---|---|
admin |
всё: проекты, пользователи, слои моделей, доступы |
contractor |
«Подрядчик» — работа внутри разрешённых проектов: зоны, бригады, задачи, проблемы |
reader |
только чтение: камера, выбор объектов, карточки, слои и прозрачность |
Роль user переименована в contractor; существующие записи переводит
миграция. Значение хранится строкой (Enum(native_enum=False)), поэтому
менять тип колонки не понадобилось.
Личный кабинет (/account) доступен всем ролям, включая «Читателя»: это
управление своей учётной записью, а не данными объекта, поэтому роутер
account намеренно не закрыт EditorGuard. Отдельной кнопки
«Администрирование» в шапках больше нет — этот раздел открывается из ЛК.
В кабинете: смена пароля (обязательно со вводом текущего — иначе оставленная без присмотра сессия позволяла бы увести учётную запись) и привязка почты с подтверждением шестизначным кодом. Код хранится хешем: доступ к базе не должен давать возможности подтвердить чужой адрес. Срок жизни — 30 мин, не больше 5 попыток ввода.
- Файлы карточек. К задаче и проблеме прикладываются файлы — и при
создании, и при редактировании. В форме создания файлы копятся на клиенте и
уходят на сервер сразу после создания карточки: пока карточки нет, привязать
их не к чему, а «ничьи» загрузки пришлось бы убирать сборщиком мусора.
Исполняемые расширения не принимаются, размер ограничен
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), а в
тулбаре висит отметка «только чтение». Это удобство, а не защита —
запрет обеспечивает бэкенд.
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
# бэкенд: 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 typechecktests/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 — декодеры не подключены.
Переменные читаются из окружения или файла 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-массив разрешённых источников |
- Миграции. Схема создаётся через
create_all, доработки существующих таблиц — вручную вapp/migrate.py. Это работает и покрыто тестами, но для дальнейшей эволюции нужен Alembic. - Конкурентная запись в
task_idsиbrigade_ids. Добавление задачи — это read-modify-write JSON-массива без блокировки. При одновременной работе нескольких пользователей в одном секторе возможна потеря записи. Лечится либо блокировкой строки сектора, либо переходом на настоящий внешний ключTask.sector_id(но это отступление от текста ТЗ). Для бригад риск снижен: добавление и снятие — точечные операции на сервере, а не отправка всего списка с клиента. - Отмена не переживает перезагрузку страницы — стек живёт в памяти вкладки. Для «истории изменений» этого мало, нужен серверный журнал.
- Массовые действия не атомарны между собой. Каждое выполняется одной транзакцией, но два одновременных массовых назначения на пересекающиеся наборы зон дадут результат «кто последний, тот и прав».
- Тяжёлые модели. Скрытый слой выгружается из видеопамяти, но LOD и инстансинг для выгрузок в десятки тысяч мешей всё ещё нужны.
- Refresh-токены. Сейчас один access-токен на 12 часов, без отзыва.
- LOD и инстансинг для тяжёлых моделей из Revit (десятки тысяч мешей).
- История изменений по задачам и проблемам — ТЗ её не требует, но на стройке она обычно нужна.