# Heart Atlas. Этап 1: 3D-модель сердца

Дата: 11.10.2026
Статус: принята
Связано: этап 1 «Сердце в 3D» из плана MVP, спека этапа 0 `docs/plans/2026-10-11-stage-0-foundation.md`

## Цель

Одна качественная модель нормального сердца для браузера, разбитая на именованные анатомические структуры, с лицензией, совместимой с CC BY-SA 4.0, и воспроизводимым конвейером от исходных файлов до сжатого GLB. На id структур ссылаются статьи (`structures: [left-atrium, mitral-valve]`), поэтому имена — часть публичного контракта.

Критерий готовности этапа из плана: модель до 3 МБ после сжатия, 60 fps на среднем телефоне, работает 2D-фолбэк.

Эта спека описывает модель и конвейер (PR 2–3 этапа). Вьюер, срез и страница анатомии (PR 4–6) получат короткие спеки, когда модель будет готова.

## Что входит

1. Выбор источника и проверка его лицензии.
2. Список структур и их id.
3. Сборка исходной модели из файлов источника, с единым именованием.
4. Собственные меши для того, чего в источнике нет (проводящая система).
5. Конвейер сжатия gltf-transform, LOD и бюджеты.
6. Реестр лицензий `assets/models/LICENSES.yaml` и проверки в CI.

## Что не входит

Вьюер и взаимодействие (камера, выбор, подписи, срез), анимация сокращения (этап 2, «часы сердца»), модели пороков (после MVP), текстуры с фотореализмом.

## Кандидаты

Проверено 11.10.2026. Лицензии процитированы со страниц источников.

| Источник | Лицензия | Формат | Что есть для сердца | Вывод |
|---|---|---|---|---|
| [BodyParts3D 4.0](https://dbarchive.biosciencedbc.jp/en/bodyparts3d/download.html) (DBCLS) | [CC BY 4.0](https://dbarchive.biosciencedbc.jp/en/bodyparts3d/lic.html) в текущем архиве; старый сайт и производные указывают [CC BY-SA 2.1 JP](https://lifesciencedb.jp/bp3d/info/license/index.html) | OBJ, архив `partof_BP3D_4.0_obj_99.zip` (62 МБ) | 4 камеры (стенки предсердий, полости желудочков), 4 клапана с отдельными створками, папиллярные мышцы, аорта, лёгочный ствол, полые и лёгочные вены, коронарные артерии с ветвями, вены сердца и коронарный синус. Id FMA у каждой части | **Основа** |
| [HRA, reference organ «Heart, Male/Female»](https://3d.nih.gov/entries/3DPX-021000) (HuBMAP, по Visible Human) | CC BY 4.0 | GLB | Камеры, клапаны, перегородка, папиллярные мышцы, id UBERON. Коронарных сосудов и проводящей системы в доступной части графа не видно, полный список не проверен | Запасной вариант и сверка |
| [Z-Anatomy](https://github.com/Z-Anatomy/Models-of-human-anatomy) | CC BY-SA 4.0 | Шаблон Blender | Сердце взято из BodyParts3D; что добавлено поверх, не проверено | Сверка, не основа |
| [Open Anatomy, MAL Thorax](https://openanatomy.org/atlas-pages/atlas-mauritania-thorax.html) | «3D Slicer License, section B», не CC | Сегментация КТ | Грудная клетка с сосудами, детализация сердца не проверена | Не берём: лицензия не сводится к CC BY-SA |
| [NIH 3D](https://3d.nih.gov/terms) | Выбирает автор модели | Разный | Других открытых моделей нормального сердца, кроме HRA, не нашли | Проверять каждую модель отдельно |
| [Visible Heart Lab Atlas](http://www.vhlab.umn.edu/atlas/) | «All rights reserved», только для обучения | — | — | Не берём |
| [TotalSegmentator](https://github.com/wasserth/TotalSegmentator) | Код Apache-2.0; детальные задачи по камерам и коронарам под отдельной некоммерческой лицензией | Сегментация КТ | В открытой задаче только сердце целиком и крупные сосуды | Не берём |
| Cardiac Atlas Project | Соглашение о данных, использование только для сегментации | — | — | Не берём |
| Модели на Sketchfab | Чаще всего CC BY-NC-SA (например, производные BodyParts3D от UMCG) | — | Сердца с проверенной CC BY и именованными частями не нашли | Не берём |

### Почему BodyParts3D

- Самое подробное разбиение из открытых источников: створки клапанов, папиллярные мышцы, коронарные артерии и вены как отдельные объекты.
- У каждой части есть id FMA (Foundational Model of Anatomy), это даёт стабильную связь с терминологией и переводами.
- OBJ легко собрать в GLB, формат не привязан к Blender.
- Тот же путь уже прошёл Z-Anatomy, который распространяет производную BodyParts3D под CC BY-SA 4.0. Это пример, а не юридический довод; довод в разделе «Совместимость лицензий».

Минусы и что с ними делаем:

- Нет проводящей системы. Её делаем сами (раздел «Собственные меши»).
- Нет хорд и отдельных объектов межпредсердной и межжелудочковой перегородок. Хорды в MVP не показываем. Перегородки: в PR 2 проверяем, можно ли выделить их из частей миокарда; если нельзя, перегородка получает в `structures.yaml` тип `zone`: пустой узел-якорь с этим id и список мешей, которые подсвечиваются вместе.
- Геометрия в архиве `_99` прорежена. Если её не хватит на крупном плане, сравниваем с HRA до того, как строить вьюер.

### Совместимость лицензий

| Лицензия источника | В CC BY-SA 4.0 | Основание |
|---|---|---|
| CC0 | Да | Условий нет |
| CC BY 4.0 | Да, с сохранением атрибуции | [CC BY 4.0, §3(a)(4)](https://creativecommons.org/licenses/by/4.0/legalcode.en): лицензия адаптации не должна мешать соблюдать CC BY, а BY-SA атрибуцию сохраняет |
| CC BY-SA 2.1 JP и другие BY-SA 2.x–3.0 | Да | Раздел 5(2) [CC BY-SA 2.1 JP](https://creativecommons.org/licenses/by-sa/2.1/jp/legalcode) разрешает распространять производные под «другой лицензией Creative Commons с теми же элементами, например новой версией этой лицензии»; для BY-SA 2.x CC прямо допускает «a later version» ([Compatible Licenses](https://creativecommons.org/compatiblelicenses)) |
| CC BY-SA 4.0 | Да | Та же лицензия |
| Любая с NC или ND | Нет | ND запрещает адаптации, NC несовместим с BY-SA 4.0, которая разрешает коммерческое использование ([FAQ](https://creativecommons.org/faq/)) |
| Без открытой лицензии, «только для обучения» | Нет | — |

Расхождение у BodyParts3D (CC BY 4.0 в архиве против CC BY-SA 2.1 JP на старом сайте) нам не мешает: обе лицензии совместимы. Мы скачиваем версию 4.0 из текущего архива и указываем обе строки атрибуции (поля `attribution` и `attribution_alt` в `LICENSES.yaml`), чтобы не зависеть от того, какая из них верна.

Код MIT это не затрагивает: модели лежат в `assets/` под `LICENSE-CONTENT`, а код конвейера в `packages/models/` под MIT.

## Список структур

Id в kebab-case на английском, сторона в начале (`left-atrium`, не `atrium-left`), формат `^[a-z0-9]+(-[a-z0-9]+)*$`, уникальны среди всех узлов модели, включая дочерние. Подписи на языках интерфейса лежат в `apps/web/src/i18n/messages`, не в модели. Id FMA для каждой строки взяты из `partof_element_parts.txt` архива, а не по памяти.

| Группа | Id | Источник |
|---|---|---|
| Камеры | `right-atrium`, `left-atrium`, `right-ventricle`, `left-ventricle` | BodyParts3D |
| Перегородки | `interatrial-septum`, `interventricular-septum` | Выделить из миокарда, иначе тип `zone`, см. выше |
| Клапаны | `tricuspid-valve`, `pulmonary-valve`, `mitral-valve`, `aortic-valve` | BodyParts3D; створки склеены в один меш |
| Папиллярные мышцы | `right-papillary-muscles`, `left-papillary-muscles` | BodyParts3D |
| Крупные сосуды | `aorta`, `pulmonary-trunk`, `superior-vena-cava`, `inferior-vena-cava`, `pulmonary-veins` | BodyParts3D |
| Коронарные артерии | `right-coronary-artery`, `left-main-coronary-artery`, `left-anterior-descending-artery`, `circumflex-artery` | BodyParts3D; ветви склеены с артерией |
| Вены сердца | `coronary-sinus`, `cardiac-veins` | BodyParts3D |
| Проводящая система | `sa-node`, `av-node`, `bundle-of-his`, `right-bundle-branch`, `left-bundle-branch`, `purkinje-fibers` | Собственные меши |

Список фиксируется в `assets/models/heart/structures.yaml`: id, тип (`mesh` или `zone`), группа, id FMA, источник, родитель, для `zone` ещё список мешей. Этот файл — единый источник правды: по нему схема контента проверяет поле `structures` у статей, а CI проверяет, что в каждом собранном GLB есть узел с каждым id (для `zone` это пустой узел-якорь).

Число мешей сейчас оценочное: у BodyParts3D створки и ветви артерий отдельные, поэтому объектов будет больше, чем строк в таблице. Фактическое число считаем в PR 2.

Изменить id после того, как на него сослалась статья, можно только с алиасом в `structures.yaml`, чтобы старые ссылки не сломались.

## Собственные меши: проводящая система

Проводящая система нужна этапу 2 (ЭКГ и фибрилляция предсердий ссылаются на `sa-node` и `av-node`), но в открытых моделях её нет. Делаем схематично:

- Узлы и пучки как простые формы (эллипсоиды и трубки по контрольным точкам), положение по описанию в учебной литературе, без претензии на точную гистологию. В интерфейсе подписываем как схему.
- Генерируются `assemble.ts` из файла контрольных точек `conduction.yaml` в Git, чтобы правка была видна в диффе, а не пряталась в бинарнике.
- Лицензия CC BY-SA 4.0, автор — контрибьюторы проекта.
- Источник анатомического положения указываем в `LICENSES.yaml` в поле `notes` (учебник с ISBN или обзор с DOI, проверенный при создании PR). Если источник проверить не удалось, меш не добавляем.

## Конвейер

```
BodyParts3D OBJ ──┐
                  ├─ assemble.ts (Node) ─→ assets/models/heart/heart.glb ─→ gltf-transform ─→ apps/web/public/models/heart-*.glb
контрольные точки ┘   сборка, имена, единицы    исходник в Git, без сжатия   сжатие и LOD перед сборкой сайта
```

### Шаг 1. Загрузка источника

Архив BodyParts3D закреплён в `assets/models/heart/sources.yaml` ссылкой и SHA-256. В Git его не кладём (62 МБ).

Облачная среда разработки не достаёт до `dbarchive.biosciencedbc.jp` (прокси отклоняет соединение, проверено 11.10.2026), поэтому загрузку и сборку выполняет workflow `.github/workflows/models.yml` (запуск вручную, `workflow_dispatch`). Он скачивает архив, сверяет хеш, собирает модель, прогоняет тесты пакета и пушит обновлённый `heart.glb` в новую ветку для PR. Локально то же самое: скачать архив по ссылке из `sources.yaml`, распаковать и запустить `pnpm --filter @heart-atlas/models assemble <папка с OBJ>`.

### Шаг 2. Сборка исходника

`packages/models/src/assemble.ts` на `@gltf-transform/core`, без Blender. В PR 2 оказалось, что Blender не нужен: исходник — набор OBJ, а собственные меши проводящей системы простые (эллипсоиды и трубки), их проще строить кодом. Так у сборки одна зависимость (Node), а не две.

Скрипт:

1. Читает OBJ-файлы элементов, перечисленных в `structures.yaml`, и склеивает их в один меш на структуру.
2. Строит меши проводящей системы из `assets/models/heart/conduction.yaml`.
3. Переводит миллиметры в метры и оси BodyParts3D (Z вверх, +X к левому боку пациента, −Y вперёд) в оси glTF (Y вверх, +Z к зрителю), центрирует по камерам, клапанам и папиллярным мышцам.
4. Называет узел id структуры и кладёт в `extras` узла тип, группу и id FMA.
5. Сваривает вершины (`weld`) и считает сглаженные нормали. Функция `normals()` из gltf-transform не подходит: она пишет плоские нормали и разваривает меш.
6. Назначает материал по группе (камеры, клапаны, артерии, вены, проводящая система), без текстур.

`heart.glb` коммитится. Так CI и контрибьюторы собирают сайт без доступа к источнику. Размер после PR 2: 2,2 МБ без сжатия.

### Что выяснилось при сборке

- В BodyParts3D 4.0 нет отдельного элемента стенки желудочков. Желудочки представлены поверхностью полости (`cavity of right/left ventricle`), предсердия — стенкой. Для вьюера этого хватает: снаружи видна форма сердца, коронарные сосуды лежат на поверхности.
- Створки клапанов склеены в один меш на клапан, мелкие ветви коронарных артерий — в меш своей артерии. Отдельные створки и ветви не нужны ни одной статье MVP, а лишние узлы добавили бы draw calls.
- Перегородки — тип `zone`: пустой узел-якорь между центрами двух камер, вьюер подсвечивает обе камеры и подписывает якорь.
- Из нижней полой вены взят только грудной сегмент, из лёгочных артерий и вен — части у сердца, без внутрилёгочных ветвей.

### Шаг 3. Сжатие и LOD

`packages/models/src/build.ts` на `@gltf-transform/functions` и `meshoptimizer` (оба MIT) запускается скриптом `models` пакета `apps/web` перед `astro dev` и `astro build` и пишет файлы в `apps/web/public/models/` (в `.gitignore`). Сайт грузит их только через `BASE_URL`: `` `${import.meta.env.BASE_URL}models/heart-lod1.glb` ``.

Отдельные функции, а не команда `optimize`: по [исходникам CLI](https://github.com/donmccurdy/glTF-Transform/blob/main/packages/cli/src/cli.ts) она по умолчанию включает `join` для именованных мешей и `palette` для материалов, и id структур теряются.

Порядок: `dedup` → `prune` → `weld` → `simplify` (`MeshoptSimplifier`) для каждого уровня LOD → `meshopt` (`MeshoptEncoder`). Без `join`, `flatten` и `palette`. Отдельный `quantize` не нужен: `meshopt()` в gltf-transform 4.5 квантует сам (проверено в PR 3). Квантование переносит смещение и масштаб каждого меша в матрицу его узла и делает обязательными `KHR_mesh_quantization` и `EXT_meshopt_compression`, поэтому вьюер не должен считать трансформации узлов единичными и обязан подключать `MeshoptDecoder`.

Упрощение по мешам, а не по сцене: маленькие структуры (узлы проводящей системы, створки, тонкие ветви) иначе потеряют все треугольники, и `prune` удалит пустой узел вместе с id. Меши меньше порога треугольников не упрощаем, границы блокируем (`lockBorder`). Проверка id идёт по каждому файлу LOD, а не только по исходнику.

### Meshopt, а не Draco

| | Meshopt (`EXT_meshopt_compression`) | Draco (`KHR_draco_mesh_compression`) |
|---|---|---|
| Декодер (файлы three.js r186, без сжатия) | `meshopt_decoder.module.js` ≈ 29 КБ | `draco_decoder.wasm` ≈ 192 КБ + обёртка ≈ 58 КБ |
| Скорость декодирования | «considerably faster than Draco» ([gltf-transform](https://gltf-transform.dev/modules/extensions/classes/EXTMeshoptCompression)) | Медленнее, зато выше степень сжатия по замыслу авторов ([meshoptimizer](https://github.com/zeux/meshoptimizer)) |
| Поддержка в drei `useGLTF` | Да, через three-stdlib | Да, но декодер по умолчанию грузится с CDN gstatic, его надо раздавать самим через `BASE_URL` |
| Условие | Отдавать с gzip или brotli, иначе выигрыш заметно меньше | — |

Размеры декодеров измерены по файлам three.js r186; drei `useGLTF` берёт свой `MeshoptDecoder` из three-stdlib, его размер в бандле меряем в PR 4 вместе с вьюером. Сравнительных цифр размера модели для нашего случая в первоисточниках нет, поэтому в PR 3 меряем оба варианта на нашей модели и записываем результат в PR. Выбор по умолчанию Meshopt: меньше декодер, быстрее декодирование, не нужен внешний CDN. Используем именно `EXT_meshopt_compression`: в `GLTFLoader` three-stdlib 2.36.1 мы нашли поддержку только этого варианта, а не `KHR_meshopt_compression` (по исходникам, перепроверяем в PR 3).

Бюджет в CI считается по размеру файла на диске, без сжатия при передаче: заголовки GitHub Pages CI не видит. Размер после gzip скрипт считает локально через `zlib` и печатает для сведения. Отдаёт ли GitHub Pages GLB со сжатием, проверяем на деплое PR 3.

### Замеры PR 3

На модели из PR 2 (124 тыс. треугольников, 2,2 МБ без сжатия), 11.10.2026:

| Вариант | Файл | После gzip -9 |
|---|---|---|
| Meshopt, `level: 'high'`, все треугольники (`heart-lod0.glb`) | 581 КБ | 433 КБ |
| Meshopt, `level: 'high'`, упрощение до 50 % в мешах от 2000 треугольников (`heart-lod1.glb`, 80 тыс. треугольников) | 400 КБ | 298 КБ |
| Draco по умолчанию, все треугольники | 400 КБ | 383 КБ |

Draco меньше на треть при той же детализации, но требует декодер около 250 КБ и декодирует медленнее, поэтому оставляем Meshopt. Оба LOD с большим запасом укладываются в бюджет, поэтому вместо `@pmndrs/detect-gpu` (нужны данные бенчмарка с CDN или свои копии) выбор файла в PR 4 делается проще: телефоны и узкие экраны (`pointer: coarse` или ширина до 48rem) получают `heart-lod1.glb`, остальные `heart-lod0.glb`. 2D-фолбэк включается, если браузер не создаёт контекст WebGL.

`build.ts` пишет файлы в `apps/web/public/models/` из скрипта `models` пакета `apps/web`, который запускается перед `astro dev` и `astro build` (pnpm по умолчанию не выполняет `prebuild`). Там же лежит постер `heart-poster.png`: вьюер показывает его, пока не загрузилась модель.

### LOD

Два файла, а не `THREE.LOD` внутри одного: на телефоне не нужно скачивать детальную версию.

| Файл | Для кого | Бюджет |
|---|---|---|
| `heart-lod1.glb` | Телефоны и слабые GPU, грузится первым | ≤ 1,5 МБ, ≤ 100 тыс. треугольников |
| `heart-lod0.glb` | Десктоп и сильные GPU, подгружается после первого кадра | ≤ 3 МБ, ≤ 300 тыс. треугольников |

Ограничение плана «до 3 МБ» относится к самому большому файлу. Треугольники — наше решение, а не отраслевой стандарт: официальных цифр у three.js и Khronos нет, Sketchfab для телефонов советует [меньше 500 тыс. полигонов](https://help.sketchfab.com/en/articles/16152043). Проверяем на реальном среднем телефоне в PR 4 и правим бюджет, если не держим 60 fps.

Draw calls: одна структура — один меш, ожидаем несколько десятков мешей. Это внутри ориентира [100–200 вызовов для слабых телефонов](https://developer.playcanvas.com/user-manual/optimization/guidelines/), поэтому меши не склеиваем: склейка сломала бы выбор и подсветку структур.

Выбор файла и 2D-фолбэк решает вьюер: телефоны и узкие экраны получают `heart-lod1.glb`, остальные `heart-lod0.glb`; если WebGL недоступен, показываем 2D (см. «Замеры PR 3»). Подробности в спеке вьюера.

## Реестр лицензий и проверки в CI

### `assets/models/LICENSES.yaml`

```yaml
- file: assets/models/heart/heart.glb
  title: Heart (normal adult)
  license: CC-BY-SA-4.0
  authors: [Heart Atlas contributors]
  derived_from:
    - name: BodyParts3D 4.0
      url: https://dbarchive.biosciencedbc.jp/en/bodyparts3d/download.html
      license: CC-BY-4.0
      attribution: 'BodyParts3D, © The Database Center for Life Science licensed under CC Attribution 4.0 International'
      also_published_as: CC-BY-SA-2.1-JP
      attribution_alt: 'BodyParts3D, Copyright© 2008 ライフサイエンス統合データベースセンター licensed by CC表示－継承2.1 日本'
      retrieved: 2026-10-XX
      sha256: <архива>
  changes: Selected heart parts, renamed to project ids, merged, rescaled, added schematic conduction system.
  notes: <источник положения проводящей системы с ISBN или DOI>
```

Поле `license` берётся из SPDX-идентификаторов. Атрибуция из `LICENSES.yaml` выводится на странице «Авторы и лицензии» сайта и в подписи вьюера.

### Проверки

Тесты `packages/models/test/` (запускаются в `pnpm test`, собранные LOD проверяет `build.test.ts`) падают, если:

- в `assets/models/` есть GLB без записи в `LICENSES.yaml`, или запись без файла;
- лицензия файла или источника не из списка разрешённых: `CC0-1.0`, `CC-BY-4.0`, `CC-BY-SA-4.0`, `CC-BY-SA-2.1-JP`. Другие лицензии, в том числе портированные BY-SA 3.0 других юрисдикций, добавляются в список отдельным PR с обоснованием;
- у записи `derived_from` нет `url`, `license` или `attribution`, или есть `also_published_as` без `attribution_alt`;
- в каком-то из собранных LOD нет узла для id из `structures.yaml`, или id не уникален, или не подходит под формат;
- собранный файл превышает бюджет по размеру или треугольникам (размер файла на диске; треугольники считает сам `build.ts` по индексам примитивов, для чтения сжатых файлов в `NodeIO` регистрируется `MeshoptDecoder`);
- несжатый `heart.glb` не проходит [glTF-Validator](https://github.com/KhronosGroup/glTF-Validator) без ошибок. Сжатые файлы тоже прогоняем, но разрешаем сообщение о неподдерживаемом расширении: Meshopt валидатор не декодирует, код сообщения `UNSUPPORTED_EXTENSION`;
- в `heart-lod1.glb` не меньше 80 % треугольников `heart-lod0.glb`, то есть упрощение перестало работать.

Формат и уникальность id нужны, чтобы three.js не переименовал узлы: `GLTFLoader` пропускает имена через `PropertyBinding.sanitizeNodeName` (пробелы становятся `_`, символы `[ ] . : /` удаляются) и добавляет суффикс `_N` к повторяющимся именам.

Тесты пакета `packages/models` запускаются в общем CI (`ci.yml`, задача `unit`, команда `pnpm test`).

## Серия PR этапа 1

1. **Эта спека** (issue «Stage 1: heart model spec»).
2. **Исходная модель**: `sources.yaml`, `assemble.ts`, `models.yml`, `conduction.yaml`, `structures.yaml`, `heart.glb`, запись в `LICENSES.yaml`, тесты пакета `packages/models` с проверкой лицензий и id. В описании PR: скриншоты модели, фактический список структур с id FMA, решение по перегородкам.
3. **Конвейер сжатия**: `build.ts`, скрипт `models`, два LOD, бюджеты в CI, замеры Meshopt против Draco.
4. **Вьюер и страница анатомии**: пакет `packages/viewer` (камера, выбор структуры, подписи, прозрачность, срез, изоляция, 2D-фолбэк, подсветка из текста) и `/<locale>/anatomy/`. Отдельная короткая [спека вьюера](2026-10-11-stage-1-viewer.md). Пункты 4–6 первоначального плана объединены: без страницы вьюер нечем проверить в e2e.

## Риски

| Риск | Мера |
|---|---|
| Прореженная геометрия BodyParts3D выглядит грубо вблизи | Сравнить с HRA в PR 2 до начала вьюера; при необходимости взять для камер HRA, оставив сосуды из BodyParts3D (обе CC BY 4.0) |
| Схема проводящей системы выглядит как точная анатомия | Подпись «схема» во вьюере, источник положения в `LICENSES.yaml` |
| Источник исчезнет из сети | SHA-256 в `sources.yaml` и собранный `heart.glb` в Git: сайт собирается без доступа к источнику |
| Id структур поменяются и сломают статьи | `structures.yaml` как контракт, алиасы, проверка в CI |
| Расхождение лицензий BodyParts3D | Обе совместимы с CC BY-SA 4.0, указываем обе атрибуции |

## Открытые вопросы

- Полный список узлов HRA heart GLB и его размер: скачать не удалось из облачной среды, проверяем в PR 2 тем же workflow `models.yml`.
- Есть ли в Z-Anatomy что-то для сердца сверх BodyParts3D: не проверено, для MVP не нужно.
