osmgl 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1509 +1,1552 @@
1
- | Профиль | dpr | буфер | MSAA | воркеры | тени | детализация | тайлы |
2
- |---------|-----|-------|------|---------|------|-------------|-------|
3
- | `high` | ≤2 | ≤4096×4096 | да | ≤6 | да, 1024 | 1 | до 512 |
4
- | `balanced` | ≤1.75 | ≤2560×2160 | да | ≤4 | да, 1024 | 0.85 | до 192 |
5
- | `low` | ≤1.25 | ≤1920×1350 | нет | ≤2 | **нет** | 0.5 | до 96, крупнее |
6
- | `minimal` | 1 | ≤1280×900 | нет | ≤2 | **нет** | **0** | до 48, ещё крупнее |
7
-
8
- # osmgl
9
-
10
- **Самостоятельный 3D-движок карты.** Не надстройка над MapLibre: своя камера, свой конвейер
11
- тайлов (сеть и разбор — в воркерах), свой рендер на WebGL2. Ни MapLibre, ни three.js
12
- в зависимостях нет.
13
-
14
- Отличие от [`maps3d-web`](../maps3d-web/README.md): тот — набор three.js-оверлеев поверх чужой
15
- карты, каждый со своим проходом рендера. Здесь карта своя целиком.
16
-
17
- ```bash
18
- npm install && npm run build
19
- npm run demo # http://localhost:5180/
20
- ```
21
-
22
- Демо-сервер сам выбирает источник тайлов — первый, который ответит:
23
-
24
- | # | Источник | Примечание |
25
- |---|----------|------------|
26
- | 1 | `TILES_UPSTREAM` | если задан |
27
- | 2 | `http://localhost:8088/tileserver/data/v3` | через шлюз, основной путь |
28
- | 3 | `http://localhost:8090/data/v3` | tileserver-gl напрямую |
29
- | 4 | `data/tiles/tajikistan.mbtiles` | офлайн, без поднятого стека |
30
-
31
- Тайлы проксируются на origin демо: иначе упрёмся либо в CORS, либо в гейт API-ключей.
32
-
33
- > **Гоча со шлюзом.** При `API_KEYS_ENABLED=true` шлюз пускает без ключа только по
34
- > first-party allowlist (`API_KEYS_FIRST_PARTY`, локально `localhost`), а решает он по
35
- > заголовку `Origin`/`Referer`. Браузер шлёт его сам, а `fetch` из Node — нет, поэтому
36
- > прокси проставляет `Referer` явно. Без этого шлюз отвечает `401 invalid or missing api key`,
37
- > даже когда «локально проверка отключена». Настоящий ключ — через `TILES_KEY`.
38
-
39
- ## Использование
40
-
41
- ```html
42
- <script src="/maps/osmgl.global.js"></script>
43
- <script>
44
- const map = new OsmGL.Map({
45
- container: 'map',
46
- center: [68.7864, 38.5598], // Душанбе
47
- zoom: 16.4, pitch: 62, bearing: -18,
48
- tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
49
- sourceMaxZoom: 14,
50
- // style не задан → встроенный styles/osm-3d.json
51
- })
52
- </script>
53
- ```
54
-
55
- > **Не пишите `const { Map } = OsmGL`** в обычном `<script>`. Такое объявление попадает в
56
- > глобальную лексическую область и затеняет встроенный `Map` для кода внутри бандла.
57
- > Движок от этого защищён (`core/natives.ts`), но привычка обращаться через пространство
58
- > имён избавляет от целого класса подобных сюрпризов. В ESM проблемы нет.
59
-
60
- ESM:
61
-
62
- ```ts
63
- import { Map } from 'osmgl'
64
- ```
65
-
66
- ### API
67
-
68
- | Метод | Описание |
69
- |-------|----------|
70
- | `new Map(opts)` | Создать карту (см. `MapOptions`). |
71
- | `setCenter/setZoom/setBearing/setPitch` | Камера по одному параметру. |
72
- | `jumpTo(opts)` / `easeTo({...,duration})` | Мгновенно / плавно. |
73
- | `project(lngLat, alt?)` / `unproject(point)` | География ↔ экран (с учётом наклона). |
74
- | `setStyle(spec)` | Заменить стиль целиком (геометрия пересобирается). |
75
- | `setPaintProperty(id, prop, v)` | Цвет/ширина слоя на лету — только юниформы, без пересборки. |
76
- | `setLayoutProperty(id, prop, v)` | Кегль, зазор до значка, шаг разрежения — пересобирается раскладка подписей, но не геометрия. |
77
- | `setLight({...})` | Ambient и два направленных источника: азимут, высота, цвет, сила. |
78
- | `setTheme(name)` | Тема оформления: `day` \| `night` \| `mono` \| `pale` либо своя накладка. |
79
- | `setLayerVisible(id, on)` | Включить/выключить слой стиля. |
80
- | `refresh()` | Перечитать тайлы (после правки в редакторе). |
81
- | `getStats()` | Тайлы в работе/видимые, число воркеров, имя GPU. |
82
- | `destroy()` | Освободить всё. |
83
-
84
- События: `load`, `move`, `moveend`, `zoom`, `render`, `idle`, `error`, `contextlost`.
85
-
86
- ## На проде
87
-
88
- Движок выпускается в npm пакетом `osmgl` — порядок выпуска и грабли в [RELEASE.md](RELEASE.md).
89
-
90
- Потребителей двое, и обновляются они по-разному. Карта `/map/` берёт пакет обычной зависимостью.
91
- Админка отдаёт отдельный файл `src/Admin/public/maps/osmgl.global.js` — он **лежит в git** и
92
- обновляется командой `npm run maps:osmgl` в `src/Admin`, которая копирует готовый бандл из
93
- установленного пакета.
94
-
95
- Бандл уезжает в образ админки;
96
- nginx отдаёт его двумя путями — `/maps/osmgl.global.js` (стабильная ссылка) и
97
- `/admin/maps/…`. Рядом лежит страница превью `osmgl-preview.html` → **`/maps/osmgl-preview.html`**.
98
- Копией, а не сборкой из админки: движок собирается своим tsup со своим воркером,
99
- вшитым в бандл строкой, и тащить это в vite-сборку значило бы держать два пути
100
- сборки одного файла. Так же лежит и `maps3d.global.js`.
101
-
102
- Мелкие зумы (глобус) страница берёт ОТДЕЛЬНЫМ источником
103
- `/tileserver/data/world/{z}/{x}/{y}.pbf` — основные тайлы покрывают только
104
- Таджикистан. Набор собирает `bash deploy/rebuild-world-tiles.sh`; на сервере файл
105
- кладётся в `data/tiles/world.mbtiles` (в git mbtiles не хранятся) и подхватывается
106
- уже прописанным в `data/tiles/config.json` датасетом `world` — после копирования
107
- нужен рестарт `tileserver`.
108
-
109
- ## Стиль
110
-
111
- Стиль — JSON (`styles/osm-3d.json`), вшитый в бандл: карта рисуется без единого запроса
112
- за конфигом.
113
-
114
- > **Гоча.** Раз стиль вшит, правка `styles/*.json` доезжает до карты только после
115
- > `npm run build` (или `npm run dev` в режиме слежения). Отредактировать JSON и
116
- > перезагрузить страницу — недостаточно, и выглядит это как «настройка не
117
- > работает». Крутить на лету можно `setPaintProperty` и `setLayoutProperty`. Формат сознательно НЕ повторяет style spec MapLibre — там половина
118
- возможностей нам сейчас не нужна, а тащить их означало бы тащить и интерпретатор
119
- выражений. Здесь минимум, покрывающий схему OpenMapTiles v3:
120
-
121
- ```jsonc
122
- {
123
- "id": "road-major",
124
- "type": "line", // fill | line | fill-extrusion
125
- "source-layer": "transportation", // слой MVT
126
- "filter": ["in", "class", "motorway", "trunk", "primary"],
127
- "minzoom": 5,
128
- "paint": {
129
- "color": "#ffeccd",
130
- // ширина в ЭКРАННЫХ пикселях, интерполяция по зуму
131
- "width": { "stops": [[6, 0.8], [14, 6], [18, 25], [20, 52]] }
132
- }
133
- }
134
- ```
135
-
136
- Ширина линии задана в пикселях **на уровне центра карты**, но раздувается лента
137
- **в плоскости земли** — как у Mapbox (`extrude * u_pixels_to_tile_units`, и только
138
- потом матрица). Сначала было наоборот: квад раздувался в экранных пикселях, а
139
- перспективу учитывал множитель ширины. Толщина от этого сокращалась правильно, а
140
- направление — нет: лента оставалась развёрнутой к экрану, и при наклоне 75°
141
- дорога поперёк взгляда читалась вертикальной стеной. Круглые стыки и концы при
142
- этом сохранились: расстояние до отрезка фрагмент считает там же, на земле.
143
-
144
- ГОЧА, которую это принесло: «не тоньше пикселя» нужно ОГРАНИЧИВАТЬ. У горизонта на
145
- пиксель приходятся десятки единиц тайла, и полпикселя превращаются в десятки
146
- метров — дорога расплывается пятном во весь склон. Подробности —
147
- в [porting/lines.md](porting/lines.md).
148
-
149
- Фильтры: `==` `!=` `<` `<=` `>` `>=` `in` `!in` `has` `!has` `all` `any` `!`.
150
- Любое число или цвет можно задать остановками `{ "stops": [[zoom, value], ...] }`.
151
-
152
- Покрыты все слои, которые реально приезжают из наших тайлов: `water`, `waterway`,
153
- `landcover` (лес/трава/песок/лёд/болото), `landuse` (жильё/промка/соцобъекты/спорт/
154
- кладбища/парковки), `park`, `aeroway`, `transportation` (обводка + полотно для
155
- магистралей, вторичных, второстепенных, ж/д и троп), `boundary`, `building`.
156
-
157
- Порядок слоёв в стиле — порядок отрисовки. Земля рисуется методом художника с
158
- выключенным depth-тестом (всё лежит на z=0, тест дал бы только z-fighting), затем
159
- объёмные слои с честной глубиной.
160
-
161
- ### Разноцветная линия
162
-
163
- Маршрут, раскрашенный по пробкам, — как у Яндекса:
164
-
165
- ```js
166
- new OsmGL.Polyline({
167
- coordinates,
168
- color: '#2563eb', // цвет по умолчанию, если кусков нет
169
- segments: [ // кусок идёт с `at` и до `at` следующего
170
- { at: 0, color: '#39b54a' },
171
- { at: 128, color: '#c01818' },
172
- ],
173
- })
174
- ```
175
-
176
- Своего шейдера это не потребовало: объект и так возвращает СПИСОК кусков отрисовки
177
- (`ObjectPart[]`), и цветной участок — просто ещё один кусок со своей лентой. Два правила,
178
- которые легко нарушить:
179
-
180
- - соседние куски ДЕЛЯТ вершину (`slice(from, to + 1)`) — иначе на стыке остаётся разрыв
181
- шириной в отрезок;
182
- - обводка рисуется ОДНА на всю линию и первой — обводка на каждый кусок положила бы белые
183
- перемычки поверх цвета соседа.
184
-
185
- Куски приходят из ответа сервиса, поэтому мусор гасится на входе (индексы вне линии,
186
- дробные, неупорядоченные), а первый кусок всегда начинается с начала линии: неокрашенный
187
- хвост выглядел бы обрывом, а не «неизвестным участком».
188
-
189
- ### Подписи вдоль линии
190
-
191
- `layout.textPlacement: 'line'` — название улицы изгибается по дороге:
192
-
193
- ```jsonc
194
- { "id": "road-label", "type": "symbol", "source-layer": "transportation_name",
195
- "layout": { "textField": ["{name:ru}", "{name}"], "textPlacement": "line" } }
196
- ```
197
-
198
- Раскладка (`text/line-placement.ts`) — ЧИСТАЯ функция над экранной ломаной: на входе точки и
199
- метрики глифов, на выходе позиция и угол каждой буквы. Считается в экранных координатах и на
200
- каждый кадр: форма дороги на экране зависит от наклона камеры, и изгиб, посчитанный в тайле,
201
- при наклоне разъезжается с самой дорогой. Воркер поэтому кладёт в бакет геометрию линии
202
- (`lines` + `lineStarts`) — самую длинную цепочку объекта.
203
-
204
- Отличия от Mapbox (`symbol_projection.js`), где часть работы уходит на GPU:
205
-
206
- - подпись ОДНА на объект, а не по одной на интервал. Поэтому при неудаче она сдвигается вдоль
207
- линии и пробует снова: улица с резким поворотом посередине иначе теряла бы название целиком;
208
- - направление чтения решается по ТОМУ КУСКУ, который занимает строка, а не по всей линии. У
209
- извилистой улицы хорда может идти вправо, а выбранный кусок влево — и название вставало вверх
210
- ногами;
211
- - место в сетке коллизий занимает цепочка боксов по буквам: общий бокс изогнутой строки — почти
212
- весь экран, и он вытеснял бы всё вокруг.
213
-
214
- Излом больше 45° на глиф — отказ (порог как у Mapbox `MAX_GLYPH_ANGLE`): на дуге буквы наезжают
215
- друг на друга внутренней стороной, и подпись читается хуже, чем если её не ставить. Цена
216
- раскладки — +0,1 мс на кадр (медиана 1,0 → 1,1 мс на z16 при непрерывном вращении, SwiftShader).
217
-
218
- ## Растровая подложка
219
-
220
- Карта картинками под всей векторной графикой — вторая основа карты («Схема
221
- картинкой» на публичном сайте). Спутник это не даёт (снимков у нас нет), зато
222
- даёт любую чужую подложку и снимает работу с клиента: рисует сервер, а мы только
223
- натягиваем текстуры.
224
-
225
- ```jsonc
226
- // источник
227
- { "basemap": { "type": "raster", "url": "/tileserver/styles/gram-raster/{z}/{x}/{y}@2x.png",
228
- "minzoom": 0, "maxzoom": 19 } }
229
- // слой
230
- { "id": "basemap-raster", "type": "raster", "source": "basemap",
231
- "visible": false, "paint": { "opacity": 1 } }
232
- ```
233
-
234
- **Источник отдельный** (`source/raster-source.ts`), а не ветка в `TileSource`. У
235
- них разная работа: векторный тайл надо разобрать и стесселировать — ради этого и
236
- живёт пул воркеров с очередью; картинку разбирает сам браузер и вне главного
237
- потока (`createImageBitmap`), и ставить её в ту же очередь значит задерживать
238
- геометрию ради того, что и так декодируется параллельно. Общее — отбор видимых
239
- тайлов, LRU и отмена запросов уехавших из вида тайлов — переиспользуется как
240
- есть.
241
-
242
- **Слой не хранит ничего.** Один единичный квад на все тайлы (тот же, что у прохода
243
- тени по земле): матрица тайла с `extent = 1` растягивает его ровно на место.
244
- Текстура живёт В ТАЙЛЕ и освобождается вместе с ним — вытеснение из кеша видит
245
- источник, а не слой. Битмап после переноса в GPU закрывается: он держит несжатую
246
- картинку вне кучи JS, и на подложке это сотни мегабайт.
247
-
248
- **Пока свой тайл грузится, рисуется кусок предка** (`patchUV`) — иначе каждый шаг
249
- зума открывает дыры до конца загрузки. Потомков не собираем: их до четырёх на
250
- тайл, целиком в кеше они бывают редко, и мозаика из части квадратов заметнее
251
- размытого предка. Так же поступают Mapbox и MapLibre.
252
-
253
- **Y не переворачивается.** У тайла начало на севере, и первая строка картинки,
254
- уходящая в `texImage2D`, — тоже северная. Мипы обязательны (без них отдалённый
255
- тайл кипит муаром), плюс анизотропия, если устройство её умеет: при наклоне тайл
256
- виден под острым углом, и обычные мипы мылят его вдоль взгляда.
257
-
258
- **Выключенный слой не стоит ни байта.** `Map` обновляет только те растровые
259
- источники, которые рисует хоть один включённый слой: слой подложки объявляют
260
- заранее и включают тумблером (пересборка стиля мигает всей картой), а картинки —
261
- самый тяжёлый трафик на карте.
262
-
263
- ## Подписи и иконки
264
-
265
- Текст берётся из SDF-глифов формата Mapbox (`/tileserver/fonts/{fontstack}/{range}.pbf`) —
266
- их отдаёт наш tileserver-gl, поэтому подписи выглядят так же, как в остальных наших картах.
267
-
268
- **Иконка красится тем же цветом и той же обводкой, что и текст.** Это не совпадение, а
269
- устройство: SVG растрируется в альфа-маску, из неё строится SDF **в той же кодировке, что
270
- у глифов** (край фигуры на 192/255, диапазон растянут на −6..+2 пикселя), и кладётся в
271
- ОБЩИЙ с буквами атлас. Дальше одна программа рисует и то и другое — шейдер не различает,
272
- буква перед ним или пиктограмма.
273
-
274
- Плата за это — иконки **силуэтные**, одноцветные.
275
-
276
- **Иконка стоит НАД подписью и по её центру** (`layout.iconPlacement`, по
277
- умолчанию `top`) — как в Mapbox Standard и у Яндекса: значок оказывается ровно
278
- над точкой объекта. При боковой раскладке длинное название уводит его в сторону,
279
- и на плотной карте перестаёт быть понятно, к чему он относится. Зазор задаётся
280
- на слой (`layout.iconTextGap`) и берётся как есть, без скрытых множителей.
281
-
282
- Зазор отсчитывается от **настоящего верха букв**, а не от строчного бокса. Бокс
283
- строки выше того, что видно: в нём живут выносные элементы и подстрочье, и у
284
- названия без «б», «р», «у» сверху остаётся пустая полоса примерно в четверть
285
- кегля. Пока подпись ставилась по боксу, «зазор 2» читался на экране как семь, и
286
- править его в стиле было бесполезно — уменьшалось не то. Поэтому подпись
287
- рисуется первой, её рамка запоминается, и блок сдвигается по факту.
288
-
289
- **Круглая подложка под значком** (`layout.iconFrame: "circle"`, диаметр —
290
- `iconFrameScale`) — как у Яндекса и в Mapbox Standard: тонкая пиктограмма поверх
291
- домов и зелени сама по себе теряется. Это ОТДЕЛЬНЫЙ значок под иконкой со своим
292
- потоком вершин и своей покраской, а не обводка: обводка у нас общая с текстом, и
293
- жирный контур вокруг силуэта выглядит грязью, а кружок — фигура с заливкой и
294
- рамкой. Цвета по умолчанию выводятся из темы и отдельной настройки не требуют:
295
- заливка — цвет обводки подписи (в каждой палитре это и есть «фон под текстом»:
296
- белый днём, почти чёрный ночью), рамка — цвет значка, приглушённый до трети.
297
- Переопределяются `paint.frameColor`, `frameHaloColor`, `frameHaloWidth`.
298
-
299
- Заливка кружка ПОЛУПРОЗРАЧНАЯ (`paint.frameOpacity`): сплошная закрывает карту и на
300
- плотной застройке читается дырой. Плотность своя на каждой теме и выводится из
301
- светлоты фона — 0,3 на бумажных палитрах, 0,6 на графитовых: тёмному фону нужна
302
- БОЛЬШАЯ плотность, иначе кружок не отделяет пиктограмму от домов, а на светлом
303
- сильный кружок сам становится объектом. Рамка при этом остаётся непрозрачной —
304
- растворять её вместе с фоном значит потерять край фигуры. Место
305
- под значок при этом занимает кружок, а не пиктограмма, иначе подпись налезала бы
306
- на него.
307
-
308
- **Набор иконок сверяется с данными, а не с воображением:**
309
-
310
- ```bash
311
- node scripts/audit-icons.mjs # какие классы приезжают и чего не хватает
312
- ```
313
-
314
- Имя иконки обязано совпадать с классом схемы (шаблон `{class}`), и ошибка тут
315
- тихая: имя не совпало — объект молча получает нейтральную точку. Именно так
316
- полторы тысячи гостиниц ходили с точкой, пока иконка называлась `hotel`, а класс
317
- в данных — `lodging`. Первый прогон по прод-набору показал **78 классов без
318
- значка, это 45% всех POI**; сейчас непокрытым остался один (`brownfield`, 16
319
- объектов — класс землепользования, случайно попавший в слой POI). Близкие по
320
- смыслу классы делят иконку через `ICON_ALIASES`: отдельные пиктограммы для
321
- волейбола, баскетбола и тенниса на шестнадцати пикселях всё равно неразличимы.
322
-
323
- **Номера трасс** — слой `road-shield`: светлая плашка с рамкой и тёмным номером,
324
- как в Mapbox. Плашка это ЗНАЧОК ПОД подписью (`iconPlacement: "behind"`), и у
325
- него **своя покраска** (`iconColor`, `iconHaloColor`) — иначе он красился бы
326
- цветом номера, и прочитать номер было бы нельзя. Ширина плашки выбирается по
327
- длине номера (`iconImage: "shield-{ref_length}"`): настоящего 9-patch у нас нет,
328
- поэтому в наборе лежат готовые ширины под 2…8 знаков — этого хватает на всё, что
329
- встречается в данных. Чтобы номер не повторялся на каждом отрезке линии, у слоя
330
- свой шаг разрежения `layout.collisionPadding`.
331
-
332
- Попутно из этого выпало два общих улучшения: значки больше не обязаны быть
333
- квадратными (растр берёт соотношение сторон из `viewBox`, а квад — из записи в
334
- атласе), и подписи рисуются **в порядке стиля**, а не в порядке размещения по
335
- важности — иначе подложка могла оказаться поверх того, что подпирает.
336
-
337
- **Цвет по типу объекта.** Раз подпись и значок красятся цветом СЛОЯ, то «еда
338
- оранжевым, транспорт синим» — это слой на категорию: `poi-food`, `poi-shop`,
339
- `poi-transport`, `poi-health`, `poi-education`, `poi-culture`, `poi-sport`,
340
- `poi-lodging`, `poi-service` и общий `poi` для остального. Классы разобраны по
341
- тому, что реально приезжает в наших тайлах (замер по Душанбе: `shop` 460,
342
- `office` 220, `cafe` 151, `restaurant` 128…), остаток ловится обратным фильтром
343
- — иначе объект нарисовался бы дважды. Коллизии при этом общие: кандидаты со
344
- всех слоёв сортируются по важности в одной сетке.
345
-
346
- Цвета категорий подобраны под светлую карту, поэтому темам они раздаются
347
- скриптом:
348
-
349
- ```bash
350
- node scripts/apply-poi-palette.mjs
351
- ```
352
-
353
- Тёмная тема получает те же тона, поднятые по светлоте, и свою обводку (белая
354
- обводка дневной темы превращает подпись в светящуюся марку). Монохромная тема
355
- остаётся монохромной: цветные категории поверх графита — это уже другая тема. Разноцветную пиктограмму так не сделать;
356
- для неё понадобится отдельный растровый слой.
357
-
358
- ```jsonc
359
- {
360
- "id": "poi",
361
- "type": "symbol",
362
- "source-layer": "poi",
363
- "minzoom": 15,
364
- "layout": {
365
- "textField": ["{name:ru}", "{name}"], // список — это фолбэки
366
- "iconImage": "{class}", // имя из набора иконок
367
- "textSize": { "stops": [[15, 10.5], [18, 12.5]] },
368
- "iconSize": { "stops": [[15, 13], [18, 16]] }
369
- },
370
- "paint": { "color": "#4a4a4a", "haloColor": "#ffffff", "haloWidth": 1.3 }
371
- }
372
- ```
373
-
374
- Свои иконки — через `icons` в опциях карты (дополняют встроенный набор силуэтов):
375
-
376
- ```ts
377
- new OsmGL.Map({ icons: { taxi: '<svg viewBox="0 0 24 24">…</svg>' } })
378
- ```
379
-
380
- Размещение, перенос по словам и разрешение коллизий считаются на CPU в экранных
381
- координатах и пересобираются на каждое движение камеры. Подпись всегда развёрнута к
382
- экрану, её положение зависит от камеры — тайловая геометрия тут ничего бы не сэкономила.
383
- Приоритет берётся из `rank` схемы OMT (там меньше — важнее, поэтому переворачиваем);
384
- что не поместилось, просто не рисуется.
385
-
386
- Настоящего шейпинга нет: перо двигается по `advance`. Кириллице, латинице и таджикскому
387
- этого достаточно, арабский и иврит потребуют отдельного прохода.
388
-
389
- ## Планы этажей
390
-
391
- Помещения ТЦ, вокзалов и рынков. Схема тайлов — [indoorequal](https://github.com/indoorequal/indoorequal)
392
- (слои `area` / `area_name` / `transportation` / `poi`, у каждой фичи числовой `level`),
393
- её же собирает наш [rebuild-indoor-tiles.sh](../../deploy/rebuild-indoor-tiles.sh). План
394
- и обоснование схемы — в [INDOOR.md](../../INDOOR.md).
395
-
396
- ```ts
397
- const map = new OsmGL.Map({
398
- tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
399
- sources: { indoor: { url: '/tileserver/data/indoor/{z}/{x}/{y}.pbf', minzoom: 15, maxzoom: 18 } },
400
- })
401
-
402
- map.on('indoor', ({ levels, level }) => renderFloorSwitcher(levels, level))
403
- map.setIndoorLevel(2)
404
- ```
405
-
406
- **Отдельный источник, а не слой основных тайлов.** У основной карты потолок z14 —
407
- планов этажей на нём не видно вовсе, — и пересобирается она сорок минут на всю страну,
408
- тогда как `indoor.mbtiles` собирается за секунды. Слой стиля выбирает источник полем
409
- `source`; их может быть сколько угодно.
410
-
411
- **Переключение этажа ничего не пересобирает.** Фильтры у нас применяются в воркере, на
412
- этапе тесселяции, поэтому «дописать `level == 3` в фильтр» означало бы перемолоть все
413
- тайлы на каждое нажатие. Вместо этого слой объявляет `splitBy: "level"`, и воркер строит
414
- по мешу на КАЖДЫЙ встреченный этаж; переключение — это выбор другого готового буфера.
415
- Оттуда же берётся и список этажей для переключателя: клиенту неоткуда узнать, какие
416
- этажи есть в здании, кроме как из самих тайлов.
417
-
418
- **Накладка поверх объёма** (`overlay: "volume"`, прежнее написание `true`). План лежит
419
- внутри здания, и в обычном порядке его закрыла бы собственная крыша. Покрывает он ровно
420
- контур своего здания, поэтому соседние дома остаются видимыми.
421
-
422
- У накладки есть и вторая позиция — `overlay: "roads"`, сразу после дорог и ДО объёма. Там
423
- живёт разметка на асфальте: зебры и перекрытия. В общую очередь она не встаёт (дороги
424
- рисуются позже поверхностей), но и поверх объёма ей делать нечего — зебра, висящая на
425
- стене дома, первой бросается в глаза.
426
-
427
- Список этажей считается по ВИДИМЫМ тайлам: переключатель должен показывать этажи того
428
- здания, на которое смотрят, а не всех, куда пользователь заезжал за сеанс. При выходе
429
- из здания выбор снимается — иначе в следующем ТЦ остался бы этаж, которого там нет.
430
- Автовыбор (`map.autoIndoorLevel`) ставит первый этаж, а не самый нижний: подвал по
431
- умолчанию не показывают.
432
-
433
- Чего пока нет: POI основной карты не гасятся внутри здания, поэтому наружные подписи
434
- магазинов дублируют внутренние. Нужна маска по контуру venue.
435
-
436
- **Палитра плана выводится для КАЖДОЙ темы** (`scripts/apply-indoor-palette.mjs`).
437
- Раньше цвета считались только для тёмных палитр, а светлые оставались с базовыми
438
- бежевыми плашками — на «морской» или «земляной» теме раскрытый этаж выглядел
439
- вставкой из другой карты. Правила те же, по которым рисуют планы Mapbox Indoor и
440
- наш web-SDK:
441
-
442
- * **пол** отсчитывается от цвета ЗДАНИЙ темы, а не от фона карты: план лежит поверх
443
- дома, и сливаться ему нельзя именно с домом;
444
- * **коридор** — самый «пустой» тон, по нему глаз читает связность плана;
445
- * **помещения** нейтральны, а смысловые классы (магазин, еда, санузел, лифт)
446
- сохраняют свой ТОН и уходят от коридора по светлоте: одного тона мало, на светлой
447
- теме плашка почти белая и предельная цветность там мизерная;
448
- * **стены** — самый контрастный элемент, и светлота у них ПЕРЕВЁРНУТА под тему: на
449
- светлой карте темнее помещений, на тёмной светлее. Без переворота на графите
450
- стена сливается с полом.
451
-
452
- Отношения проверяются тестом на всех темах: пол отличается от здания, стена
453
- отделяется от помещения (контраст ≥ 1,25 и верное направление), коридор отличим,
454
- смысловые классы различимы между собой и от обычной комнаты.
455
-
456
- **Клик по помещению.** Пока план открыт, щелчок выбирает КОМНАТУ, а не дом: дом под
457
- планом скрыт, и обводить невидимое незачем. Помещение — плоская заливка, поэтому и
458
- попадание считается иначе, чем у дома: не объёмным лучом, а пересечением луча с
459
- плоскостью земли и проверкой «внутри треугольника» (`pickFill`). Для этого заливки
460
- теперь несут id фичи на треугольник — те же четыре байта, что и у зданий, иначе
461
- подсветить и вернуть наружу нечего. Подсветка комнаты рисуется программой заливок:
462
- у плоского меша другой формат вершины, «нарисовать зданием» его нельзя.
463
-
464
- Подсвечивается ТОЛЬКО ТО, ПО ЧЕМУ КЛИКНУЛИ, — пол выбранной комнаты. Приписывать
465
- комнате её стены мы пробовали и отказались: в данных стена это отдельный полигон между
466
- двумя помещениями, тянется вдоль целого ряда комнат и «своей» не помечена ни для одной.
467
- Любой геометрический подбор (по прилеганию вершин, по близости треугольников) в углах
468
- и вдоль общих стен зажигал сразу несколько помещений. К тому же выводу пришёл наш
469
- web-SDK: стены комнат попадают в общий движок зданий наравне с домами, и выделение там
470
- одно на всю карту — «своей» подсветки у помещений нет.
471
-
472
- Заливка подсветки идёт ЧЕРЕЗ ТРАФАРЕТ. Полупрозрачный полигон в перекрытии тайлов
473
- ложится дважды, и по стыку идёт полоса иной плотности — тот самый шов на выделенной
474
- комнате. Трафарет пропускает каждый пиксель ровно один раз, и склеивать половинки
475
- полигона из соседних тайлов не нужно. ГОЧА: опорное значение обязано быть НЕнулевым —
476
- `REPLACE` пишет в трафарет именно его, и с нулём тест «равно нулю» продолжает
477
- проходить, то есть трафарет не делает ровно ничего.
478
-
479
- Сами слои плана вдобавок РЕЖУТСЯ по квадрату тайла (`clipToTile`): они полупрозрачны,
480
- и в буферной полосе тоже ложились дважды. Непрозрачной заливке буфер, наоборот,
481
- полезен — он закрывает волосяные щели на стыке, поэтому обрезка включается слоем, а не
482
- всем подряд.
483
-
484
- Стрелки входов при этом остаются: если дом не выделен, но его план открыт, контур для
485
- `showFor: "highlight"` берётся у самого плана — стрелки показывают, как в это здание
486
- попасть, и выбор комнаты этого не отменяет.
487
-
488
- **Объект без здания в тайлах** (`indoor-venue`). У части объектов помещения размечены,
489
- а контур дома не нарисован — ни в тайлах, ни в схеме редактирования: план стоял в
490
- воздухе. Решение то же, что в нашем web-SDK (`Buildings3D.extra`): плита этажа
491
- (`class = level`, `base = 1`) подаётся как ОБЫЧНОЕ здание с высотой из данных и той же
492
- покраской — «свой» стиль рядом с честными домами выглядит чужеродно. Живёт объём с z15
493
- до z16 и снимается ровно там, где появляется план: дальше внутрь смотрят помещения, а
494
- не коробка.
495
-
496
- ★ Эта коробка — ВТОРАЯ отрисовка того же дома, и её тоже надо прятать под детальной
497
- 3D-моделью. Дом, который модель заменяет собой, выбрасывается из объёма основных тайлов
498
- (`renderer.hiddenBuildings`), но плита этажа приезжает из ДРУГОГО источника и под ту
499
- резку не попадала: рядом с детальной башней продолжала стоять её же серая коробка.
500
- Набор поэтому второй (`renderer.hiddenVenues`), и наполняется он геометрически — по
501
- точке размещения, потому что нумерация у двух источников своя (та же причина, по
502
- которой геометрическая и маска планов этажей).
503
-
504
- **Открытый план гасит модель ПЛАВНО.** Пока план раскрыт, стоящая на нём модель не
505
- рисуется — она закрывает его ровно так же, как это делал бы сам дом. Уход именно
506
- затуханием (`Model.setHidden`, отдельно от «вырастания» `grow`): рост сопровождает
507
- ПРИЕЗД объекта на карту, а здесь объект никуда не девался — камера просто зашла внутрь,
508
- и вырастающая обратно из земли башня при выходе читалась бы как новое событие.
509
- Полупрозрачная модель целиком уходит в прозрачный подпроход (запиши она глубину — в том,
510
- что за ней, осталась бы дыра её формы), а тень перестаёт отбрасываться на середине ухода:
511
- карта теней полутонов не знает, и под выцветшим объектом сплошная тень выглядит чужой.
512
-
513
- **Дом с раскрытым этажом НЕ РИСУЕТСЯ** (`layers/indoor-mask.ts`). План этажа — это
514
- взгляд внутрь здания, и стоящая вокруг коробка мешает: в наклоне она закрывает
515
- половину плана, а сверху спорит с ним крышей. Так же поступают Mapbox Indoor и наш
516
- web-SDK. Связать план со зданием по id нельзя — этажи нумерует редактор (с 5e12), а
517
- тайлы собирает планетайлер по своему источнику, id из разных пространств. Поэтому
518
- маска ГЕОМЕТРИЧЕСКАЯ: под неё идёт ВСЯ плоская геометрия открытого этажа (полигон
519
- `class = level` есть не в каждом тайле, и по нему одному маска молча оказывалась
520
- пустой), а здания, чей центр в неё попал, выбрасываются из индексов.
521
-
522
- Три грабли, каждая из которых уже проявлялась на карте:
523
-
524
- * **привязка индексного буфера — состояние VAO**, а не глобальное. Подменив индексы
525
- в чужом VAO, мы меняем его навсегда: тайл рисуется урезанным и в кадрах без маски,
526
- а при освобождении буфера мигает. У маски поэтому СВОЙ VAO;
527
- * решение принимается **на id фичи и по всем тайлам сразу**. Дом на границе приезжает
528
- кусками, у каждого куска свой центр — решая по куску, мы прятали половину дома;
529
- * набор **пополняется по мере загрузки тайлов**: список видимых появляется раньше их
530
- данных, и «собрать один раз» оставляло часть домов стоять поверх плана. По той же
531
- причине в отпечаток маски входит НАЛИЧИЕ ДАННЫХ у каждого тайла, а не только его
532
- ключ: иначе маска, посчитанная по недогруженному плану, не пересчитывалась вовсе —
533
- после перезагрузки страницы дом стоял поверх плана и пропадал лишь от первого
534
- движения карты. И решение «нечего резать» нельзя кешировать для тайла, данных
535
- которого ещё нет.
536
-
537
- Маска живёт РОВНО там, где рисуется план: слой, невидимый на текущем зуме, в неё не
538
- идёт. Без этого между зумом, где план уже пропал по своему `minzoom`, и зумом, где
539
- здания ещё не показаны, не видно ни того ни другого — дом остаётся скрытым под
540
- планом, которого нет. Проверено по зумам: до z16 дома на месте и плана нет, с z16
541
- появляется план и дом исчезает.
542
-
543
- Маску получает только городской слой зданий: стены плана рисуются тем же методом и
544
- стоят внутри той же маски — применив её к ним, мы стёрли бы ровно то, ради чего дом
545
- и прячем.
546
-
547
- **Стены комнат — ОБЪЁМНЫЕ** (`indoor-wall`, тип `fill-extrusion`): высота приходит
548
- из данных (`height`, низ — `base`), материал и подсветка общие с городскими домами.
549
- Покрашены они СВЕТЛЕЕ, чем плоские слои плана, и у них своя кровля: стена это
550
- освещённая поверхность, а не самая тёмная линия чертежа — отделяет её свет, и
551
- тёмная плашка под настоящей тенью читается грязью. Затенение у основания
552
- (`aoStrength`) выключено: полоса в пару метров на тридцатиметровом доме читается
553
- контактной тенью, а на трёхметровой перегородке занимает пол-стены и выглядит
554
- чёрной каймой со ступеньками. Внутри помещения контактной тени и не бывает.
555
- Рисуются они СВОЕЙ сценой: перед ними очищается буфер глубины, потому что внутри
556
- себя план обязан быть объёмным (дальняя стена не должна ложиться на ближнюю), а
557
- общая глубина города для него не годится — план лежит внутри дома. Тот же вывод, к
558
- которому пришёл наш web-SDK: плоская экструзия рядом с настоящими домами выглядела
559
- чужеродной, и стены отданы общему движку зданий.
560
-
561
- **Двери и лифты.** Дверей в тайлах больше сотни на здание — без них план читается
562
- набором комнат без входов; они рисуются отдельным слоем с z18. Лифты стали заливкой
563
- вместо пунктирной линии: шахта читается фигурой, а не обводкой.
564
-
565
- ## Объекты приложения
566
-
567
- Маршрут, зона доставки, трек машины — то, что кладёт на карту приложение, а не
568
- тайлсервер. Отдельным источником они не оформлены сознательно: таких объектов
569
- единицы, их правят покадрово, и воркер с LRU здесь ничего бы не сэкономили.
570
-
571
- ```ts
572
- const route = new OsmGL.Polyline({
573
- coordinates: [[68.780, 38.563], [68.790, 38.562], [68.790, 38.557]],
574
- color: '#2f6df6', width: 7,
575
- strokeColor: '#fff', strokeWidth: 2, // обводка снизу, как у дорог в стиле
576
- zIndex: 20,
577
- userData: { orderId: 42 },
578
- })
579
- map.addObject(route)
580
-
581
- map.addObject(new OsmGL.Circle({ center: [68.786, 38.560], radius: 400 })) // радиус в МЕТРАХ
582
- map.addMarker(new OsmGL.Marker({ coordinates: [68.786, 38.560], html: '<b>А</b>' }))
583
-
584
- map.on('click', ({ object, lngLat }) => {
585
- if (object) console.log('попали в заказ', object.userData.orderId)
586
- })
587
- ```
588
-
589
- Доступны `Polyline`, `Polygon` (с дырками), `Circle` и `Marker`. Оформление —
590
- те же поля, что у слоёв стиля: `color`, `opacity`, `width`, `dashArray`, `offset`,
591
- `arrow`. Шейдеры буквально те же самые; отличается только тип атрибута позиции.
592
-
593
- **Координаты.** У объекта нет тайла, который держал бы его вершины в маленьких
594
- целых числах, поэтому у каждого объекта СВОЁ начало координат — центр его рамки, —
595
- а вершины хранятся относительно него в нормализованном меркаторе, во Float32.
596
- Int16, как у тайлов, здесь не годится: маршрут через город это 40 км, шаг сетки
597
- вышел бы около метра, а на z20 метр — это восемь пикселей дрожания.
598
-
599
- **Правки.** `setCoordinates`/`setPaint` только увеличивают `revision`; буфер
600
- перезаливается один раз в следующем кадре. Двигать трек машины можно хоть на
601
- каждый тик — загрузка будет одна.
602
-
603
- **Маркер — это DOM**, а не спрайт в канве. По маркеру кликают, наводят подсказку,
604
- ставят в него счётчик, анимируют пульсацию; браузер это уже умеет, а в канве
605
- пришлось бы заново делать попадание, курсор и доступность. Цена известна: тысячи
606
- маркеров DOM не потянет — для массовых точек есть слой `symbol` из стиля, он в GL
607
- и умеет коллизии.
608
-
609
- **Клик** ловится не событием `click` браузера, а парой pointerdown/pointerup с
610
- порогом 4 px и 500 мс: на карте почти любой клик приходит после перетаскивания, и
611
- настоящий щелчок надо отличать от конца панорамирования. Попадание считается на
612
- CPU в экранных координатах — поэтому переживает наклон и поворот и позволяет
613
- задать допуск на палец.
614
-
615
- ## Фасады и крыши
616
-
617
- Стены получают окна, а невысокие дома — скатные крыши, как в 2GIS и Яндексе.
618
-
619
- Рисунок фасада считается ПРОЦЕДУРНО, без единой текстуры: атлас фасадов
620
- (путь Mapbox Standard) даёт больше, но требует художника, загрузки и памяти, а
621
- читаемый город получается и из сетки, построенной прямо по координатам. Класс
622
- фасада выводится в основном ИЗ ВЫСОТЫ: тег `class` есть у двух процентов зданий
623
- (64 из 2700 в тайле Душанбе), а частный дом от девятиэтажки отличается рисунком
624
- окон сильнее, чем жилой дом от офисного.
625
-
626
- | Класс | Кто | Рисунок |
627
- |-------|-----|---------|
628
- | дом | ниже 7 м, `house`, `garage` | редкие окна, глухой цоколь |
629
- | жилая секция | 7–40 м | регулярная сетка, витрины на первом этаже |
630
- | стекло | выше 40 м, `office`, `retail` | ленточное остекление с импостами |
631
- | промышленное | `industrial`, `warehouse` | крупные панели, окна только выше 4 м |
632
-
633
- Скатная крыша ставится на ОПИСАННОМ прямоугольнике и только там, где контур на
634
- него похож (заполнение ≥ 0.86), не выше 15 м и не больше 600 м². Честный способ —
635
- прямой скелет многоугольника — на порядок сложнее и нужен ради редких форм;
636
- прямоугольник закрывает ровно тот случай, ради которого всё затевалось, — частный
637
- дом. Форма (двускатная, вальмовая, шатровая, односкатная) берётся из тега
638
- `roof_shape`, а без него — из пропорций и хеша здания. Скат добавляется ПОВЕРХ
639
- заданной высоты: `render_height` у дома это обычно карниз.
640
-
641
- Раскладка окон идёт **по грани**, а не по общей сетке: у каждой стены свои поля
642
- по краям, целое число ячеек и остаток, размазанный по простенкам. Так сделано в
643
- [`maps3d-web`](../maps3d-web/README.md) (`fw`/`t0` на ребро), и причина не в
644
- красоте: при общей сетке ребро дома режет её где придётся — у угла остаётся
645
- половина окна, а простенки у соседних стен разной ширины. Ширина грани приходит
646
- атрибутом `a_wall`, и подряд идущие сонаправленные рёбра склеиваются в одну
647
- грань — контуры из OSM сплошь и рядом дробят прямую стену на два-три звена, и
648
- иначе на ней оказалось бы несколько независимых сеток со швом на ровном месте.
649
-
650
- Ночью часть окон горит (`windowGlow` — доля светящихся). Какие именно, решает хеш
651
- ячейки, поэтому при движении камеры свет не «бегает», а яркость у окон разная —
652
- одинаково светящиеся окна выглядят вывеской, а не жилым домом.
653
-
654
- Вся детализация гасится по `fwidth` (метров на пиксель) задолго до того, как
655
- начнёт рябить: на общем плане фасад не меняет НИ ОДНОГО пикселя (дымовой тест
656
- меряет это с обоих концов — 125 тысяч пикселей вблизи против нуля вдали).
657
-
658
- Настройки — в paint слоя `building`: `facadeDetail`, `windowColor`,
659
- `windowFrameColor`, `windowGlow`, `floorHeight`, `roofVariation`.
660
-
661
- ## Темы
662
-
663
- Двадцать пять готовых. Четыре сделаны под этот движок — `day`, `night` (ночная),
664
- `mono` (графит), `pale` (бледная, под данные сверху); остальные — палитры
665
- **GramMaps Standard** из админки (`src/Admin/public/maps/standard-*.json`),
666
- перенесённые сюда скриптом, чтобы карта на своём движке выглядела так же, как
667
- везде в продукте:
668
-
669
- ```bash
670
- node scripts/import-standard-themes.mjs # → styles/standard/*.json
671
- ```
672
-
673
- ```ts
674
- map.setTheme('cyberpunk')
675
- OsmGL.Map.themeList // [{ id: 'day', name: 'Дневная' }, …] — для выпадающего списка
676
- ```
677
-
678
- Цвета переносятся как есть, а **свет и небо выводятся**: в стиле MapLibre нет ни
679
- наших двух источников, ни неба вовсе. Формулы откалиброваны по ручным темам —
680
- подставив их фон, формула обязана вернуть примерно их свет. Планы этажей у
681
- тёмных палитр тоже выводятся (в стилях админки этажей нет), причём светлота
682
- ПЕРЕВОРАЧИВАЕТСЯ: у нас стена темнее помещения, и в тёмной теме такая пара
683
- слилась бы в пятно. Правки в `styles/standard/` бессмысленны — следующий запуск
684
- скрипта их перетрёт; менять надо стиль в админке.
685
-
686
- Тема — это **накладка поверх базового стиля**, а не его копия:
687
-
688
- ```jsonc
689
- {
690
- "name": "Ночная",
691
- "background": "#11151c",
692
- "light": { "ambientIntensity": 0.34, "keyColor": "#cfd8ea" },
693
- "paint": {
694
- "water": { "color": "#0f2438" },
695
- "building": { "color": "#232833", "roofColor": "#2b313d" }
696
- },
697
- "hidden": ["housenumber"]
698
- }
699
- ```
700
-
701
- Копировать все три десятка слоёв в каждую тему пришлось бы вместе со структурой —
702
- фильтрами, зумами, шаблонами подписей, — и любая правка структуры требовала бы повторения
703
- во всех файлах. Отсюда же и приятное следствие: у тем ОДИН план для воркера, поэтому
704
- `setTheme` не пересобирает геометрию — тайлы не перекачиваются, картинка меняется в том
705
- же кадре (дымовой тест это проверяет: 0 тайлов в перезагрузке на каждой теме).
706
-
707
- ## Глобус
708
-
709
- ```ts
710
- const map = new OsmGL.Map({ projection: 'globe', zoom: 2, ... })
711
- map.setProjection('globe') // и обратно 'mercator'
712
- ```
713
-
714
- Тайлы при этом остаются **обычными меркаторными** — на сферу их натягивает
715
- вершинный шейдер, как в Mapbox. Переключение не трогает данные: ничего не
716
- перекачивается, меняется только набор видимых тайлов. К зуму 6 сфера сама
717
- переходит в плоскую карту, чтобы на городских масштабах за неё не платить.
718
-
719
- Устроено так, что **камеру не пришлось трогать вовсе**: глобус отдаёт не своё
720
- пространство, а локальные координаты касательной плоскости центра карты — x на
721
- восток, y на юг, z вверх, начало под камерой. Ровно то же, что даёт плоская карта.
722
- Отсюда и переход: это обычный `mix` двух позиций в ОДНОМ пространстве, а в центре
723
- кадра они совпадают тождественно, поэтому шва там нет по построению.
724
-
725
- Что ещё меняется в режиме глобуса: отбор тайлов идёт перебором уровня с отсечением
726
- по горизонту (спуск по пирамиде опирается на плоскую землю), обратная проекция —
727
- пересечением луча со сферой, а перетаскивание поворачивает шар, а не двигает
728
- плоскость. Геометрия на низких зумах дробится в воркере: на сфере прямой отрезок
729
- идёт хордой и срезает побережье.
730
-
731
- Для мелких зумов нужен мировой набор — основной покрывает только Таджикистан:
732
-
733
- ```bash
734
- bash deploy/rebuild-world-tiles.sh # ~1,2 МБ, z0–6, Natural Earth
735
- ```
736
-
737
- В набор входят океаны, озёра, границы, города и природные зоны — ледники, пустыни,
738
- тундра, болота. Лесов там нет: растительности в Natural Earth нет ни в одном
739
- масштабе, а зелень планетарного масштаба берётся из растровых наборов покрова,
740
- для которых нужен растровый слой (его у движка пока нет).
741
-
742
- Демо-сервер сам отдаёт z0–6 из него, а крупные зумы — из основного набора; так же
743
- устроен и прод. На глобусе камера ограничена: наклон до 60° (как в Mapbox) и нижняя граница зума,
744
- чтобы шар не превращался в точку. При включении глобуса камера доводится до этой
745
- рамки плавно. Ограничения — в [porting/globe.md](porting/globe.md).
746
-
747
- ## 3D-модели
748
-
749
- ```bash
750
- node scripts/fetch-models.mjs # четыре модели CC0 из набора Khronos, ~34 МБ
751
- ```
752
-
753
- ```ts
754
- map.addModel(new OsmGL.Model({
755
- url: '/examples/models/Lantern.glb',
756
- coordinates: [68.7864, 38.5598],
757
- fitHeight: 9, // высота НА МЕСТНОСТИ, метры
758
- rotation: 20, // азимут, градусы
759
- }))
760
- ```
761
-
762
- Модели живут **в одной сцене со зданиями**: общий свет и общая карта теней,
763
- поэтому тень фонаря ложится на дом, а тень дома — на фонарь. Отдельного прохода
764
- теней у моделей нет и не должно быть: вторая карта теней на общем контексте уже
765
- роняла кадр в `maps3d-web`.
766
-
767
- Размер задаётся через `fitHeight` в метрах, потому что единицы у моделей какие
768
- угодно — в наборе Khronos ToyCar имеет габарит 7 см, а Fox 155 «метров».
769
-
770
- Разбор GLB свой (`src/models/glb.ts`): позиции, нормали, координаты текстуры,
771
- базовый цвет и его текстура. Нет Draco и meshopt, анимаций, скиннинга и PBR сверх
772
- базового цвета — подробности и причины в [porting/models-gltf.md](porting/models-gltf.md).
773
-
774
- ## Выбор здания кликом
775
-
776
- ```ts
777
- map.on('click', ({ building }) => {
778
- if (building) console.log(building.featureId) // это osm_id из тайла
779
- })
780
- map.setHighlight(map.queryBuilding({ x, y })) // или вручную
781
- new OsmGL.Map({ highlightBuildings: false }) // подсветку выключить
782
- ```
783
-
784
- Попадание считается **лучом на CPU** по геометрии видимых тайлов, а не отдельным
785
- проходом идентификаторов с `readPixels`. У прохода есть цена: он синхронизирует
786
- конвейер на каждый клик и требует номер фичи в КАЖДОЙ вершине — четыре байта на
787
- вершину города ради одного попадания. Луч же строится сразу в пространстве
788
- тайла (матрица тайла обращается), поэтому и высоты, и координаты берутся ровно в
789
- тех единицах, в которых лежит меш. На настоящем тайле Душанбе один клик — 3–8 мс.
790
-
791
- Подсветка рисуется отдельным маленьким мешем из треугольников выбранной фичи
792
- (их даёт массив «номер фичи на треугольник», он есть с самого начала). Цвет
793
- полупрозрачный: под ним остаются видны окна и светотень — иначе выделенный дом
794
- читается не как «выбран», а как «другой дом». Рантайм-объект важнее здания:
795
- клик по маршруту или маркеру не проваливается в дом под ними.
796
-
797
- Выделение задаётся **на id фичи**, а не на тайл: здание, пересекающее границу,
798
- лежит в нескольких тайлах, и подсветка «того тайла, по которому кликнули»
799
- закрашивала половину дома. У MapLibre и Mapbox то же решение — `setFeatureState`
800
- привязан к id фичи и применяется во всех тайлах источника.
801
-
802
- ## Входы в здание
803
-
804
- ```jsonc
805
- {
806
- "id": "building-entrance", "type": "symbol",
807
- "source": "points", "source-layer": "entrances", "minzoom": 17,
808
- "layout": {
809
- "placement": "map", // стрелка лежит НА ЗЕМЛЕ, а не значком на экране
810
- "iconRotate": "angle", // азимут «внутрь дома», посчитан в базе
811
- "showFor": "highlight", // только у ВЫБРАННОГО дома
812
- "arrowLength": { "stops": [[17, 21], [21, 1.7]] }, // метры, по зуму
813
- "textField": ["{ref}"] // номер подъезда — обычной экранной подписью
814
- }
815
- }
816
- ```
817
-
818
- Как у Яндекса: выбрал дом — у дверей легли стрелки, показывающие, куда заходить.
819
- Без выбора их нет: висеть у каждого дома они не должны, это шум.
820
-
821
- **Размер — в пикселях со стопами по зуму, как ширина дорог** (`arrowUnits: "pixels"`,
822
- по умолчанию; длина 22 px на z17 → 56 на z22). Стрелка лежит на земле, и в метрах её
823
- экранный размер растёт вчетверо на два уровня зума: между z17 и z22 она разбухала с
824
- двух десятков пикселей до трёхсот и перерастала дом. Держать её строго постоянной
825
- тоже неверно — на мелком зуме она читается наклейкой поверх карты. Поэтому ровно тот
826
- же приём, что у `road-major` (11 px на z16 → 52 на z20): значение в пикселях,
827
- интерполяция по зуму, перевод в геометрию каждый кадр (пиксель — это `1/worldSize`
828
- нормализованного меркатора). `arrowUnits: "meters"` оставлен на случай, когда нужен
829
- именно объект на местности.
830
-
831
- **Отступ от стены** (`arrowGap`, 20 px): остриё встаёт НЕ на контур, а перед ним.
832
- Впритык оно сливается со стеной, а под наклоном уходит под неё. Проверка — остриё
833
- каждой стрелки обязано лежать снаружи контура выбранного дома.
834
-
835
- **Стрелка — геометрия на поверхности карты, а не значок.** Разница видна сразу:
836
- экранный значок всегда развёрнут к пользователю и одинаков при любом наклоне, а
837
- стрелка у двери обязана лежать на земле — поворачиваться вместе с картой, уходить
838
- в перспективу и прятаться за домом, который стоит перед ней. Поэтому
839
- `placement: "map"` рисуется отдельным проходом (`layers/ground-arrows.ts`)
840
- процедурными треугольниками: древко, голова, белая кайма. Размер в МЕТРАХ —
841
- стрелка это объект на местности, а не элемент интерфейса, и при зуме растёт вместе
842
- с домом. Рисуется в проходе земли, ДО зданий и без записи глубины: дом впереди
843
- закрывает её сам, а сама она ничего не загораживает. Координаты — нормализованный
844
- меркатор относительно центра карты, как у рантайм-объектов: во Float32 мировые
845
- пиксели города дали бы шаг в метры. Подпись (номер подъезда) осталась ЭКРАННОЙ —
846
- её надо читать при любом наклоне.
847
-
848
- Остриё смотрит на дверь, хвост уходит НАРУЖУ: стрелка показывает не «здесь дверь»,
849
- а «заходить отсюда». На самой двери стоит кружок (`arrowDotRadius`) — стрелка
850
- говорит, откуда заходить, точка — куда именно; так это устроено у Яндекса. Кружок
851
- лежит в ТОЙ ЖЕ фигуре, что и стрелка (общий набор вершин и общий список индексов),
852
- поэтому рисуется тем же проходом и той же парой «обводка + заливка».
853
-
854
- Номер подъезда стоит ПОД стрелкой по центру — так же, как подпись POI стоит под
855
- значком. Сдвига для этого два, и они разной природы: по карте — к середине стрелки
856
- (точка привязки сидит на стене, а стрелка от неё отъехала, и номер иначе лежит на
857
- доме), по экрану — вниз. Экранный обязан быть экранным: низ для читателя всегда низ
858
- кадра, как бы ни была повёрнута и наклонена карта.
859
-
860
- Форма — замкнутый контур (`arrowShape`), а не «прямоугольник плюс треугольник», и
861
- подобрана она под взгляд ПОД НАКЛОНОМ: сужение к хвосту читается как направление,
862
- даже когда голова частью за домом; вырез у основания головы делает фигуру стрелой,
863
- без него в перспективе она выглядит ромбом; хвост скруглён, потому что острый угол
864
- на земле вырождается в пару пикселей и мерцает при движении камеры. Обводка —
865
- тот же контур, отодвинутый наружу по биссектрисам (как фаска у рельефа): «та же
866
- стрелка, но крупнее» растёт от центра, и на носу кайма выходит вдвое тоньше, чем
867
- на боках. Триангуляция считается ОДИН раз на слой — форма у всех стрелок общая,
868
- меняются только положение и поворот.
869
-
870
- **Направление — ВНУТРЕННЯЯ НОРМАЛЬ К СТЕНЕ**, а не азимут на центр здания. Это не
871
- придирка: у длинного дома вход смещён от середины, и направление на центр уходит от
872
- нормали в среднем на 45°, а в худшем случае на 83° — стрелка ложится почти вдоль
873
- стены и читается как «мимо дома». Нормаль берётся по касательной к контуру в точке
874
- входа (±1 м вдоль периметра), из двух сторон выбирается та, что ведёт внутрь
875
- полигона. Сама точка снаппится на контур: узел бывает смещён на метр-другой, а
876
- остриё обязано упираться в стену.
877
-
878
- Считается это в базе при сборке тайлов, а не на клиенте: там есть и узел, и контур.
879
- Поворот камеры не нужен вовсе — геометрия лежит на земле и разворачивается вместе с
880
- картой сама. Проверка независимая: у всех 202 входов с домом точка в метре по
881
- азимуту лежит ВНУТРИ здания, в метре против — снаружи, отклонение от перпендикуляра
882
- к стене 0,00°.
883
-
884
- Номер подъезда отъезжает на древко стрелки (на 55% её длины наружу): точка привязки
885
- сидит на стене, и подпись иначе ложится на сам дом, а не рядом со стрелкой.
886
-
887
- **Цвет — свой на каждую тему**, и раздаёт его тот же скрипт, что и цвета категорий
888
- POI (`scripts/apply-poi-palette.mjs`). Причина та же, что у подписей, но острее:
889
- стрелка лежит НА ЗЕМЛЕ, поэтому дневной синий на графитовой карте сливается с
890
- асфальтом, а светлый акцент теряется на бетоне. Тон акцента сохраняется, светлота
891
- подгоняется под фон (на тёмных темах поднимается и слегка гасится насыщенность —
892
- иначе чистый синий выглядит неоном, на очень светлых притемняется), монохромная тема
893
- остаётся монохромной, обводка берётся у темы — это её «цвет фона под текстом».
894
- Контраст к фону проверяется тестом на ВСЕХ 25 темах (худший случай — 3.07 при пороге
895
- 2.5), номер подъезда красится тем же цветом и той же обводкой.
896
-
897
- **Привязка входа к дому — геометрическая, по контуру.** Сложить их по ключу
898
- нечем: id зданий в тайлах ставит планетайлер (`osm_id * 10 + тип`), а правки живут
899
- в своей схеме, и совпадение id — не гарантия. Поэтому рендерер отдаёт контур
900
- выбранного здания в мировых пикселях (проекция крыши, треугольниками по тайлам), а
901
- слой со `showFor: "highlight"` показывает только те точки, что в него попадают.
902
- Допуск — полтора метра: узел входа сидит РОВНО на ребре, и строгая проверка
903
- «внутри треугольника» теряла бы его на ошибке округления. Контур собирается по всем
904
- видимым тайлам сразу — дом на границе тайлов приезжает кусками, и половина входов
905
- иначе пропала бы.
906
-
907
- **Данные** — `deploy/entrances-geojson.sql` из схемы `edit.*` (в `osm.point` узел
908
- с одним тегом `entrance=yes` не попадает вовсе: классификатор импорта смотрит
909
- `highway`/`building`/`amenity`/…). Едут они в общий набор дорожных точек
910
- (`navpoints.mbtiles`) отдельным слоем — через поле `tippecanoe.layer` у фичи,
911
- поэтому ни второго файла, ни второй кнопки в админке не понадобилось. ГОЧА
912
- запроса: искать здание НАДО от узла (в каких путях он состоит). Обратный ход —
913
- «построить полигоны всех зданий и найти ближайший» — вешает базу на десятки
914
- минут: полигоны 478 тыс. зданий строятся целиком и без индекса.
915
-
916
- ## Камера в адресе страницы
917
-
918
- ```ts
919
- new OsmGL.Map({ hash: true }) // #зум/широта/долгота/поворот/наклон
920
- new OsmGL.Map({ hash: 'map' }) // #map=16.4/38.5598/68.7864 — рядом с чужими якорями
921
- ```
922
-
923
- `#16.4/38.559800/68.786400/-18/62` — формат тот же, что у MapLibre и Mapbox.
924
- Совместимость тут не украшение: ссылками на место обмениваются между картами
925
- разных движков, и свой формат означал бы, что чужая ссылка открывается
926
- «где-то не там».
927
-
928
- При загрузке адрес **сильнее** опций `center`/`zoom`: ссылка на место обязана
929
- открывать место, а не то, что зашито в приложении. Правка адреса руками и
930
- кнопка «назад» тоже двигают карту — это `hashchange`.
931
-
932
- Точность координат считается **от зума**: на z18 пятый знак широты это метр и
933
- терять его нельзя, а на z3 он же — шум, от которого ссылка перестаёт читаться.
934
- Поворот и наклон в ссылку не пишутся, пока они нулевые.
935
-
936
- Адрес обновляется с задержкой в 300 мс и через `replaceState`: за одно
937
- перетаскивание камера меняется десятки раз, и `pushState` набил бы историю так,
938
- что кнопка «назад» перестала бы работать.
939
-
940
- ## Качество под устройство
941
-
942
- ```ts
943
- new OsmGL.Map({ quality: 'auto' }) // по умолчанию
944
- map.setQuality('low') // или явно
945
- map.getQuality() // { auto, profile, pixelRatio }
946
- ```
947
-
948
- | Профиль | dpr | буфер | MSAA | воркеры | карта теней | детализация |
949
- |---------|-----|-------|------|---------|-------------|-------------|
950
- | `high` | ≤2 | ≤4096×4096 | да | ≤6 | 1024 | 1 |
951
- | `balanced` | ≤1.75 | ≤2560×2160 | да | ≤4 | 1024 | 0.85 |
952
- | `low` | ≤1.25 | ≤1920×1350 | нет | ≤2 | 512 | 0.5 |
953
-
954
- Телефон отличается от десктопа не столько силой GPU, сколько **плотностью
955
- экрана**: при `devicePixelRatio` 3 кадр 412×892 CSS — это 3,3 миллиона
956
- пикселей, вдвое больше типичного окна на ноутбуке, и рисует их вчетверо более
957
- слабый чип. Поэтому первое, что ограничивается, — плотность буфера: у MapLibre
958
- это `pixelRatio` и `maxCanvasSize`, у Mapbox — потолок 4096×4096.
959
-
960
- Потолка два, и второй не лишний: планшет с `dpr = 2` пролезает под лимит
961
- плотности и всё равно получает буфер, который его GPU не тянет, — поэтому
962
- ограничивается ещё и **площадь**.
963
-
964
- Профиль выбирается по дешёвым признакам (`hardwareConcurrency`, `deviceMemory`,
965
- тип указателя): мерить GPU бенчмарком на старте нельзя, это сотни миллисекунд
966
- ровно там, где важна первая отрисовка. Признак «мобильный» складывается по ИЛИ
967
- из `userAgentData.mobile` и «палец + небольшой экран» — объект `userAgentData`
968
- бывает present-but-wrong (эмуляторы, вебвью, режим «версия для ПК»), и через
969
- `??` его `false` побеждал бы настоящий телефон.
970
-
971
- Ошибку определения подчищает **адаптация делом**: восемь тяжёлых кадров подряд
972
- понижают профиль на ступень, и так до самого слабого. Кадр дольше 100 мс
973
- засчитывается сразу за три — ждать подтверждения очевидного незачем.
974
- Уложившийся кадр гасит счётчик постепенно, а не сбрасывает в ноль: одиночные
975
- быстрые кадры бывают и на стабильно тяжёлой карте. Вверх не поднимаемся —
976
- качели «плохо → хорошо → плохо» заметнее стабильной картинки.
977
-
978
- Мерится при этом **интервал между кадрами**, а не время нашей работы. Команды
979
- уходят в очередь WebGL, и вся плата за заливку приходит уже после возврата из
980
- `render`: на телефоне из-за этого соседствовали «кадр 2 мс» и карта, которая
981
- едет рывками. В событии `render` теперь два числа — `frameMs` (наша работа) и
982
- `intervalMs` (реальная стоимость кадра при непрерывной отрисовке).
983
-
984
- На слабых профилях выключается **работа, а не её вес**: `detail: 0` убирает
985
- материалы и фасады из шейдера целиком (множитель ноль оставил бы тот же шум с
986
- нулевым весом), тени не считаются вовсе, а тайлы берутся крупнее — тайл это и
987
- запрос, и разбор, и десятки вызовов отрисовки, и экономия здесь заметнее, чем на
988
- любом шейдере.
989
-
990
- `maxPixelRatio` и `workerCount` в опциях сильнее профиля. Сглаживание после
991
- создания карты не переключается: это атрибут контекста WebGL.
992
-
993
- ## Рельеф поверхностей
994
-
995
- ```jsonc
996
- {
997
- "id": "park",
998
- "type": "surface", // не fill: живёт в объёме
999
- "source-layer": "park",
1000
- "elevation": 3.2, // метры над землёй; отрицательное — ниже
1001
- "bevel": 2.5, // ширина фаски по горизонтали
1002
- "bevelDrop": 0.7, // на сколько фаска опускается
1003
- "cornerRadius": 9, // скругление в плане
1004
- "paint": { "color": "#cfe5bd", "material": "foliage", "sideShade": 0.14 }
1005
- }
1006
- ```
1007
-
1008
- ### Лесенка высот: у каждой поверхности своя ступень
1009
-
1010
- Плоские зоны в городе лежат друг на друге — парковка во дворе, спортплощадка в парке, школьный
1011
- участок в жилом квартале. Если две поверхности встали на одну высоту, их грани начинают спорить за
1012
- глубину: край двоится и мерцает при движении камеры (z-fighting). Поэтому **высоты обязаны быть
1013
- разными**, а порядок — по вложенности: чем чаще слой лежит ПОВЕРХ других, тем он выше.
1014
-
1015
- Действующая лесенка в `styles/osm-city.json` (метры):
1016
-
1017
- | высота | слой | |
1018
- |---|---|---|
1019
- | −0.087 | `water` | вода утоплена |
1020
- | −0.05 | `water-pool` | |
1021
- | 0.02 | `landuse-residential` | самая широкая зона — основание |
1022
- | 0.03 | `landuse-industrial` | |
1023
- | 0.035 | `landcover-sand` | |
1024
- | 0.045 | `landcover-wetland` | |
1025
- | 0.055 | `landuse-civic` | школы, больницы |
1026
- | 0.065 | `aeroway-area` | |
1027
- | 0.075 | `landuse-cemetery` | |
1028
- | 0.088 | `landuse-sport` | |
1029
- | 0.125 | `landcover-grass` | |
1030
- | 0.15 | `park` | |
1031
- | 0.175 | `landcover-wood` | |
1032
- | 0.19 | `landuse-parking` | выше всех: встречается и во дворе, и в парке |
1033
-
1034
- Добавляешь новую поверхность — дай ей СВОЮ ступень, а не «как у соседней». Значения мелкие
1035
- (сантиметры), на вид они не влияют: работает только порядок и подсветка кромки.
1036
-
1037
- ```ts
1038
- map.setStyle(OsmGL.RELIEF_STYLE) // готовый вариант
1039
- node scripts/make-relief-style.mjs // пересобрать из базового
1040
- ```
1041
-
1042
- Зелень поднимается над землёй, вода уходит вниз, площадки лежат своими
1043
- уровнями. Каждая поверхность — это верх, **фаска** и юбка до земли; фаска и есть
1044
- «край без уголков»: верхняя грань сдвинута внутрь, и переход к вертикали идёт
1045
- наклонной полосой. Вместе со скруглением контура в плане (`cornerRadius`) край
1046
- получается мягким с обеих сторон.
1047
-
1048
- Отдельный тип слоя, а не `fill` с высотой: поверхность пишет глубину, получает
1049
- свет и тень из общей карты, а материал (рябь, кроны) кладётся **только на верх**
1050
- — на фаске он читался бы полосами поперёк.
1051
-
1052
- Отдельный тип и не `fill-extrusion`: у здания высота приходит из данных, стены
1053
- несут фасад с окнами, а здесь уровень задаёт стиль, он бывает отрицательным, и
1054
- вместо стены нужен тонкий борт.
1055
-
1056
- Дороги поднимаются `paint.elevation` (метры) — это **юниформ**, геометрия не
1057
- пересобирается. В готовом стиле полотно лежит чуть выше земли, чтобы его не
1058
- проглотил приподнятый двор, а тропинки — на уровне зелени, по которой они идут.
1059
-
1060
- **Борт утопленной поверхности не берёт свет.** У воды, опущенной ниже земли,
1061
- юбка идёт вверх и упирается в грунт: физически это откос берега, и освещённый
1062
- борт читается тёмной каймой вдоль всей береговой линии. Поэтому у поверхности с
1063
- отрицательным уровнем `paint.sideLight` по умолчанию 1 — освещённый борт
1064
- смешивается с плоским цветом слоя. Именно смешивание с плоским цветом, а не
1065
- наклон нормали к вертикали: земля у нас вообще не геометрия, света она не
1066
- получает, и борт с любым, даже правильно посчитанным светом остаётся лентой. По
1067
- той же причине в рельефном стиле у утопленных слоёв `sideShade: 0`.
1068
-
1069
- ★ **Цвет борта — свой, а не фон стиля.** Одно время утопленный борт красился
1070
- фоном («борт это берег, то есть та же земля вокруг»). Мысль верна ровно там, где
1071
- вокруг воды и правда голая земля: пруд посреди парка получал светлую кайму
1072
- шириной с фаску, и она читалась не берегом, а **зазором между водой и травой**.
1073
- Чем окружён водоём, слой не знает и знать не может, поэтому чужим цветом борт не
1074
- красит. Кому нужен берег — задаёт `paint.sideColor` явно.
1075
-
1076
- Переопределяется на слое — `paint.sideColor`, `paint.sideLight`, `paint.sideShade`;
1077
- у приподнятых поверхностей поведение прежнее, иначе парк потеряет объём.
1078
-
1079
- **Остров внутри воды закрывается крышкой — только в ГЛУБИНУ.** Плоские слои
1080
- рисуются без записи глубины (земля это краска, а не геометрия), поэтому
1081
- утопленную воду перекрывать нечем: сквозь остров посреди реки была видна вода за
1082
- ним. Дырка в контуре утопленной поверхности получает горизонтальную крышку на
1083
- уровне земли, но её индексы лежат ОТДЕЛЬНО (`capIndexOffset`) и рисуются своим
1084
- проходом с выключенной записью цвета: на острове могут быть свои слои — парк,
1085
- пляж, застройка, — и закрашивать его землёй значило бы менять карту вместо того,
1086
- чтобы починить перекрытие. Проход идёт по всем тайлам сразу, до цветного:
1087
- вода соседнего тайла, нарисованная раньше чужой крышки, уже легла бы краской, и
1088
- глубина её не отменит.
1089
-
1090
- Сдвиг контура внутрь ради фаски делается по биссектрисе и **проверяется**: если
1091
- площадь схлопнулась или контур вывернулся, фаска для этой фигуры не строится.
1092
- Иначе узкая протока превращалась бы в узел самопересечений.
1093
-
1094
- **Соседние полигоны слоя склеиваются.** Река в данных нарезана на десяток
1095
- полигонов, парк граничит с газоном того же слоя. Каждый сам по себе не знает о
1096
- соседе и строит борт по всему своему краю — на общей границе два борта
1097
- складываются в отчётливый гребень посреди сплошной воды. Поэтому рёбра,
1098
- встречающиеся в слое дважды, считаются внутренними: ни борта, ни скругления, ни
1099
- сдвига под фаску на них нет, и слой читается одной поверхностью. Остаётся случай
1100
- T-образного стыка (у соседа на общем ребре лишняя вершина) — там рёбра не
1101
- совпадают точь-в-точь, и тонкая линия может остаться.
1102
-
1103
- **ГОЧА (стоила пустых тайлов).** Место в буфере бронирует КАЖДЫЙ, кто пишет
1104
- вершины, и по своему точному числу. Оценка «на фигуру целиком» была занижена:
1105
- контур даёт верх + фаску + юбку, то есть вдевятеро больше вершин, чем в нём
1106
- точек, — и на кольце длиннее ~30 точек запись уходила за границу `DataView`.
1107
- Симптом при этом не «пропал слой», а «на некоторых зумах тайлы не грузятся»:
1108
- исключение обрывало сборку ВСЕГО тайла, ответ не уходил вовсе, и на карте
1109
- оставались одни подписи. Поэтому же тесселяция каждого слоя обёрнута в `try` —
1110
- сбойный слой теперь теряет только себя, а его имя уезжает в `diag.failed` и в
1111
- предупреждение консоли. На синтетическом прямоугольнике это не ловится (четыре
1112
- точки), проверка идёт по настоящим тайлам всех зумов.
1113
-
1114
- ## Скругление углов
1115
-
1116
- ```jsonc
1117
- {
1118
- "id": "building",
1119
- "type": "fill-extrusion",
1120
- "cornerRadius": 1.6, // метры; 0 или нет свойства — острые углы
1121
- "cornerSegments": 2, // отрезков на ПРЯМОЙ УГОЛ (и потолок на дугу)
1122
- "paint": { … }
1123
- }
1124
- ```
1125
-
1126
- ```ts
1127
- map.setStyle(OsmGL.SOFT_STYLE) // готовый стиль со скруглением
1128
- node scripts/make-soft-style.mjs // пересобрать его из базового
1129
- ```
1130
-
1131
- Радиус задаётся **на слой**, поэтому у здания это фаска в метр-два, у зелени и
1132
- воды — естественная граница в десяток метров, а у землепользования что-то между.
1133
- Одинаковый радиус на всех превращает мелкие дворы в кляксы, а дома оставляет
1134
- острыми.
1135
-
1136
- У дома скругляется контур, а значит и стены, и **крыша**: она строится по тому
1137
- же кольцу. Угол срезается на равную длину по обоим рёбрам, срез заполняется
1138
- квадратичной кривой Безье. Скругляются только **выпуклые** углы: срез вогнутого
1139
- уходит внутрь контура и даёт самопересечение, которого триангуляция не прощает.
1140
- На границе тайла срез не делается — у соседа он пришёлся бы на другое место, и
1141
- на стыке получилась бы ступенька.
1142
-
1143
- Это **стиль, а не тема**: скругление считает воркер при тесселяции, то есть его
1144
- смена пересобирает тайлы. Тема по устройству бесплатна — она меняет только цвета
1145
- и свет, и класть в неё геометрию значило бы отменить это свойство.
1146
-
1147
- Цена честная: на настоящем тайле Душанбе меш зданий вырастает примерно вдвое
1148
- (88 → 187 тыс. вершин при двух отрезках на дугу, втрое при трёх). Поэтому у
1149
- зданий в `SOFT_STYLE` отрезков два, а мелкие уступы короче трети радиуса не
1150
- скругляются вовсе — на глаз их не видно, а вершин они добавляют половину.
1151
-
1152
- ### Дробность дуги
1153
-
1154
- `cornerSegments` — это отрезки на **прямой угол**, а не потолок. Раньше шаг дуги
1155
- был жёстко 22,5°, и прямой угол получал ровно четыре отрезка независимо от того,
1156
- что стояло в стиле: потолок срабатывал только на поворотах круче 90°, а таких в
1157
- городе почти нет. Поднимать число в стиле было бессмысленно, и водоём на близком
1158
- зуме читался срезанной фаской. Теперь шаг выводится из числа (90°/N) и не бывает
1159
- грубее прежних 22,5° — стили с `segments ≤ 4` дают ту же геометрию, что и до
1160
- правки, до точки.
1161
-
1162
- **У поверхностей скругляется КАЖДЫЙ выпуклый угол** (`minCutRatio: 0`), а не
1163
- только те, где срез вышел не меньше трети радиуса. У зданий этот порог экономит
1164
- меш на мелких уступах, а берег из таких уступов и состоит: тайлы кончаются на
1165
- z14, планетайлер упрощает контур до рёбер в пять-семь метров, и пропуская их, мы
1166
- пропускаем ровно те углы, из-за которых водоём выглядит рубленым. Вода в
1167
- `CITY_STYLE` получает 16 отрезков на угол, зелень и площадки — 12, здания
1168
- остаются на двух. Поверхности от этого тяжелеют примерно вдвое-втрое (вода на
1169
- z13 — 12 → 33 тыс. вершин), но в абсолютных числах это доли бюджета зданий.
1170
-
1171
- ★ **Координата вершины поверхности — Float32, а не Int16 единиц тайла.** У
1172
- зданий Int16 хватает: единица тайла это около полуметра на z14, нашем последнем
1173
- зуме с данными, а деталей такого размера у дома нет. У поверхности есть — берег
1174
- это пояс фаски шириной 0,43 единицы, то есть уже полутора округлений. На целой
1175
- сетке было два видимых дефекта разом: дуга из отрезков по 0,7 единицы садилась
1176
- на неё лесенкой (19 точек кольца из 89 попадали в одну точку сетки), а ширина
1177
- берегового пояса гуляла от 0,24 до 0,45 единицы. На z21 видно и то и другое, так
1178
- что «сделали глаже» выглядело ХУЖЕ, чем было. Четыре лишних байта на вершину
1179
- (stride 12 → 16) платит только поверхность: их на тайле десятки тысяч против
1180
- сотен тысяч у зданий.
1181
-
1182
- ## Материалы плоских слоёв
1183
-
1184
- ```jsonc
1185
- "paint": {
1186
- "color": "#a5cbe3",
1187
- "material": "water", // flat | water | foliage | sand | asphalt
1188
- "materialScale": 14, // размер рисунка в МЕТРАХ: у воды — длина волны
1189
- "materialSpeed": 1, // скорость ряби; 0 — статичная вода без перерисовок
1190
- "materialStrength": 1 // выраженность, 0 — та же сплошная заливка
1191
- }
1192
- ```
1193
-
1194
- У сплошной заливки нет нормали, поэтому свет на неё не ложится вовсе — карта
1195
- читается бумажной. Материал даёт поверхности **микрорельеф**, и дальше работает
1196
- тот же свет, что у зданий: вода бликует, кроны получают светотень, асфальт
1197
- перестаёт быть однотонным пятном. Всё считается процедурно, ни одной текстуры.
1198
-
1199
- Настраивается целиком из стиля, поэтому «сплошной» вариант никуда не делся:
1200
- нет `material` — слой рисуется ровно как раньше, без единой лишней инструкции в
1201
- шейдере. Темы переопределяют это теми же paint-полями.
1202
-
1203
- Координата рисунка **сквозная через тайлы**: начало тайла в метрах приходит
1204
- юниформом (с остатком от 65 км, чтобы не терять точность Float32). Возьми её
1205
- внутри тайла — и рисунок начнётся заново на каждой границе: по швам пойдут
1206
- полосы, которые сразу замечают глазами и не видит ни один тест. Дымовой тест
1207
- теперь ставит камеру ровно на границу тайлов z14 и меряет разрыв между соседними
1208
- столбцами кадра.
1209
-
1210
- ★ **Поверхности перекрывают линию разреза на единицу тайла**
1211
- (`SURFACE_CUT_BLEED`). Обрезка по квадрату тайла точная, но НА ЭКРАНЕ соседние
1212
- тайлы считают общую границу разными матрицами, и во Float32 они расходятся на
1213
- доли пикселя: половину пикселей шва не накрывает никто, и сквозь воду
1214
- проступает земля отдельными точками — волосяная трещина ровно по стыку (замер:
1215
- пиксель 174,225,250 вместо 151,221,255, то есть четверть пикселя земли).
1216
- Вершины, лежащие НА разрезе, выдвигаются наружу: соседи перекрываются вместо
1217
- того, чтобы иногда не сходиться, а перекрытие двух одинаковых по цвету и высоте
1218
- поверхностей невидимо. Бортов это не касается — на линии разреза их не строят.
1219
-
1220
- ★ **Обрезает нахлёст ТРАФАРЕТ, а не арифметика вершин** (`drawTileStencil`).
1221
- Перед проходом поверхностей каждый видимый тайл красит свой квадрат в трафарет
1222
- своим номером, и дальше слой рисует только «свои» пиксели (`stencilFunc EQUAL`).
1223
- Так делает mapbox-gl-js (`_renderTileClippingMasks` + `stencilModeForClipping`),
1224
- и это единственное честное лекарство: два соседних квада делят пиксели границы
1225
- по правилу заполнения растеризатора — без пропусков и без повторов, на любом
1226
- зуме и при любом наклоне. Отсюда и вся конструкция: геометрия обязана ЗАЕЗЖАТЬ
1227
- за границу (`a_bleed`), трафарет её ровно там и срежет.
1228
-
1229
- Полумеры не работают, и обе проверены на карте: обрезать геометрию точно по
1230
- квадрату — остаётся щель (соседи считают границу разными матрицами и во Float32
1231
- расходятся на доли пикселя); дать нахлёст без трафарета — у полупрозрачного слоя
1232
- перекрытие ложится второй краской, и полоса выходит заметнее трещины (это сразу
1233
- проявилось на парке с `opacity: 0.75`). Трафарет закрывает оба случая разом,
1234
- поэтому нахлёст кладётся всем слоям без оглядки на прозрачность.
1235
-
1236
- Номера, а не «покрасить пиксель один раз»: слоёв поверхностей девять, и чистить
1237
- трафарет под каждый было бы дороже одного прохода на кадр. Трафарет
1238
- восьмибитный, поэтому при более чем 255 видимых тайлах разметка пропускается —
1239
- это мелкие зумы, где шва и не видно.
1240
-
1241
- ★ **Метры в этой координате — МЕРКАТОРНЫЕ, а не настоящие.** Сначала ширина
1242
- тайла бралась по широте его собственного центра (`metersPerTile`), и это тихо
1243
- ломало ровно то, ради чего координата заведена. У соседей по вертикали широта
1244
- разная, ширина тайла отличается на пару миллиметров, а начало считается как
1245
- `x × ширина`, где `x` — сотни тысяч: на z18 два соседних ряда расходились на
1246
- **324 метра**, то есть больше чем на две ширины тайла. Вертикальный шов был
1247
- цел, а горизонтальный резал водоём пополам — особенно заметно на бегущей ряби.
1248
- Меркаторные метры от широты не зависят, поэтому непрерывны и по x, и по y, и
1249
- между зумами (у родителя тайл вдвое шире, а `x` вдвое меньше — начало то же).
1250
-
1251
- Обратно в настоящие метры (чтобы рябь осталась четырнадцатиметровой, а черепица
1252
- черепицей) переводит делитель по широте **центра карты**, общий на кадр:
1253
- делится и масштаб, и начало, поэтому размеры прежние, а непрерывность точная.
1254
- По широте центра, а не тайла, именно потому, что делитель обязан быть у всех
1255
- тайлов один — иначе шов возвращается. Цена: при движении с юга на север рисунок
1256
- незаметно «дышит» (по Таджикистану — шесть процентов на четыреста километров).
1257
-
1258
- Рисунок гаснет по `fwidth` — числу метров на пиксель, — причём порог считается
1259
- **от размера рисунка**: рябь в 14 м и дюны в 26 м исчезают из виду на разных
1260
- зумах, и общий порог либо гасил бы дюны раньше времени, либо оставлял бы воду
1261
- рябить пикселями.
1262
-
1263
- Две вещи, которые пришлось выяснить на воде. Настоящая рябь наклонена на единицы
1264
- градусов, и любая формула блика на такой нормали даёт ноль — для блика нормаль
1265
- намеренно «крутим» круче, для рассеянного света берём честную. А три чистых
1266
- синуса дают идеально ровные параллельные гребни, читающиеся штриховкой, поэтому
1267
- фаза сбивается медленным шумом.
1268
-
1269
- ## Небо и дымка
1270
-
1271
- ```ts
1272
- map.setSky({ skyColor: '#7fb2e5', horizonColor: '#dfe6ee', fogRange: [1200, 9000] })
1273
- map.setSky(false)
1274
- ```
1275
-
1276
- Небо — **вертикальный градиент во весь экран**, рисуемый первым. Геометрии у него нет:
1277
- крена у нашей камеры не бывает, поэтому горизонт всегда горизонтальная линия, а её
1278
- экранную координату даёт `transform.horizonScreenY()`. Скайбокс с кубической текстурой
1279
- дал бы ровно тот же результат втрое дороже.
1280
-
1281
- Ниже горизонта заливается цветом **дымки**, а не неба. Туда же уходит дальняя земля —
1282
- совпадение цветов делает стык невидимым, и край данных перестаёт читаться как обрыв.
1283
- Ради этого всё и сделано.
1284
-
1285
- Дальность дымки (`fogRange`) меряется **в расстояниях от камеры до центра карты**, как
1286
- в Mapbox, а не в метрах. Это не мелочь: при взгляде СВЕРХУ вся видимая земля равноудалена
1287
- от камеры (от 1.0 в центре кадра до 1.15 у края), и порог в метрах либо накрывает весь
1288
- кадр дымкой, либо не срабатывает никогда — подобрать его на все зумы нельзя. В
1289
- относительных единицах порог 1.35 сам собой оказывается за пределами вида сверху и
1290
- попадает в кадр только при наклоне, где земля действительно уходит вдаль.
1291
-
1292
- Глубина во фрагменте берётся как `1.0 / gl_FragCoord.w`; это работает в любом шейдере,
1293
- что важно — слои земли рисуются с выключенным тестом глубины, и на буфер глубины
1294
- полагаться нельзя.
1295
-
1296
- Каждая тема задаёт своё небо: у ночной оно тёмное, у бледной почти белое.
1297
-
1298
- **На глобусе небо становится космосом**: тёмный фон, звёзды и светящаяся кайма
1299
- атмосферы вокруг планеты (`spaceColor`, `atmosphereColor`, `starIntensity`). Рисует
1300
- это тот же полноэкранный проход. Звёзды закреплены в мире, а не на экране, поэтому
1301
- при повороте камеры они стоят на месте, а не ползут вместе с кадром.
1302
-
1303
- **Подписи дымка не берёт** — они рисуются в экранных координатах, и глубины во фрагменте
1304
- у них нет. Поэтому подписи дальше конца дымки просто не размещаются: иначе над затянутой
1305
- далью висела бы россыпь совершенно чётких названий.
1306
-
1307
- ## Тени
1308
-
1309
- По умолчанию **выключены**: проход глубины идёт по всей объёмной геометрии.
1310
-
1311
- ```ts
1312
- map.setShadows(true)
1313
- map.setShadows({ strength: 0.6, color: '#5a6b86', resolution: 2048, bias: 0.0015 })
1314
- ```
1315
-
1316
- Направление берётся у ключевого источника света (`setLight`), поэтому отдельной ручки
1317
- «куда падает тень» нет — солнце одно.
1318
-
1319
- Одна карта теней на кадр, объём источника **подгоняется под видимую землю**: при
1320
- наклоне 70° видимая площадь больше, чем сверху, в десятки раз, и фиксированный объём
1321
- либо обрезал бы тени спереди, либо тратил бы все тексели на пустоту у горизонта.
1322
- Фильтрация аппаратная (`sampler2DShadow` + `textureProj`), сверху 2×2 выборка.
1323
-
1324
- **На плоскую графику тень кладётся ОДНИМ проходом** — квадрат на тайл с умножающим
1325
- блендингом поверх всех уже нарисованных слоёв. Слоёв земли три десятка и они
1326
- перекрываются: выборка в каждом фрагменте каждого слоя означала бы платить за тень
1327
- столько раз, сколько слоёв под пикселем. Заодно тень сама ложится на дороги, воду и
1328
- объекты приложения, без единой строки в их шейдерах.
1329
-
1330
- **Глубина и цвет тени не настраиваются, а выводятся из света**: `ambient / (ambient
1331
- + directional)` по каждому каналу — ровно то, что остаётся от освещения, когда солнце
1332
- загорожено. Поэтому при ярком солнце тени глубже, при синем небе — синее.
1333
-
1334
- **На объёме тень гасит только направленный свет**, а не итоговый цвет. В тени
1335
- остаются ambient и заполняющий источник, поэтому стена сохраняет свой цвет и просто
1336
- теряет солнце; умножение итогового цвета на оттенок давало серое пятно.
1337
-
1338
- Тень отбрасывают только здания — у плоской земли нет объёма, и гонять её через второй
1339
- проход было бы чистой потерей. Планы этажей (`overlay`) тень не принимают: они внутри
1340
- здания.
1341
-
1342
- Координата в карте теней считается СВОЕЙ матрицей на слой, а не из клипа камеры.
1343
- Почему так — в [porting/shadows.md](porting/shadows.md); коротко: восстановление
1344
- позиции из уже сжатой перспективой глубины во Float32 разваливается, и все здания
1345
- уходят в собственную тень.
1346
-
1347
- Освещение заодно стало естественнее: ambient зависит от нормали (небо ярче со стороны
1348
- солнца, грань вниз видит меньше неба), а свет применяется с гаммой — прямое умножение
1349
- sRGB-цвета пересвечивало светлое и заваливало тёмное.
1350
-
1351
- ## Освещение
1352
-
1353
- Ровный ambient + **два направленных источника**: ключевой (key) задаёт светотень,
1354
- заполняющий (fill) светит примерно с противоположной стороны и вытягивает грани,
1355
- отвёрнутые от ключевого. Тот же набор — ambient + dir1 + dir2 — используют картовые
1356
- движки, и на зданиях он принципиально лучше полусферы, с которой мы начинали: у
1357
- полусферы освещённость зависит только от `normal.z`, а у ЛЮБОЙ вертикальной стены
1358
- `normal.z = 0`, значит все четыре стороны дома получали ОДИНАКОВЫЙ ambient и отличались
1359
- лишь единственным солнечным членом — дома читались плоскими.
1360
-
1361
- Азимуты по умолчанию намеренно НЕ диагональные (120/300, а не 135/315): на диагонали
1362
- пары стен восток/юг и запад/север получают равную яркость и объём снова пропадает.
1363
- На текущих значениях четыре стороны дают примерно 122, 135, 137 и 160 из 255.
1364
-
1365
- ## Как устроено
1366
-
1367
- ```
1368
- Map ──┬── Transform камера: матрицы, project/unproject, пирамида видимости
1369
- ├── GestureManager панорама/зум/поворот/наклон + инерция
1370
- ├── TileSource ──── coveringTiles → WorkerPool → LRU-кеш тайлов
1371
- │ │
1372
- │ воркер: fetch → MVT → генераторы мешей
1373
- │ └── transfer буферов без копирования
1374
- ├── Style ───────── слои, фильтры, значения по зуму, свет
1375
- └── LayerRenderer заливки → линии → экструзия; VAO на пару (тайл × слой)
1376
- ```
1377
-
1378
- **Координаты.** Единая система — «мировые пиксели»: нормализованный меркатор × `worldSize(zoom)`,
1379
- ось Y вниз. Высота хранится в **метрах**, а матрица домножает Z на `pixelsPerMeter` — поэтому
1380
- одно и то же здание корректно выглядит на любом зуме без пересборки буфера.
1381
-
1382
- **Точность.** Матрицы считаются в `Float64` (на z=20 мировые координаты доходят до ~5·10⁸ пикселей —
1383
- во `Float32` там уже дрожание), а в шейдер уходит `Float32` уже после переноса начала координат
1384
- в тайл. Вершины лежат в локальных координатах MVT `[0..extent]`.
1385
-
1386
- **LOD.** `coveringTiles` дробит тайл, пока его проекция на экран крупнее тайлового квадрата.
1387
- При наклонённой камере это само собой даёт уровни детализации: у горизонта тайлы далеко,
1388
- дробление там останавливается раньше, и вместо тысяч тайлов до горизонта получается пара десятков.
1389
-
1390
- **Форматы вершин.** Заливка — 4 байта (только позиция). Линия — 12 байт: оба конца отрезка
1391
- (`a_p0`, `a_p1` — одинаковы у всех четырёх вершин капсулы) + `a_corner` (какой конец и какая
1392
- сторона). Здание — 12 байт:
1393
- `a_pos` 2×Int16, `a_height` Uint16 (метры × 4), `a_tint` Uint8, `a_ao` Uint8, `a_normal` 3×Int8n.
1394
- Высота ушла из Float32 в Uint16 ради места под `a_tint`: без разброса оттенка квартал выглядит
1395
- одной сплошной массой. Вершины между рёбрами намеренно не переиспользуются — иначе усреднится
1396
- нормаль и углы дома «поплывут».
1397
-
1398
- **Рисунок вдоль линии.** Каждая вершина несёт расстояние от начала ломаной, поэтому
1399
- пунктир (`dashArray`) и стрелки направления (`arrow`) идут НЕПРЕРЫВНО вдоль всей дороги,
1400
- а не начинаются заново на каждом отрезке. Пунктир применён к тропам и границам;
1401
- стрелки в базовый стиль намеренно НЕ включены — сплошь усыпанная шевронами улица
1402
- выглядит грязно, и на подложке 2GIS их тоже нет. Возможность остаётся для слоя пробок. Шеврон считается аналитически в
1403
- шейдере — текстуры нет, поэтому он чёткий на любом зуме и не занимает места в атласе
1404
- (у MapGL для этого своя функция `sdf_chevron`). Масштаб рисунка задаётся один на тайл:
1405
- если считать по экранной длине каждого отрезка, при наклоне соседние отрезки получат
1406
- разный масштаб и пунктир порвётся на стыках.
1407
-
1408
- **Линии рисуются «капсулами»**: на каждый отрезок — четырёхугольник, раздутый на
1409
- пол-ширины во все стороны, а точную форму вырезает фрагментный шейдер по расстоянию до
1410
- отрезка. Отсюда бесплатно берутся круглые стыки, круглые концы и аналитическое
1411
- сглаживание края. Так сделано вместо угловых стыков (miter), с которых начинали: там на
1412
- концах дорог оставались рубленые торцы, а на перекрёстках зияли выемки. Раздувание
1413
- считается ПОСЛЕ проекции, в экранных пикселях — иначе при наклонённой камере ближние
1414
- дороги раздувало бы, а дальние истончало.
1415
-
1416
- ## Проверка
1417
-
1418
- ```bash
1419
- npm run typecheck
1420
- npm test # 166 тестов: геометрия, стиль, темы, подписи, объекты, этажи, тени, небо, конвейер
1421
- npm run demo # в отдельном окне
1422
- node scripts/smoke.mjs # рендер в headless-браузере, 4 ракурса
1423
- ```
1424
-
1425
- `npm test` включает сквозной прогон на **настоящем** тайле из `data/tiles/tajikistan.mbtiles`
1426
- (разбор MVT → экструзия → проверка намотки и нормалей). Синтетика тут бесполезна: ошибки
1427
- вылезают именно на реальных данных — дырки в полигонах, высоты строкой, незамкнутые кольца.
1428
- Если файла нет, эти тесты помечаются пропущенными, а не падают.
1429
-
1430
- `scripts/smoke.mjs` гоняет **несколько ракурсов**, и это не перестраховка: баг с
1431
- `ELEMENT_ARRAY_BUFFER` (см. ниже) проявлялся только на одиночном тайле — при нескольких
1432
- тайлах часть рисовалась, и карта выглядела рабочей.
1433
-
1434
- Он же проверяет **единство цвета иконки и текста**: слой POI красится в чистый красный,
1435
- зелёный и синий, и от всех закрашенных пикселей требуется попасть в целевой ТОН
1436
- (соотношение каналов, а не яркость — у сглаженного края буквы та же краска, но меньшая
1437
- интенсивность). Ломается это требование тихо: достаточно разойтись кодировкам SDF,
1438
- и картинка ещё будет похожа на правду.
1439
-
1440
- ## Грабли, на которые уже наступили
1441
-
1442
- - **`ELEMENT_ARRAY_BUFFER` — состояние VAO, а не глобальное.** Создание индексного буфера
1443
- соседнего тайла, пока привязан VAO предыдущего, молча переписывало тому привязку: тайл
1444
- начинал рисовать чужими индексами по своим вершинам. Ошибок GL нет, кадр не падает — тайл
1445
- просто исчезает. Лечится тем, что буферы создаются только со снятым VAO (`gl/buffer.ts`).
1446
- - **Экспорт класса с именем `Map`.** `const { Map } = OsmGL` в глобальном скрипте затеняет
1447
- встроенный `Map`, и `Evented` начинает бесконечно строить наш класс → переполнение стека
1448
- в минифицированном коде. Лечится захватом встроенных конструкторов при загрузке бандла
1449
- (`core/natives.ts`).
1450
- - **Шаблон тайлов и `new URL()`.** Приводить адрес к абсолютному нужно ПОСЛЕ подстановки
1451
- `{z}/{x}/{y}`: иначе скобки кодируются в `%7B`/`%7D` и подстановка перестаёт совпадать.
1452
- Абсолютный адрес нужен потому, что воркер поднят из Blob URL и относительные пути
1453
- относительно `blob:` не разбираются.
1454
- - **`readPixels` для проверки кадра.** При `preserveDrawingBuffer:false` буфер после
1455
- композитинга пуст, и исправный движок читается как чёрный кадр. Проверять только по
1456
- скриншоту.
1457
- - **Намотка стен: пространство тайла ЛЕВОСТОРОННЕЕ** (x на восток, y на юг, z вверх), да
1458
- ещё проекция переворачивает Y. При «интуитивном» обходе лицевой в NDC оказывается стена,
1459
- ОТВЁРНУТАЯ от камеры: backface-culling срезал ближние стены, и дома выглядели пустыми
1460
- коробками — было видно внутреннюю поверхность дальней стены. Обход стен — `bl,tr,br`.
1461
- Проверять правосторонним векторным произведением тут НЕЛЬЗЯ: оно даёт вектор,
1462
- противоположный внешней нормали, и тест «зеленел» на вывернутой намотке.
1463
- - **Капсула линии скручивалась бабочкой.** Перпендикуляр считался от направления
1464
- «на соседний конец», а на дальнем конце оно противоположно — углы квада перекрещивались,
1465
- и дороги превращались в цепочку линз. Концы отрезка хранятся канонически (p0, p1
1466
- одинаковы у всех четырёх вершин).
1467
- - **`gl_FragCoord` — в пикселях БУФЕРА, а стиль — в CSS-пикселях.** Ширину линии надо
1468
- домножать на плотность, иначе на экране с dpr=2 дороги вдвое тоньше.
1469
- - **`sampler2DShadow` требует сравнивающей текстуры ВСЕГДА**, даже когда шейдер до
1470
- выборки не доходит. Без неё текстура для этого типа сэмплера «неполна», поведение не
1471
- определено, и слой молча не рисует ничего — карта осталась с одними подписями, БЕЗ
1472
- единой ошибки GL. Лечится заглушкой 1×1.
1473
- - **Юниформ `u_shadow_map` надо ставить каждой программе.** Забыли в проходе зданий —
1474
- сэмплер остался на блоке 0 с атласом подписей, и здания перестали рисоваться с
1475
- `INVALID_OPERATION`. Видно это только через `gl.getError()`.
1476
- - **Номера атрибутов у разных программ разные.** VAO привязывает буфер к НОМЕРУ, а не
1477
- к имени, поэтому общий меш зданий и прохода глубины подавал бы вершины не в те
1478
- атрибуты. Лечится `bindAttribLocation` до линковки.
1479
- - **Стиль правил ОБЩИЙ образец.** `Style` держал ссылки на слои спецификации, а встроенный
1480
- стиль — модуль, один на весь бандл. `setLayerVisible`/`setPaintProperty` правили слой на
1481
- месте, то есть сам `DEFAULT_STYLE`. Проявлялось отложенно и совершенно непонятно: слой,
1482
- когда-то выключенный, ПРОПАДАЛ навсегда после следующего `setTheme`, потому что тема
1483
- строилась из уже испорченного образца. Нашёл дымовой тест — планы этажей перестали
1484
- строиться после прогона проверки цветов, которая гасит все слои кроме одного.
1485
- - **`visible: false` фильтровался только при разборе стиля**, а `layerVisibleAt` его не
1486
- проверял — `setLayerVisible` молча ничего не выключал.
1487
- - **Подписи не появлялись из-за курицы и яйца.** На первом кадре глифов ещё нет, значит
1488
- не размещается ничего, `quads === 0` — и ранний выход не давал уйти запросу на догрузку
1489
- шрифтов. Запрос теперь отправляется ДО проверки на пустоту.
1490
- - **Край SDF на бинарной маске.** У маски без сглаживания пикселя с нулевым расстоянием не
1491
- существует: соседние дают 223 и 159, а граница 192 проходит МЕЖДУ ними. Тест должен
1492
- проверять пересечение, а не значение в одном пикселе.
1493
-
1494
- ## Состояние
1495
-
1496
- Готово: камера и жесты, конвейер тайлов с воркерами и LRU (несколько источников), разбор
1497
- MVT, стиль с фильтрами и интерполяцией по зуму, заливки, линии-капсулы со скруглениями
1498
- (пунктир, стрелки, смещение), слой 3D-зданий (экструзия, крыши, дырки, AO, разброс
1499
- оттенка, свет ambient + key + fill, анимация появления), подписи и SVG-иконки в общем
1500
- SDF-атласе с разрешением коллизий, четыре темы, отсечение по пирамиде видимости с LOD.
1501
-
1502
- Плюс рантайм-объекты приложения (маршруты, зоны, окружности, DOM-маркеры) с попаданием
1503
- по клику, планы этажей с переключателем, тени от зданий и небо с дымкой.
1504
-
1505
- Дальше по очереди: пикинг по тайловым фичам (здания и POI — там уже через GPU), подписи
1506
- ВДОЛЬ линии (сейчас улица подписывается горизонтально в середине самого длинного звена),
1507
- GeoJSON-источник, DEM, glTF-модели. Постпроцессное сглаживание не планируется: контекст
1508
- и так с MSAA 4×. Полный разбор того, что осталось перенести, — в [PORTING.md](PORTING.md)
1509
- и по подсистемам в [porting/](porting/).
1
+ | Профиль | dpr | буфер | MSAA | воркеры | тени | детализация | тайлы |
2
+ |---------|-----|-------|------|---------|------|-------------|-------|
3
+ | `high` | ≤2 | ≤4096×4096 | да | ≤6 | да, 1024 | 1 | до 512 |
4
+ | `balanced` | ≤1.75 | ≤2560×2160 | да | ≤4 | да, 1024 | 0.85 | до 192 |
5
+ | `low` | ≤1.25 | ≤1920×1350 | нет | ≤2 | **нет** | 0.5 | до 96, крупнее |
6
+ | `minimal` | 1 | ≤1280×900 | нет | ≤2 | **нет** | **0** | до 48, ещё крупнее |
7
+
8
+ # osmgl
9
+
10
+ **Самостоятельный 3D-движок карты.** Не надстройка над MapLibre: своя камера, свой конвейер
11
+ тайлов (сеть и разбор — в воркерах), свой рендер на WebGL2. Ни MapLibre, ни three.js
12
+ в зависимостях нет.
13
+
14
+ Отличие от [`maps3d-web`](../maps3d-web/README.md): тот — набор three.js-оверлеев поверх чужой
15
+ карты, каждый со своим проходом рендера. Здесь карта своя целиком.
16
+
17
+ ```bash
18
+ npm install && npm run build
19
+ npm run demo # http://localhost:5180/
20
+ ```
21
+
22
+ Демо-сервер сам выбирает источник тайлов — первый, который ответит:
23
+
24
+ | # | Источник | Примечание |
25
+ |---|----------|------------|
26
+ | 1 | `TILES_UPSTREAM` | если задан |
27
+ | 2 | `http://localhost:8088/tileserver/data/v3` | через шлюз, основной путь |
28
+ | 3 | `http://localhost:8090/data/v3` | tileserver-gl напрямую |
29
+ | 4 | `data/tiles/tajikistan.mbtiles` | офлайн, без поднятого стека |
30
+
31
+ Тайлы проксируются на origin демо: иначе упрёмся либо в CORS, либо в гейт API-ключей.
32
+
33
+ > **Гоча со шлюзом.** При `API_KEYS_ENABLED=true` шлюз пускает без ключа только по
34
+ > first-party allowlist (`API_KEYS_FIRST_PARTY`, локально `localhost`), а решает он по
35
+ > заголовку `Origin`/`Referer`. Браузер шлёт его сам, а `fetch` из Node — нет, поэтому
36
+ > прокси проставляет `Referer` явно. Без этого шлюз отвечает `401 invalid or missing api key`,
37
+ > даже когда «локально проверка отключена». Настоящий ключ — через `TILES_KEY`.
38
+
39
+ ## Использование
40
+
41
+ ```html
42
+ <script src="/maps/osmgl.global.js"></script>
43
+ <script>
44
+ const map = new OsmGL.Map({
45
+ container: 'map',
46
+ center: [68.7864, 38.5598], // Душанбе
47
+ zoom: 16.4, pitch: 62, bearing: -18,
48
+ tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
49
+ sourceMaxZoom: 14,
50
+ // style не задан → встроенный styles/osm-3d.json
51
+ })
52
+ </script>
53
+ ```
54
+
55
+ > **Не пишите `const { Map } = OsmGL`** в обычном `<script>`. Такое объявление попадает в
56
+ > глобальную лексическую область и затеняет встроенный `Map` для кода внутри бандла.
57
+ > Движок от этого защищён (`core/natives.ts`), но привычка обращаться через пространство
58
+ > имён избавляет от целого класса подобных сюрпризов. В ESM проблемы нет.
59
+
60
+ ESM:
61
+
62
+ ```ts
63
+ import { Map } from 'osmgl'
64
+ ```
65
+
66
+ ### API
67
+
68
+ | Метод | Описание |
69
+ |-------|----------|
70
+ | `new Map(opts)` | Создать карту (см. `MapOptions`). |
71
+ | `setCenter/setZoom/setBearing/setPitch` | Камера по одному параметру. |
72
+ | `jumpTo(opts)` / `easeTo({...,duration})` | Мгновенно / плавно. |
73
+ | `project(lngLat, alt?)` / `unproject(point)` | География ↔ экран (с учётом наклона). |
74
+ | `setStyle(spec)` | Заменить стиль целиком (геометрия пересобирается). |
75
+ | `setPaintProperty(id, prop, v)` | Цвет/ширина слоя на лету — только юниформы, без пересборки. |
76
+ | `setLayoutProperty(id, prop, v)` | Кегль, зазор до значка, шаг разрежения — пересобирается раскладка подписей, но не геометрия. |
77
+ | `setLight({...})` | Ambient и два направленных источника: азимут, высота, цвет, сила. |
78
+ | `setTheme(name)` | Тема оформления: `day` \| `night` \| `mono` \| `pale` либо своя накладка. |
79
+ | `setLayerVisible(id, on)` | Включить/выключить слой стиля. |
80
+ | `refresh()` | Перечитать тайлы (после правки в редакторе). |
81
+ | `getStats()` | Тайлы в работе/видимые, число воркеров, имя GPU. |
82
+ | `destroy()` | Освободить всё. |
83
+
84
+ События: `load`, `move`, `moveend`, `zoom`, `render`, `idle`, `error`, `contextlost`.
85
+
86
+ ## На проде
87
+
88
+ Движок выпускается в npm пакетом `osmgl` — порядок выпуска и грабли в [RELEASE.md](RELEASE.md).
89
+
90
+ Потребителей двое, и обновляются они по-разному. Карта `/map/` берёт пакет обычной зависимостью.
91
+ Админка отдаёт отдельный файл `src/Admin/public/maps/osmgl.global.js` — он **лежит в git** и
92
+ обновляется командой `npm run maps:osmgl` в `src/Admin`, которая копирует готовый бандл из
93
+ установленного пакета.
94
+
95
+ Бандл уезжает в образ админки;
96
+ nginx отдаёт его двумя путями — `/maps/osmgl.global.js` (стабильная ссылка) и
97
+ `/admin/maps/…`. Рядом лежит страница превью `osmgl-preview.html` → **`/maps/osmgl-preview.html`**.
98
+ Копией, а не сборкой из админки: движок собирается своим tsup со своим воркером,
99
+ вшитым в бандл строкой, и тащить это в vite-сборку значило бы держать два пути
100
+ сборки одного файла. Так же лежит и `maps3d.global.js`.
101
+
102
+ Мелкие зумы (глобус) страница берёт ОТДЕЛЬНЫМ источником
103
+ `/tileserver/data/world/{z}/{x}/{y}.pbf` — основные тайлы покрывают только
104
+ Таджикистан. Набор собирает `bash deploy/rebuild-world-tiles.sh`; на сервере файл
105
+ кладётся в `data/tiles/world.mbtiles` (в git mbtiles не хранятся) и подхватывается
106
+ уже прописанным в `data/tiles/config.json` датасетом `world` — после копирования
107
+ нужен рестарт `tileserver`.
108
+
109
+ ## Стиль
110
+
111
+ Стиль — JSON (`styles/osm-3d.json`), вшитый в бандл: карта рисуется без единого запроса
112
+ за конфигом.
113
+
114
+ > **Гоча.** Раз стиль вшит, правка `styles/*.json` доезжает до карты только после
115
+ > `npm run build` (или `npm run dev` в режиме слежения). Отредактировать JSON и
116
+ > перезагрузить страницу — недостаточно, и выглядит это как «настройка не
117
+ > работает». Крутить на лету можно `setPaintProperty` и `setLayoutProperty`. Формат сознательно НЕ повторяет style spec MapLibre — там половина
118
+ возможностей нам сейчас не нужна, а тащить их означало бы тащить и интерпретатор
119
+ выражений. Здесь минимум, покрывающий схему OpenMapTiles v3:
120
+
121
+ ```jsonc
122
+ {
123
+ "id": "road-major",
124
+ "type": "line", // fill | line | fill-extrusion
125
+ "source-layer": "transportation", // слой MVT
126
+ "filter": ["in", "class", "motorway", "trunk", "primary"],
127
+ "minzoom": 5,
128
+ "paint": {
129
+ "color": "#ffeccd",
130
+ // ширина в ЭКРАННЫХ пикселях, интерполяция по зуму
131
+ "width": { "stops": [[6, 0.8], [14, 6], [18, 25], [20, 52]] }
132
+ }
133
+ }
134
+ ```
135
+
136
+ Ширина линии задана в пикселях **на уровне центра карты**, но раздувается лента
137
+ **в плоскости земли** — как у Mapbox (`extrude * u_pixels_to_tile_units`, и только
138
+ потом матрица). Сначала было наоборот: квад раздувался в экранных пикселях, а
139
+ перспективу учитывал множитель ширины. Толщина от этого сокращалась правильно, а
140
+ направление — нет: лента оставалась развёрнутой к экрану, и при наклоне 75°
141
+ дорога поперёк взгляда читалась вертикальной стеной. Круглые стыки и концы при
142
+ этом сохранились: расстояние до отрезка фрагмент считает там же, на земле.
143
+
144
+ ГОЧА, которую это принесло: «не тоньше пикселя» нужно ОГРАНИЧИВАТЬ. У горизонта на
145
+ пиксель приходятся десятки единиц тайла, и полпикселя превращаются в десятки
146
+ метров — дорога расплывается пятном во весь склон. Подробности —
147
+ в [porting/lines.md](porting/lines.md).
148
+
149
+ Фильтры: `==` `!=` `<` `<=` `>` `>=` `in` `!in` `has` `!has` `all` `any` `!`.
150
+ Любое число или цвет можно задать остановками `{ "stops": [[zoom, value], ...] }`.
151
+
152
+ Покрыты все слои, которые реально приезжают из наших тайлов: `water`, `waterway`,
153
+ `landcover` (лес/трава/песок/лёд/болото), `landuse` (жильё/промка/соцобъекты/спорт/
154
+ кладбища/парковки), `park`, `aeroway`, `transportation` (обводка + полотно для
155
+ магистралей, вторичных, второстепенных, ж/д и троп), `boundary`, `building`.
156
+
157
+ Порядок слоёв в стиле — порядок отрисовки. Земля рисуется методом художника с
158
+ выключенным depth-тестом (всё лежит на z=0, тест дал бы только z-fighting), затем
159
+ объёмные слои с честной глубиной.
160
+
161
+ ### Разноцветная линия
162
+
163
+ Маршрут, раскрашенный по пробкам, — как у Яндекса:
164
+
165
+ ```js
166
+ new OsmGL.Polyline({
167
+ coordinates,
168
+ color: '#2563eb', // цвет по умолчанию, если кусков нет
169
+ segments: [ // кусок идёт с `at` и до `at` следующего
170
+ { at: 0, color: '#39b54a' },
171
+ { at: 128, color: '#c01818' },
172
+ ],
173
+ })
174
+ ```
175
+
176
+ Своего шейдера это не потребовало: объект и так возвращает СПИСОК кусков отрисовки
177
+ (`ObjectPart[]`), и цветной участок — просто ещё один кусок со своей лентой. Два правила,
178
+ которые легко нарушить:
179
+
180
+ - соседние куски ДЕЛЯТ вершину (`slice(from, to + 1)`) — иначе на стыке остаётся разрыв
181
+ шириной в отрезок;
182
+ - обводка рисуется ОДНА на всю линию и первой — обводка на каждый кусок положила бы белые
183
+ перемычки поверх цвета соседа.
184
+
185
+ Куски приходят из ответа сервиса, поэтому мусор гасится на входе (индексы вне линии,
186
+ дробные, неупорядоченные), а первый кусок всегда начинается с начала линии: неокрашенный
187
+ хвост выглядел бы обрывом, а не «неизвестным участком».
188
+
189
+ ### Подписи вдоль линии
190
+
191
+ `layout.textPlacement: 'line'` — название улицы изгибается по дороге:
192
+
193
+ ```jsonc
194
+ { "id": "road-label", "type": "symbol", "source-layer": "transportation_name",
195
+ "layout": { "textField": ["{name:ru}", "{name}"], "textPlacement": "line" } }
196
+ ```
197
+
198
+ Раскладка (`text/line-placement.ts`) — ЧИСТАЯ функция над экранной ломаной: на входе точки и
199
+ метрики глифов, на выходе позиция и угол каждой буквы. Считается в экранных координатах и на
200
+ каждый кадр: форма дороги на экране зависит от наклона камеры, и изгиб, посчитанный в тайле,
201
+ при наклоне разъезжается с самой дорогой. Воркер поэтому кладёт в бакет геометрию линии
202
+ (`lines` + `lineStarts`) — самую длинную цепочку объекта.
203
+
204
+ Отличия от Mapbox (`symbol_projection.js`), где часть работы уходит на GPU:
205
+
206
+ - подпись ОДНА на объект, а не по одной на интервал. Поэтому при неудаче она сдвигается вдоль
207
+ линии и пробует снова: улица с резким поворотом посередине иначе теряла бы название целиком;
208
+ - направление чтения решается по ТОМУ КУСКУ, который занимает строка, а не по всей линии. У
209
+ извилистой улицы хорда может идти вправо, а выбранный кусок влево — и название вставало вверх
210
+ ногами;
211
+ - место в сетке коллизий занимает цепочка боксов по буквам: общий бокс изогнутой строки — почти
212
+ весь экран, и он вытеснял бы всё вокруг.
213
+
214
+ Излом больше 45° на глиф — отказ (порог как у Mapbox `MAX_GLYPH_ANGLE`): на дуге буквы наезжают
215
+ друг на друга внутренней стороной, и подпись читается хуже, чем если её не ставить. Цена
216
+ раскладки — +0,1 мс на кадр (медиана 1,0 → 1,1 мс на z16 при непрерывном вращении, SwiftShader).
217
+
218
+ ## Растровая подложка
219
+
220
+ Карта картинками под всей векторной графикой — вторая основа карты («Схема
221
+ картинкой» на публичном сайте). Спутник это не даёт (снимков у нас нет), зато
222
+ даёт любую чужую подложку и снимает работу с клиента: рисует сервер, а мы только
223
+ натягиваем текстуры.
224
+
225
+ ```jsonc
226
+ // источник
227
+ { "basemap": { "type": "raster", "url": "/tileserver/styles/gram-raster/{z}/{x}/{y}@2x.png",
228
+ "minzoom": 0, "maxzoom": 19 } }
229
+ // слой
230
+ { "id": "basemap-raster", "type": "raster", "source": "basemap",
231
+ "visible": false, "paint": { "opacity": 1 } }
232
+ ```
233
+
234
+ **Источник отдельный** (`source/raster-source.ts`), а не ветка в `TileSource`. У
235
+ них разная работа: векторный тайл надо разобрать и стесселировать — ради этого и
236
+ живёт пул воркеров с очередью; картинку разбирает сам браузер и вне главного
237
+ потока (`createImageBitmap`), и ставить её в ту же очередь значит задерживать
238
+ геометрию ради того, что и так декодируется параллельно. Общее — отбор видимых
239
+ тайлов, LRU и отмена запросов уехавших из вида тайлов — переиспользуется как
240
+ есть.
241
+
242
+ **Слой не хранит ничего.** Один единичный квад на все тайлы (тот же, что у прохода
243
+ тени по земле): матрица тайла с `extent = 1` растягивает его ровно на место.
244
+ Текстура живёт В ТАЙЛЕ и освобождается вместе с ним — вытеснение из кеша видит
245
+ источник, а не слой. Битмап после переноса в GPU закрывается: он держит несжатую
246
+ картинку вне кучи JS, и на подложке это сотни мегабайт.
247
+
248
+ **Пока свой тайл грузится, рисуется кусок предка** (`patchUV`) — иначе каждый шаг
249
+ зума открывает дыры до конца загрузки. Потомков не собираем: их до четырёх на
250
+ тайл, целиком в кеше они бывают редко, и мозаика из части квадратов заметнее
251
+ размытого предка. Так же поступают Mapbox и MapLibre.
252
+
253
+ **Y не переворачивается.** У тайла начало на севере, и первая строка картинки,
254
+ уходящая в `texImage2D`, — тоже северная. Мипы обязательны (без них отдалённый
255
+ тайл кипит муаром), плюс анизотропия, если устройство её умеет: при наклоне тайл
256
+ виден под острым углом, и обычные мипы мылят его вдоль взгляда.
257
+
258
+ **Выключенный слой не стоит ни байта.** `Map` обновляет только те растровые
259
+ источники, которые рисует хоть один включённый слой: слой подложки объявляют
260
+ заранее и включают тумблером (пересборка стиля мигает всей картой), а картинки —
261
+ самый тяжёлый трафик на карте.
262
+
263
+ ## Подписи и иконки
264
+
265
+ Текст берётся из SDF-глифов формата Mapbox (`/tileserver/fonts/{fontstack}/{range}.pbf`) —
266
+ их отдаёт наш tileserver-gl, поэтому подписи выглядят так же, как в остальных наших картах.
267
+
268
+ **Иконка красится тем же цветом и той же обводкой, что и текст.** Это не совпадение, а
269
+ устройство: SVG растрируется в альфа-маску, из неё строится SDF **в той же кодировке, что
270
+ у глифов** (край фигуры на 192/255, диапазон растянут на −6..+2 пикселя), и кладётся в
271
+ ОБЩИЙ с буквами атлас. Дальше одна программа рисует и то и другое — шейдер не различает,
272
+ буква перед ним или пиктограмма.
273
+
274
+ Плата за это — иконки **силуэтные**, одноцветные.
275
+
276
+ **Иконка стоит НАД подписью и по её центру** (`layout.iconPlacement`, по
277
+ умолчанию `top`) — как в Mapbox Standard и у Яндекса: значок оказывается ровно
278
+ над точкой объекта. При боковой раскладке длинное название уводит его в сторону,
279
+ и на плотной карте перестаёт быть понятно, к чему он относится. Зазор задаётся
280
+ на слой (`layout.iconTextGap`) и берётся как есть, без скрытых множителей.
281
+
282
+ Зазор отсчитывается от **настоящего верха букв**, а не от строчного бокса. Бокс
283
+ строки выше того, что видно: в нём живут выносные элементы и подстрочье, и у
284
+ названия без «б», «р», «у» сверху остаётся пустая полоса примерно в четверть
285
+ кегля. Пока подпись ставилась по боксу, «зазор 2» читался на экране как семь, и
286
+ править его в стиле было бесполезно — уменьшалось не то. Поэтому подпись
287
+ рисуется первой, её рамка запоминается, и блок сдвигается по факту.
288
+
289
+ **Круглая подложка под значком** (`layout.iconFrame: "circle"`, диаметр —
290
+ `iconFrameScale`) — как у Яндекса и в Mapbox Standard: тонкая пиктограмма поверх
291
+ домов и зелени сама по себе теряется. Это ОТДЕЛЬНЫЙ значок под иконкой со своим
292
+ потоком вершин и своей покраской, а не обводка: обводка у нас общая с текстом, и
293
+ жирный контур вокруг силуэта выглядит грязью, а кружок — фигура с заливкой и
294
+ рамкой. Цвета по умолчанию выводятся из темы и отдельной настройки не требуют:
295
+ заливка — цвет обводки подписи (в каждой палитре это и есть «фон под текстом»:
296
+ белый днём, почти чёрный ночью), рамка — цвет значка, приглушённый до трети.
297
+ Переопределяются `paint.frameColor`, `frameHaloColor`, `frameHaloWidth`.
298
+
299
+ Заливка кружка ПОЛУПРОЗРАЧНАЯ (`paint.frameOpacity`): сплошная закрывает карту и на
300
+ плотной застройке читается дырой. Плотность своя на каждой теме и выводится из
301
+ светлоты фона — 0,3 на бумажных палитрах, 0,6 на графитовых: тёмному фону нужна
302
+ БОЛЬШАЯ плотность, иначе кружок не отделяет пиктограмму от домов, а на светлом
303
+ сильный кружок сам становится объектом. Рамка при этом остаётся непрозрачной —
304
+ растворять её вместе с фоном значит потерять край фигуры. Место
305
+ под значок при этом занимает кружок, а не пиктограмма, иначе подпись налезала бы
306
+ на него.
307
+
308
+ **Набор иконок сверяется с данными, а не с воображением:**
309
+
310
+ ```bash
311
+ node scripts/audit-icons.mjs # какие классы приезжают и чего не хватает
312
+ ```
313
+
314
+ Имя иконки обязано совпадать с классом схемы (шаблон `{class}`), и ошибка тут
315
+ тихая: имя не совпало — объект молча получает нейтральную точку. Именно так
316
+ полторы тысячи гостиниц ходили с точкой, пока иконка называлась `hotel`, а класс
317
+ в данных — `lodging`. Первый прогон по прод-набору показал **78 классов без
318
+ значка, это 45% всех POI**; сейчас непокрытым остался один (`brownfield`, 16
319
+ объектов — класс землепользования, случайно попавший в слой POI). Близкие по
320
+ смыслу классы делят иконку через `ICON_ALIASES`: отдельные пиктограммы для
321
+ волейбола, баскетбола и тенниса на шестнадцати пикселях всё равно неразличимы.
322
+
323
+ **Номера трасс** — слой `road-shield`: светлая плашка с рамкой и тёмным номером,
324
+ как в Mapbox. Плашка это ЗНАЧОК ПОД подписью (`iconPlacement: "behind"`), и у
325
+ него **своя покраска** (`iconColor`, `iconHaloColor`) — иначе он красился бы
326
+ цветом номера, и прочитать номер было бы нельзя. Ширина плашки выбирается по
327
+ длине номера (`iconImage: "shield-{ref_length}"`): настоящего 9-patch у нас нет,
328
+ поэтому в наборе лежат готовые ширины под 2…8 знаков — этого хватает на всё, что
329
+ встречается в данных. Чтобы номер не повторялся на каждом отрезке линии, у слоя
330
+ свой шаг разрежения `layout.collisionPadding`.
331
+
332
+ Попутно из этого выпало два общих улучшения: значки больше не обязаны быть
333
+ квадратными (растр берёт соотношение сторон из `viewBox`, а квад — из записи в
334
+ атласе), и подписи рисуются **в порядке стиля**, а не в порядке размещения по
335
+ важности — иначе подложка могла оказаться поверх того, что подпирает.
336
+
337
+ **Цвет по типу объекта.** Раз подпись и значок красятся цветом СЛОЯ, то «еда
338
+ оранжевым, транспорт синим» — это слой на категорию: `poi-food`, `poi-shop`,
339
+ `poi-transport`, `poi-health`, `poi-education`, `poi-culture`, `poi-sport`,
340
+ `poi-lodging`, `poi-service` и общий `poi` для остального. Классы разобраны по
341
+ тому, что реально приезжает в наших тайлах (замер по Душанбе: `shop` 460,
342
+ `office` 220, `cafe` 151, `restaurant` 128…), остаток ловится обратным фильтром
343
+ — иначе объект нарисовался бы дважды. Коллизии при этом общие: кандидаты со
344
+ всех слоёв сортируются по важности в одной сетке.
345
+
346
+ Цвета категорий подобраны под светлую карту, поэтому темам они раздаются
347
+ скриптом:
348
+
349
+ ```bash
350
+ node scripts/apply-poi-palette.mjs
351
+ ```
352
+
353
+ Тёмная тема получает те же тона, поднятые по светлоте, и свою обводку (белая
354
+ обводка дневной темы превращает подпись в светящуюся марку). Монохромная тема
355
+ остаётся монохромной: цветные категории поверх графита — это уже другая тема. Разноцветную пиктограмму так не сделать;
356
+ для неё понадобится отдельный растровый слой.
357
+
358
+ ```jsonc
359
+ {
360
+ "id": "poi",
361
+ "type": "symbol",
362
+ "source-layer": "poi",
363
+ "minzoom": 15,
364
+ "layout": {
365
+ "textField": ["{name:ru}", "{name}"], // список — это фолбэки
366
+ "iconImage": "{class}", // имя из набора иконок
367
+ "textSize": { "stops": [[15, 10.5], [18, 12.5]] },
368
+ "iconSize": { "stops": [[15, 13], [18, 16]] }
369
+ },
370
+ "paint": { "color": "#4a4a4a", "haloColor": "#ffffff", "haloWidth": 1.3 }
371
+ }
372
+ ```
373
+
374
+ Свои иконки — через `icons` в опциях карты (дополняют встроенный набор силуэтов):
375
+
376
+ ```ts
377
+ new OsmGL.Map({ icons: { taxi: '<svg viewBox="0 0 24 24">…</svg>' } })
378
+ ```
379
+
380
+ Размещение, перенос по словам и разрешение коллизий считаются на CPU в экранных
381
+ координатах и пересобираются на каждое движение камеры. Подпись всегда развёрнута к
382
+ экрану, её положение зависит от камеры — тайловая геометрия тут ничего бы не сэкономила.
383
+ Приоритет берётся из `rank` схемы OMT (там меньше — важнее, поэтому переворачиваем);
384
+ что не поместилось, просто не рисуется.
385
+
386
+ Настоящего шейпинга нет: перо двигается по `advance`. Кириллице, латинице и таджикскому
387
+ этого достаточно, арабский и иврит потребуют отдельного прохода.
388
+
389
+ ## Планы этажей
390
+
391
+ Помещения ТЦ, вокзалов и рынков. Схема тайлов — [indoorequal](https://github.com/indoorequal/indoorequal)
392
+ (слои `area` / `area_name` / `transportation` / `poi`, у каждой фичи числовой `level`),
393
+ её же собирает наш [rebuild-indoor-tiles.sh](../../deploy/rebuild-indoor-tiles.sh). План
394
+ и обоснование схемы — в [INDOOR.md](../../INDOOR.md).
395
+
396
+ ```ts
397
+ const map = new OsmGL.Map({
398
+ tiles: '/tileserver/data/v3/{z}/{x}/{y}.pbf',
399
+ sources: { indoor: { url: '/tileserver/data/indoor/{z}/{x}/{y}.pbf', minzoom: 15, maxzoom: 18 } },
400
+ })
401
+
402
+ map.on('indoor', ({ levels, level }) => renderFloorSwitcher(levels, level))
403
+ map.setIndoorLevel(2)
404
+ ```
405
+
406
+ **Отдельный источник, а не слой основных тайлов.** У основной карты потолок z14 —
407
+ планов этажей на нём не видно вовсе, — и пересобирается она сорок минут на всю страну,
408
+ тогда как `indoor.mbtiles` собирается за секунды. Слой стиля выбирает источник полем
409
+ `source`; их может быть сколько угодно.
410
+
411
+ **Переключение этажа ничего не пересобирает.** Фильтры у нас применяются в воркере, на
412
+ этапе тесселяции, поэтому «дописать `level == 3` в фильтр» означало бы перемолоть все
413
+ тайлы на каждое нажатие. Вместо этого слой объявляет `splitBy: "level"`, и воркер строит
414
+ по мешу на КАЖДЫЙ встреченный этаж; переключение — это выбор другого готового буфера.
415
+ Оттуда же берётся и список этажей для переключателя: клиенту неоткуда узнать, какие
416
+ этажи есть в здании, кроме как из самих тайлов.
417
+
418
+ **Накладка поверх объёма** (`overlay: "volume"`, прежнее написание `true`). План лежит
419
+ внутри здания, и в обычном порядке его закрыла бы собственная крыша. Покрывает он ровно
420
+ контур своего здания, поэтому соседние дома остаются видимыми.
421
+
422
+ У накладки есть и вторая позиция — `overlay: "roads"`, сразу после дорог и ДО объёма. Там
423
+ живёт разметка на асфальте: зебры и перекрытия. В общую очередь она не встаёт (дороги
424
+ рисуются позже поверхностей), но и поверх объёма ей делать нечего — зебра, висящая на
425
+ стене дома, первой бросается в глаза.
426
+
427
+ Список этажей считается по ВИДИМЫМ тайлам: переключатель должен показывать этажи того
428
+ здания, на которое смотрят, а не всех, куда пользователь заезжал за сеанс. При выходе
429
+ из здания выбор снимается — иначе в следующем ТЦ остался бы этаж, которого там нет.
430
+ Автовыбор (`map.autoIndoorLevel`) ставит первый этаж, а не самый нижний: подвал по
431
+ умолчанию не показывают.
432
+
433
+ Чего пока нет: POI основной карты не гасятся внутри здания, поэтому наружные подписи
434
+ магазинов дублируют внутренние. Нужна маска по контуру venue.
435
+
436
+ **Палитра плана выводится для КАЖДОЙ темы** (`scripts/apply-indoor-palette.mjs`).
437
+ Раньше цвета считались только для тёмных палитр, а светлые оставались с базовыми
438
+ бежевыми плашками — на «морской» или «земляной» теме раскрытый этаж выглядел
439
+ вставкой из другой карты. Правила те же, по которым рисуют планы Mapbox Indoor и
440
+ наш web-SDK:
441
+
442
+ * **пол** отсчитывается от цвета ЗДАНИЙ темы, а не от фона карты: план лежит поверх
443
+ дома, и сливаться ему нельзя именно с домом;
444
+ * **коридор** — самый «пустой» тон, по нему глаз читает связность плана;
445
+ * **помещения** нейтральны, а смысловые классы (магазин, еда, санузел, лифт)
446
+ сохраняют свой ТОН и уходят от коридора по светлоте: одного тона мало, на светлой
447
+ теме плашка почти белая и предельная цветность там мизерная;
448
+ * **стены** — самый контрастный элемент, и светлота у них ПЕРЕВЁРНУТА под тему: на
449
+ светлой карте темнее помещений, на тёмной светлее. Без переворота на графите
450
+ стена сливается с полом.
451
+
452
+ Отношения проверяются тестом на всех темах: пол отличается от здания, стена
453
+ отделяется от помещения (контраст ≥ 1,25 и верное направление), коридор отличим,
454
+ смысловые классы различимы между собой и от обычной комнаты.
455
+
456
+ **Клик по помещению.** Пока план открыт, щелчок выбирает КОМНАТУ, а не дом: дом под
457
+ планом скрыт, и обводить невидимое незачем. Помещение — плоская заливка, поэтому и
458
+ попадание считается иначе, чем у дома: не объёмным лучом, а пересечением луча с
459
+ плоскостью земли и проверкой «внутри треугольника» (`pickFill`). Для этого заливки
460
+ теперь несут id фичи на треугольник — те же четыре байта, что и у зданий, иначе
461
+ подсветить и вернуть наружу нечего. Подсветка комнаты рисуется программой заливок:
462
+ у плоского меша другой формат вершины, «нарисовать зданием» его нельзя.
463
+
464
+ Подсвечивается ТОЛЬКО ТО, ПО ЧЕМУ КЛИКНУЛИ, — пол выбранной комнаты. Приписывать
465
+ комнате её стены мы пробовали и отказались: в данных стена это отдельный полигон между
466
+ двумя помещениями, тянется вдоль целого ряда комнат и «своей» не помечена ни для одной.
467
+ Любой геометрический подбор (по прилеганию вершин, по близости треугольников) в углах
468
+ и вдоль общих стен зажигал сразу несколько помещений. К тому же выводу пришёл наш
469
+ web-SDK: стены комнат попадают в общий движок зданий наравне с домами, и выделение там
470
+ одно на всю карту — «своей» подсветки у помещений нет.
471
+
472
+ Заливка подсветки идёт ЧЕРЕЗ ТРАФАРЕТ. Полупрозрачный полигон в перекрытии тайлов
473
+ ложится дважды, и по стыку идёт полоса иной плотности — тот самый шов на выделенной
474
+ комнате. Трафарет пропускает каждый пиксель ровно один раз, и склеивать половинки
475
+ полигона из соседних тайлов не нужно. ГОЧА: опорное значение обязано быть НЕнулевым —
476
+ `REPLACE` пишет в трафарет именно его, и с нулём тест «равно нулю» продолжает
477
+ проходить, то есть трафарет не делает ровно ничего.
478
+
479
+ Сами слои плана вдобавок РЕЖУТСЯ по квадрату тайла (`clipToTile`): они полупрозрачны,
480
+ и в буферной полосе тоже ложились дважды. Непрозрачной заливке буфер, наоборот,
481
+ полезен — он закрывает волосяные щели на стыке, поэтому обрезка включается слоем, а не
482
+ всем подряд.
483
+
484
+ Стрелки входов при этом остаются: если дом не выделен, но его план открыт, контур для
485
+ `showFor: "highlight"` берётся у самого плана — стрелки показывают, как в это здание
486
+ попасть, и выбор комнаты этого не отменяет.
487
+
488
+ **Объект без здания в тайлах** (`indoor-venue`). У части объектов помещения размечены,
489
+ а контур дома не нарисован — ни в тайлах, ни в схеме редактирования: план стоял в
490
+ воздухе. Решение то же, что в нашем web-SDK (`Buildings3D.extra`): плита этажа
491
+ (`class = level`, `base = 1`) подаётся как ОБЫЧНОЕ здание с высотой из данных и той же
492
+ покраской — «свой» стиль рядом с честными домами выглядит чужеродно. Живёт объём с z15
493
+ до z16 и снимается ровно там, где появляется план: дальше внутрь смотрят помещения, а
494
+ не коробка.
495
+
496
+ ★ Эта коробка — ВТОРАЯ отрисовка того же дома, и её тоже надо прятать под детальной
497
+ 3D-моделью. Дом, который модель заменяет собой, выбрасывается из объёма основных тайлов
498
+ (`renderer.hiddenBuildings`), но плита этажа приезжает из ДРУГОГО источника и под ту
499
+ резку не попадала: рядом с детальной башней продолжала стоять её же серая коробка.
500
+ Набор поэтому второй (`renderer.hiddenVenues`), и наполняется он геометрически — по
501
+ точке размещения, потому что нумерация у двух источников своя (та же причина, по
502
+ которой геометрическая и маска планов этажей).
503
+
504
+ **Открытый план гасит модель ПЛАВНО.** Пока план раскрыт, стоящая на нём модель не
505
+ рисуется — она закрывает его ровно так же, как это делал бы сам дом. Уход именно
506
+ затуханием (`Model.setHidden`, отдельно от «вырастания» `grow`): рост сопровождает
507
+ ПРИЕЗД объекта на карту, а здесь объект никуда не девался — камера просто зашла внутрь,
508
+ и вырастающая обратно из земли башня при выходе читалась бы как новое событие.
509
+ Полупрозрачная модель целиком уходит в прозрачный подпроход (запиши она глубину — в том,
510
+ что за ней, осталась бы дыра её формы), а тень перестаёт отбрасываться на середине ухода:
511
+ карта теней полутонов не знает, и под выцветшим объектом сплошная тень выглядит чужой.
512
+
513
+ **Дом с раскрытым этажом НЕ РИСУЕТСЯ** (`layers/indoor-mask.ts`). План этажа — это
514
+ взгляд внутрь здания, и стоящая вокруг коробка мешает: в наклоне она закрывает
515
+ половину плана, а сверху спорит с ним крышей. Так же поступают Mapbox Indoor и наш
516
+ web-SDK. Связать план со зданием по id нельзя — этажи нумерует редактор (с 5e12), а
517
+ тайлы собирает планетайлер по своему источнику, id из разных пространств. Поэтому
518
+ маска ГЕОМЕТРИЧЕСКАЯ: под неё идёт ВСЯ плоская геометрия открытого этажа (полигон
519
+ `class = level` есть не в каждом тайле, и по нему одному маска молча оказывалась
520
+ пустой), а здания, чей центр в неё попал, выбрасываются из индексов.
521
+
522
+ Три грабли, каждая из которых уже проявлялась на карте:
523
+
524
+ * **привязка индексного буфера — состояние VAO**, а не глобальное. Подменив индексы
525
+ в чужом VAO, мы меняем его навсегда: тайл рисуется урезанным и в кадрах без маски,
526
+ а при освобождении буфера мигает. У маски поэтому СВОЙ VAO;
527
+ * решение принимается **на id фичи и по всем тайлам сразу**. Дом на границе приезжает
528
+ кусками, у каждого куска свой центр — решая по куску, мы прятали половину дома;
529
+ * набор **пополняется по мере загрузки тайлов**: список видимых появляется раньше их
530
+ данных, и «собрать один раз» оставляло часть домов стоять поверх плана. По той же
531
+ причине в отпечаток маски входит НАЛИЧИЕ ДАННЫХ у каждого тайла, а не только его
532
+ ключ: иначе маска, посчитанная по недогруженному плану, не пересчитывалась вовсе —
533
+ после перезагрузки страницы дом стоял поверх плана и пропадал лишь от первого
534
+ движения карты. И решение «нечего резать» нельзя кешировать для тайла, данных
535
+ которого ещё нет.
536
+
537
+ Маска живёт РОВНО там, где рисуется план: слой, невидимый на текущем зуме, в неё не
538
+ идёт. Без этого между зумом, где план уже пропал по своему `minzoom`, и зумом, где
539
+ здания ещё не показаны, не видно ни того ни другого — дом остаётся скрытым под
540
+ планом, которого нет. Проверено по зумам: до z16 дома на месте и плана нет, с z16
541
+ появляется план и дом исчезает.
542
+
543
+ Маску получает только городской слой зданий: стены плана рисуются тем же методом и
544
+ стоят внутри той же маски — применив её к ним, мы стёрли бы ровно то, ради чего дом
545
+ и прячем.
546
+
547
+ **Стены комнат — ОБЪЁМНЫЕ** (`indoor-wall`, тип `fill-extrusion`): высота приходит
548
+ из данных (`height`, низ — `base`), материал и подсветка общие с городскими домами.
549
+ Покрашены они СВЕТЛЕЕ, чем плоские слои плана, и у них своя кровля: стена это
550
+ освещённая поверхность, а не самая тёмная линия чертежа — отделяет её свет, и
551
+ тёмная плашка под настоящей тенью читается грязью. Затенение у основания
552
+ (`aoStrength`) выключено: полоса в пару метров на тридцатиметровом доме читается
553
+ контактной тенью, а на трёхметровой перегородке занимает пол-стены и выглядит
554
+ чёрной каймой со ступеньками. Внутри помещения контактной тени и не бывает.
555
+ Рисуются они СВОЕЙ сценой: перед ними очищается буфер глубины, потому что внутри
556
+ себя план обязан быть объёмным (дальняя стена не должна ложиться на ближнюю), а
557
+ общая глубина города для него не годится — план лежит внутри дома. Тот же вывод, к
558
+ которому пришёл наш web-SDK: плоская экструзия рядом с настоящими домами выглядела
559
+ чужеродной, и стены отданы общему движку зданий.
560
+
561
+ **Двери и лифты.** Дверей в тайлах больше сотни на здание — без них план читается
562
+ набором комнат без входов; они рисуются отдельным слоем с z18. Лифты стали заливкой
563
+ вместо пунктирной линии: шахта читается фигурой, а не обводкой.
564
+
565
+ ## Объекты приложения
566
+
567
+ Маршрут, зона доставки, трек машины — то, что кладёт на карту приложение, а не
568
+ тайлсервер. Отдельным источником они не оформлены сознательно: таких объектов
569
+ единицы, их правят покадрово, и воркер с LRU здесь ничего бы не сэкономили.
570
+
571
+ ```ts
572
+ const route = new OsmGL.Polyline({
573
+ coordinates: [[68.780, 38.563], [68.790, 38.562], [68.790, 38.557]],
574
+ color: '#2f6df6', width: 7,
575
+ strokeColor: '#fff', strokeWidth: 2, // обводка снизу, как у дорог в стиле
576
+ zIndex: 20,
577
+ userData: { orderId: 42 },
578
+ })
579
+ map.addObject(route)
580
+
581
+ map.addObject(new OsmGL.Circle({ center: [68.786, 38.560], radius: 400 })) // радиус в МЕТРАХ
582
+ map.addMarker(new OsmGL.Marker({ coordinates: [68.786, 38.560], html: '<b>А</b>' }))
583
+
584
+ map.on('click', ({ object, lngLat }) => {
585
+ if (object) console.log('попали в заказ', object.userData.orderId)
586
+ })
587
+ ```
588
+
589
+ Доступны `Polyline`, `Polygon` (с дырками), `Circle` и `Marker`. Оформление —
590
+ те же поля, что у слоёв стиля: `color`, `opacity`, `width`, `dashArray`, `offset`,
591
+ `arrow`. Шейдеры буквально те же самые; отличается только тип атрибута позиции.
592
+
593
+ **Координаты.** У объекта нет тайла, который держал бы его вершины в маленьких
594
+ целых числах, поэтому у каждого объекта СВОЁ начало координат — центр его рамки, —
595
+ а вершины хранятся относительно него в нормализованном меркаторе, во Float32.
596
+ Int16, как у тайлов, здесь не годится: маршрут через город это 40 км, шаг сетки
597
+ вышел бы около метра, а на z20 метр — это восемь пикселей дрожания.
598
+
599
+ **Правки.** `setCoordinates`/`setPaint` только увеличивают `revision`; буфер
600
+ перезаливается один раз в следующем кадре. Двигать трек машины можно хоть на
601
+ каждый тик — загрузка будет одна.
602
+
603
+ **Маркер — это DOM**, а не спрайт в канве. По маркеру кликают, наводят подсказку,
604
+ ставят в него счётчик, анимируют пульсацию; браузер это уже умеет, а в канве
605
+ пришлось бы заново делать попадание, курсор и доступность. Цена известна: тысячи
606
+ маркеров DOM не потянет — для массовых точек есть слой `symbol` из стиля, он в GL
607
+ и умеет коллизии.
608
+
609
+ **Клик** ловится не событием `click` браузера, а парой pointerdown/pointerup с
610
+ порогом 4 px и 500 мс: на карте почти любой клик приходит после перетаскивания, и
611
+ настоящий щелчок надо отличать от конца панорамирования. Попадание считается на
612
+ CPU в экранных координатах — поэтому переживает наклон и поворот и позволяет
613
+ задать допуск на палец.
614
+
615
+ ## Фасады и крыши
616
+
617
+ Стены получают окна, а невысокие дома — скатные крыши, как в 2GIS и Яндексе.
618
+
619
+ Рисунок фасада считается ПРОЦЕДУРНО, без единой текстуры: атлас фасадов
620
+ (путь Mapbox Standard) даёт больше, но требует художника, загрузки и памяти, а
621
+ читаемый город получается и из сетки, построенной прямо по координатам. Класс
622
+ фасада выводится в основном ИЗ ВЫСОТЫ: тег `class` есть у двух процентов зданий
623
+ (64 из 2700 в тайле Душанбе), а частный дом от девятиэтажки отличается рисунком
624
+ окон сильнее, чем жилой дом от офисного.
625
+
626
+ | Класс | Кто | Рисунок |
627
+ |-------|-----|---------|
628
+ | дом | ниже 7 м, `house`, `garage` | редкие окна, глухой цоколь |
629
+ | жилая секция | 7–40 м | регулярная сетка, витрины на первом этаже |
630
+ | стекло | выше 40 м, `office`, `retail` | ленточное остекление с импостами |
631
+ | промышленное | `industrial`, `warehouse` | крупные панели, окна только выше 4 м |
632
+
633
+ Скатная крыша ставится на ОПИСАННОМ прямоугольнике и только там, где контур на
634
+ него похож (заполнение ≥ 0.86), не выше 15 м и не больше 600 м². Честный способ —
635
+ прямой скелет многоугольника — на порядок сложнее и нужен ради редких форм;
636
+ прямоугольник закрывает ровно тот случай, ради которого всё затевалось, — частный
637
+ дом. Форма (двускатная, вальмовая, шатровая, односкатная) берётся из тега
638
+ `roof_shape`, а без него — из пропорций и хеша здания. Скат добавляется ПОВЕРХ
639
+ заданной высоты: `render_height` у дома это обычно карниз.
640
+
641
+ Раскладка окон идёт **по грани**, а не по общей сетке: у каждой стены свои поля
642
+ по краям, целое число ячеек и остаток, размазанный по простенкам. Так сделано в
643
+ [`maps3d-web`](../maps3d-web/README.md) (`fw`/`t0` на ребро), и причина не в
644
+ красоте: при общей сетке ребро дома режет её где придётся — у угла остаётся
645
+ половина окна, а простенки у соседних стен разной ширины. Ширина грани приходит
646
+ атрибутом `a_wall`, и подряд идущие сонаправленные рёбра склеиваются в одну
647
+ грань — контуры из OSM сплошь и рядом дробят прямую стену на два-три звена, и
648
+ иначе на ней оказалось бы несколько независимых сеток со швом на ровном месте.
649
+
650
+ Ночью часть окон горит (`windowGlow` — доля светящихся). Какие именно, решает хеш
651
+ ячейки, поэтому при движении камеры свет не «бегает», а яркость у окон разная —
652
+ одинаково светящиеся окна выглядят вывеской, а не жилым домом.
653
+
654
+ Вся детализация гасится по `fwidth` (метров на пиксель) задолго до того, как
655
+ начнёт рябить: на общем плане фасад не меняет НИ ОДНОГО пикселя (дымовой тест
656
+ меряет это с обоих концов — 125 тысяч пикселей вблизи против нуля вдали).
657
+
658
+ Настройки — в paint слоя `building`: `facadeDetail`, `windowColor`,
659
+ `windowFrameColor`, `windowGlow`, `floorHeight`, `roofVariation`.
660
+
661
+ ## Темы
662
+
663
+ Двадцать семь готовых. Пять сделаны под этот движок — `day`, `night` (ночная),
664
+ `mono` (графит), `pale` (бледная, под данные сверху), `ice` (ледяная, со своим
665
+ вариантом `ice-tone` — окна в тон стене); остальные — палитры
666
+ **GramMaps Standard** из админки (`src/Admin/public/maps/standard-*.json`),
667
+ перенесённые сюда скриптом, чтобы карта на своём движке выглядела так же, как
668
+ везде в продукте:
669
+
670
+ `ice` — отдельная тема со СВОЕЙ палитрой целиком, а не правка светлой. Цвета сняты
671
+ с растровых тайлов Baidu Maps (Луцзяцзуй, z17, 25 тайлов): земля `#f5f3f0`,
672
+ дороги белые с серой обводкой `#dbdbdd`, магистрали `#ffd86b`, вода `#90ddf6`,
673
+ зелень `#c2f0c3`, жилые кварталы `#ecf1fb`. Пешеходные дорожки там сплошные
674
+ белые, а не пунктирные, — тема снимает пунктир (`dashArray: null`).
675
+
676
+ Дома гладкие: рисунка фасада и швов нет вовсе (`facadeDetail: 0`,
677
+ `seamStrength: 0`), затенение у основания почти снято (`aoStrength: 0.04`), свет
678
+ наполовину рассеянный (`ambientIntensity: 0.72` против `keyIntensity: 0.36`) —
679
+ грани разводятся оттенком, а не темнотой.
680
+
681
+ Сверх цвета у зданий работает ручка `ice` (0..1) — цветом её не задать, она меняет
682
+ саму модель освещения. Приём снят с живых шейдеров Baidu Maps (перехват
683
+ `shaderSource` на их странице):
684
+
685
+ 1. кровля не освещается вовсе — берёт цвет палитры как есть;
686
+ 2. свет НЕ УМНОЖАЕТ, а ДОБАВЛЯЕТ, поэтому ничто не бывает темнее собственного
687
+ цвета: дом читается светящимся изнутри, а не освещённым снаружи. Обычное
688
+ освещение уводит отвёрнутую стену в тень — отсюда «матовый картон».
689
+
690
+ Ручка не только для ледяной: светлая и тёмная темы включают её тоже, каждая со
691
+ своей палитрой и своим светом (`withIceLight` в `style/style.ts` — ставится
692
+ кодом, потому что светлая ГЕНЕРИРУЕТСЯ из стилей админки и правка её файла живёт
693
+ до следующей сборки). Считается она ДО свечения окон, иначе на тёмной теме
694
+ горящие окна затирались бы вместе с освещением.
695
+
696
+ Коэффициенты вдвое меньше их 0.1/0.06, и боковой свет считается по
697
+ ГОРИЗОНТАЛЬНОЙ проекции нормали. Причина — наша геометрия: низ стены у нас со
698
+ скруглением и фаской, её нормаль завалена вниз, и у земли появлялась тёмная
699
+ кайма (замер по стене: ровные 215,230,241 и полоса 203,217,227 у основания).
700
+ Проекция уравнивает фаску со стеной над ней. Затенение у основания остаётся
701
+ одной ручкой — документированной `aoStrength`.
702
+
703
+ Дороги у Baidu кодом не рисуются вовсе: их клиент компилирует около дюжины
704
+ шейдеров (здания, плоская проекционная тень, горизонт и наклейка текстуры), а
705
+ базовая карта приезжает готовыми растровыми PNG. Поэтому дороги здесь
706
+ воспроизведены по ИЗМЕРЕНИЯМ растра: белое полотно, магистраль `#ffd86b`,
707
+ второстепенная `#ffecba`, пешеходные дорожки сплошные белые.
708
+
709
+ Тени тема не задаёт — это настройка карты. Под неё просят мягкие и холодные:
710
+ `map.setShadows({ strength: 0.16, color: '#9fc4d8' })`.
711
+
712
+ ```bash
713
+ node scripts/import-standard-themes.mjs # → styles/standard/*.json
714
+ ```
715
+
716
+ ```ts
717
+ map.setTheme('cyberpunk')
718
+ OsmGL.Map.themeList // [{ id: 'day', name: 'Дневная' }, …] — для выпадающего списка
719
+ ```
720
+
721
+ Цвета переносятся как есть, а **свет и небо выводятся**: в стиле MapLibre нет ни
722
+ наших двух источников, ни неба вовсе. Формулы откалиброваны по ручным темам —
723
+ подставив их фон, формула обязана вернуть примерно их свет. Планы этажей у
724
+ тёмных палитр тоже выводятся (в стилях админки этажей нет), причём светлота
725
+ ПЕРЕВОРАЧИВАЕТСЯ: у нас стена темнее помещения, и в тёмной теме такая пара
726
+ слилась бы в пятно. Правки в `styles/standard/` бессмысленны — следующий запуск
727
+ скрипта их перетрёт; менять надо стиль в админке.
728
+
729
+ Тема — это **накладка поверх базового стиля**, а не его копия:
730
+
731
+ ```jsonc
732
+ {
733
+ "name": "Ночная",
734
+ "background": "#11151c",
735
+ "light": { "ambientIntensity": 0.34, "keyColor": "#cfd8ea" },
736
+ "paint": {
737
+ "water": { "color": "#0f2438" },
738
+ "building": { "color": "#232833", "roofColor": "#2b313d" }
739
+ },
740
+ "hidden": ["housenumber"]
741
+ }
742
+ ```
743
+
744
+ Копировать все три десятка слоёв в каждую тему пришлось бы вместе со структурой —
745
+ фильтрами, зумами, шаблонами подписей, — и любая правка структуры требовала бы повторения
746
+ во всех файлах. Отсюда же и приятное следствие: у тем ОДИН план для воркера, поэтому
747
+ `setTheme` не пересобирает геометрию — тайлы не перекачиваются, картинка меняется в том
748
+ же кадре (дымовой тест это проверяет: 0 тайлов в перезагрузке на каждой теме).
749
+
750
+ ## Глобус
751
+
752
+ ```ts
753
+ const map = new OsmGL.Map({ projection: 'globe', zoom: 2, ... })
754
+ map.setProjection('globe') // и обратно 'mercator'
755
+ ```
756
+
757
+ Тайлы при этом остаются **обычными меркаторными** — на сферу их натягивает
758
+ вершинный шейдер, как в Mapbox. Переключение не трогает данные: ничего не
759
+ перекачивается, меняется только набор видимых тайлов. К зуму 6 сфера сама
760
+ переходит в плоскую карту, чтобы на городских масштабах за неё не платить.
761
+
762
+ Устроено так, что **камеру не пришлось трогать вовсе**: глобус отдаёт не своё
763
+ пространство, а локальные координаты касательной плоскости центра карты — x на
764
+ восток, y на юг, z вверх, начало под камерой. Ровно то же, что даёт плоская карта.
765
+ Отсюда и переход: это обычный `mix` двух позиций в ОДНОМ пространстве, а в центре
766
+ кадра они совпадают тождественно, поэтому шва там нет по построению.
767
+
768
+ Что ещё меняется в режиме глобуса: отбор тайлов идёт перебором уровня с отсечением
769
+ по горизонту (спуск по пирамиде опирается на плоскую землю), обратная проекция —
770
+ пересечением луча со сферой, а перетаскивание поворачивает шар, а не двигает
771
+ плоскость. Геометрия на низких зумах дробится в воркере: на сфере прямой отрезок
772
+ идёт хордой и срезает побережье.
773
+
774
+ Для мелких зумов нужен мировой набор — основной покрывает только Таджикистан:
775
+
776
+ ```bash
777
+ bash deploy/rebuild-world-tiles.sh # ~1,2 МБ, z0–6, Natural Earth
778
+ ```
779
+
780
+ В набор входят океаны, озёра, границы, города и природные зоны — ледники, пустыни,
781
+ тундра, болота. Лесов там нет: растительности в Natural Earth нет ни в одном
782
+ масштабе, а зелень планетарного масштаба берётся из растровых наборов покрова,
783
+ для которых нужен растровый слой (его у движка пока нет).
784
+
785
+ Демо-сервер сам отдаёт z0–6 из него, а крупные зумы — из основного набора; так же
786
+ устроен и прод. На глобусе камера ограничена: наклон до 60° (как в Mapbox) и нижняя граница зума,
787
+ чтобы шар не превращался в точку. При включении глобуса камера доводится до этой
788
+ рамки плавно. Ограничения — в [porting/globe.md](porting/globe.md).
789
+
790
+ ## 3D-модели
791
+
792
+ ```bash
793
+ node scripts/fetch-models.mjs # четыре модели CC0 из набора Khronos, ~34 МБ
794
+ ```
795
+
796
+ ```ts
797
+ map.addModel(new OsmGL.Model({
798
+ url: '/examples/models/Lantern.glb',
799
+ coordinates: [68.7864, 38.5598],
800
+ fitHeight: 9, // высота НА МЕСТНОСТИ, метры
801
+ rotation: 20, // азимут, градусы
802
+ }))
803
+ ```
804
+
805
+ Модели живут **в одной сцене со зданиями**: общий свет и общая карта теней,
806
+ поэтому тень фонаря ложится на дом, а тень дома — на фонарь. Отдельного прохода
807
+ теней у моделей нет и не должно быть: вторая карта теней на общем контексте уже
808
+ роняла кадр в `maps3d-web`.
809
+
810
+ Размер задаётся через `fitHeight` в метрах, потому что единицы у моделей какие
811
+ угодно — в наборе Khronos ToyCar имеет габарит 7 см, а Fox 155 «метров».
812
+
813
+ Разбор GLB свой (`src/models/glb.ts`): позиции, нормали, координаты текстуры,
814
+ базовый цвет и его текстура. Нет Draco и meshopt, анимаций, скиннинга и PBR сверх
815
+ базового цвета — подробности и причины в [porting/models-gltf.md](porting/models-gltf.md).
816
+
817
+ ## Выбор здания кликом
818
+
819
+ ```ts
820
+ map.on('click', ({ building }) => {
821
+ if (building) console.log(building.featureId) // это osm_id из тайла
822
+ })
823
+ map.setHighlight(map.queryBuilding({ x, y })) // или вручную
824
+ new OsmGL.Map({ highlightBuildings: false }) // подсветку выключить
825
+ ```
826
+
827
+ Попадание считается **лучом на CPU** по геометрии видимых тайлов, а не отдельным
828
+ проходом идентификаторов с `readPixels`. У прохода есть цена: он синхронизирует
829
+ конвейер на каждый клик и требует номер фичи в КАЖДОЙ вершине — четыре байта на
830
+ вершину города ради одного попадания. Луч же строится сразу в пространстве
831
+ тайла (матрица тайла обращается), поэтому и высоты, и координаты берутся ровно в
832
+ тех единицах, в которых лежит меш. На настоящем тайле Душанбе один клик — 3–8 мс.
833
+
834
+ Подсветка рисуется отдельным маленьким мешем из треугольников выбранной фичи
835
+ (их даёт массив «номер фичи на треугольник», он есть с самого начала). Цвет
836
+ полупрозрачный: под ним остаются видны окна и светотень — иначе выделенный дом
837
+ читается не как «выбран», а как «другой дом». Рантайм-объект важнее здания:
838
+ клик по маршруту или маркеру не проваливается в дом под ними.
839
+
840
+ Выделение задаётся **на id фичи**, а не на тайл: здание, пересекающее границу,
841
+ лежит в нескольких тайлах, и подсветка «того тайла, по которому кликнули»
842
+ закрашивала половину дома. У MapLibre и Mapbox то же решение — `setFeatureState`
843
+ привязан к id фичи и применяется во всех тайлах источника.
844
+
845
+ ## Входы в здание
846
+
847
+ ```jsonc
848
+ {
849
+ "id": "building-entrance", "type": "symbol",
850
+ "source": "points", "source-layer": "entrances", "minzoom": 17,
851
+ "layout": {
852
+ "placement": "map", // стрелка лежит НА ЗЕМЛЕ, а не значком на экране
853
+ "iconRotate": "angle", // азимут «внутрь дома», посчитан в базе
854
+ "showFor": "highlight", // только у ВЫБРАННОГО дома
855
+ "arrowLength": { "stops": [[17, 21], [21, 1.7]] }, // метры, по зуму
856
+ "textField": ["{ref}"] // номер подъезда — обычной экранной подписью
857
+ }
858
+ }
859
+ ```
860
+
861
+ Как у Яндекса: выбрал дом — у дверей легли стрелки, показывающие, куда заходить.
862
+ Без выбора их нет: висеть у каждого дома они не должны, это шум.
863
+
864
+ **Размер — в пикселях со стопами по зуму, как ширина дорог** (`arrowUnits: "pixels"`,
865
+ по умолчанию; длина 22 px на z17 → 56 на z22). Стрелка лежит на земле, и в метрах её
866
+ экранный размер растёт вчетверо на два уровня зума: между z17 и z22 она разбухала с
867
+ двух десятков пикселей до трёхсот и перерастала дом. Держать её строго постоянной
868
+ тоже неверно — на мелком зуме она читается наклейкой поверх карты. Поэтому ровно тот
869
+ же приём, что у `road-major` (11 px на z16 → 52 на z20): значение в пикселях,
870
+ интерполяция по зуму, перевод в геометрию каждый кадр (пиксель — это `1/worldSize`
871
+ нормализованного меркатора). `arrowUnits: "meters"` оставлен на случай, когда нужен
872
+ именно объект на местности.
873
+
874
+ **Отступ от стены** (`arrowGap`, 20 px): остриё встаёт НЕ на контур, а перед ним.
875
+ Впритык оно сливается со стеной, а под наклоном уходит под неё. Проверка — остриё
876
+ каждой стрелки обязано лежать снаружи контура выбранного дома.
877
+
878
+ **Стрелка — геометрия на поверхности карты, а не значок.** Разница видна сразу:
879
+ экранный значок всегда развёрнут к пользователю и одинаков при любом наклоне, а
880
+ стрелка у двери обязана лежать на земле — поворачиваться вместе с картой, уходить
881
+ в перспективу и прятаться за домом, который стоит перед ней. Поэтому
882
+ `placement: "map"` рисуется отдельным проходом (`layers/ground-arrows.ts`)
883
+ процедурными треугольниками: древко, голова, белая кайма. Размер в МЕТРАХ —
884
+ стрелка это объект на местности, а не элемент интерфейса, и при зуме растёт вместе
885
+ с домом. Рисуется в проходе земли, ДО зданий и без записи глубины: дом впереди
886
+ закрывает её сам, а сама она ничего не загораживает. Координаты — нормализованный
887
+ меркатор относительно центра карты, как у рантайм-объектов: во Float32 мировые
888
+ пиксели города дали бы шаг в метры. Подпись (номер подъезда) осталась ЭКРАННОЙ —
889
+ её надо читать при любом наклоне.
890
+
891
+ Остриё смотрит на дверь, хвост уходит НАРУЖУ: стрелка показывает не «здесь дверь»,
892
+ а «заходить отсюда». На самой двери стоит кружок (`arrowDotRadius`) — стрелка
893
+ говорит, откуда заходить, точка — куда именно; так это устроено у Яндекса. Кружок
894
+ лежит в ТОЙ ЖЕ фигуре, что и стрелка (общий набор вершин и общий список индексов),
895
+ поэтому рисуется тем же проходом и той же парой «обводка + заливка».
896
+
897
+ Номер подъезда стоит ПОД стрелкой по центру — так же, как подпись POI стоит под
898
+ значком. Сдвига для этого два, и они разной природы: по карте — к середине стрелки
899
+ (точка привязки сидит на стене, а стрелка от неё отъехала, и номер иначе лежит на
900
+ доме), по экрану — вниз. Экранный обязан быть экранным: низ для читателя всегда низ
901
+ кадра, как бы ни была повёрнута и наклонена карта.
902
+
903
+ Форма — замкнутый контур (`arrowShape`), а не «прямоугольник плюс треугольник», и
904
+ подобрана она под взгляд ПОД НАКЛОНОМ: сужение к хвосту читается как направление,
905
+ даже когда голова частью за домом; вырез у основания головы делает фигуру стрелой,
906
+ без него в перспективе она выглядит ромбом; хвост скруглён, потому что острый угол
907
+ на земле вырождается в пару пикселей и мерцает при движении камеры. Обводка —
908
+ тот же контур, отодвинутый наружу по биссектрисам (как фаска у рельефа): «та же
909
+ стрелка, но крупнее» растёт от центра, и на носу кайма выходит вдвое тоньше, чем
910
+ на боках. Триангуляция считается ОДИН раз на слой — форма у всех стрелок общая,
911
+ меняются только положение и поворот.
912
+
913
+ **Направление — ВНУТРЕННЯЯ НОРМАЛЬ К СТЕНЕ**, а не азимут на центр здания. Это не
914
+ придирка: у длинного дома вход смещён от середины, и направление на центр уходит от
915
+ нормали в среднем на 45°, а в худшем случае на 83° — стрелка ложится почти вдоль
916
+ стены и читается как «мимо дома». Нормаль берётся по касательной к контуру в точке
917
+ входа (±1 м вдоль периметра), из двух сторон выбирается та, что ведёт внутрь
918
+ полигона. Сама точка снаппится на контур: узел бывает смещён на метр-другой, а
919
+ остриё обязано упираться в стену.
920
+
921
+ Считается это в базе при сборке тайлов, а не на клиенте: там есть и узел, и контур.
922
+ Поворот камеры не нужен вовсе — геометрия лежит на земле и разворачивается вместе с
923
+ картой сама. Проверка независимая: у всех 202 входов с домом точка в метре по
924
+ азимуту лежит ВНУТРИ здания, в метре против — снаружи, отклонение от перпендикуляра
925
+ к стене 0,00°.
926
+
927
+ Номер подъезда отъезжает на древко стрелки (на 55% её длины наружу): точка привязки
928
+ сидит на стене, и подпись иначе ложится на сам дом, а не рядом со стрелкой.
929
+
930
+ **Цвет — свой на каждую тему**, и раздаёт его тот же скрипт, что и цвета категорий
931
+ POI (`scripts/apply-poi-palette.mjs`). Причина та же, что у подписей, но острее:
932
+ стрелка лежит НА ЗЕМЛЕ, поэтому дневной синий на графитовой карте сливается с
933
+ асфальтом, а светлый акцент теряется на бетоне. Тон акцента сохраняется, светлота
934
+ подгоняется под фон (на тёмных темах поднимается и слегка гасится насыщенность —
935
+ иначе чистый синий выглядит неоном, на очень светлых притемняется), монохромная тема
936
+ остаётся монохромной, обводка берётся у темы — это её «цвет фона под текстом».
937
+ Контраст к фону проверяется тестом на ВСЕХ 25 темах (худший случай — 3.07 при пороге
938
+ 2.5), номер подъезда красится тем же цветом и той же обводкой.
939
+
940
+ **Привязка входа к дому — геометрическая, по контуру.** Сложить их по ключу
941
+ нечем: id зданий в тайлах ставит планетайлер (`osm_id * 10 + тип`), а правки живут
942
+ в своей схеме, и совпадение id — не гарантия. Поэтому рендерер отдаёт контур
943
+ выбранного здания в мировых пикселях (проекция крыши, треугольниками по тайлам), а
944
+ слой со `showFor: "highlight"` показывает только те точки, что в него попадают.
945
+ Допуск — полтора метра: узел входа сидит РОВНО на ребре, и строгая проверка
946
+ «внутри треугольника» теряла бы его на ошибке округления. Контур собирается по всем
947
+ видимым тайлам сразу — дом на границе тайлов приезжает кусками, и половина входов
948
+ иначе пропала бы.
949
+
950
+ **Данные** — `deploy/entrances-geojson.sql` из схемы `edit.*` (в `osm.point` узел
951
+ с одним тегом `entrance=yes` не попадает вовсе: классификатор импорта смотрит
952
+ `highway`/`building`/`amenity`/…). Едут они в общий набор дорожных точек
953
+ (`navpoints.mbtiles`) отдельным слоем — через поле `tippecanoe.layer` у фичи,
954
+ поэтому ни второго файла, ни второй кнопки в админке не понадобилось. ГОЧА
955
+ запроса: искать здание НАДО от узла (в каких путях он состоит). Обратный ход —
956
+ «построить полигоны всех зданий и найти ближайший» — вешает базу на десятки
957
+ минут: полигоны 478 тыс. зданий строятся целиком и без индекса.
958
+
959
+ ## Камера в адресе страницы
960
+
961
+ ```ts
962
+ new OsmGL.Map({ hash: true }) // #зум/широта/долгота/поворот/наклон
963
+ new OsmGL.Map({ hash: 'map' }) // #map=16.4/38.5598/68.7864 — рядом с чужими якорями
964
+ ```
965
+
966
+ `#16.4/38.559800/68.786400/-18/62` — формат тот же, что у MapLibre и Mapbox.
967
+ Совместимость тут не украшение: ссылками на место обмениваются между картами
968
+ разных движков, и свой формат означал бы, что чужая ссылка открывается
969
+ «где-то не там».
970
+
971
+ При загрузке адрес **сильнее** опций `center`/`zoom`: ссылка на место обязана
972
+ открывать место, а не то, что зашито в приложении. Правка адреса руками и
973
+ кнопка «назад» тоже двигают карту — это `hashchange`.
974
+
975
+ Точность координат считается **от зума**: на z18 пятый знак широты это метр и
976
+ терять его нельзя, а на z3 он же — шум, от которого ссылка перестаёт читаться.
977
+ Поворот и наклон в ссылку не пишутся, пока они нулевые.
978
+
979
+ Адрес обновляется с задержкой в 300 мс и через `replaceState`: за одно
980
+ перетаскивание камера меняется десятки раз, и `pushState` набил бы историю так,
981
+ что кнопка «назад» перестала бы работать.
982
+
983
+ ## Качество под устройство
984
+
985
+ ```ts
986
+ new OsmGL.Map({ quality: 'auto' }) // по умолчанию
987
+ map.setQuality('low') // или явно
988
+ map.getQuality() // { auto, profile, pixelRatio }
989
+ ```
990
+
991
+ | Профиль | dpr | буфер | MSAA | воркеры | карта теней | детализация |
992
+ |---------|-----|-------|------|---------|-------------|-------------|
993
+ | `high` | ≤2 | ≤4096×4096 | да | ≤6 | 1024 | 1 |
994
+ | `balanced` | ≤1.75 | ≤2560×2160 | да | ≤4 | 1024 | 0.85 |
995
+ | `low` | ≤1.25 | ≤1920×1350 | нет | ≤2 | 512 | 0.5 |
996
+
997
+ Телефон отличается от десктопа не столько силой GPU, сколько **плотностью
998
+ экрана**: при `devicePixelRatio` 3 кадр 412×892 CSS — это 3,3 миллиона
999
+ пикселей, вдвое больше типичного окна на ноутбуке, и рисует их вчетверо более
1000
+ слабый чип. Поэтому первое, что ограничивается, — плотность буфера: у MapLibre
1001
+ это `pixelRatio` и `maxCanvasSize`, у Mapbox — потолок 4096×4096.
1002
+
1003
+ Потолка два, и второй не лишний: планшет с `dpr = 2` пролезает под лимит
1004
+ плотности и всё равно получает буфер, который его GPU не тянет, — поэтому
1005
+ ограничивается ещё и **площадь**.
1006
+
1007
+ Профиль выбирается по дешёвым признакам (`hardwareConcurrency`, `deviceMemory`,
1008
+ тип указателя): мерить GPU бенчмарком на старте нельзя, это сотни миллисекунд
1009
+ ровно там, где важна первая отрисовка. Признак «мобильный» складывается по ИЛИ
1010
+ из `userAgentData.mobile` и «палец + небольшой экран» — объект `userAgentData`
1011
+ бывает present-but-wrong (эмуляторы, вебвью, режим «версия для ПК»), и через
1012
+ `??` его `false` побеждал бы настоящий телефон.
1013
+
1014
+ Ошибку определения подчищает **адаптация делом**: восемь тяжёлых кадров подряд
1015
+ понижают профиль на ступень, и так до самого слабого. Кадр дольше 100 мс
1016
+ засчитывается сразу за три — ждать подтверждения очевидного незачем.
1017
+ Уложившийся кадр гасит счётчик постепенно, а не сбрасывает в ноль: одиночные
1018
+ быстрые кадры бывают и на стабильно тяжёлой карте. Вверх не поднимаемся —
1019
+ качели «плохо → хорошо → плохо» заметнее стабильной картинки.
1020
+
1021
+ Мерится при этом **интервал между кадрами**, а не время нашей работы. Команды
1022
+ уходят в очередь WebGL, и вся плата за заливку приходит уже после возврата из
1023
+ `render`: на телефоне из-за этого соседствовали «кадр 2 мс» и карта, которая
1024
+ едет рывками. В событии `render` теперь два числа — `frameMs` (наша работа) и
1025
+ `intervalMs` (реальная стоимость кадра при непрерывной отрисовке).
1026
+
1027
+ На слабых профилях выключается **работа, а не её вес**: `detail: 0` убирает
1028
+ материалы и фасады из шейдера целиком (множитель ноль оставил бы тот же шум с
1029
+ нулевым весом), тени не считаются вовсе, а тайлы берутся крупнее — тайл это и
1030
+ запрос, и разбор, и десятки вызовов отрисовки, и экономия здесь заметнее, чем на
1031
+ любом шейдере.
1032
+
1033
+ `maxPixelRatio` и `workerCount` в опциях сильнее профиля. Сглаживание после
1034
+ создания карты не переключается: это атрибут контекста WebGL.
1035
+
1036
+ ## Рельеф поверхностей
1037
+
1038
+ ```jsonc
1039
+ {
1040
+ "id": "park",
1041
+ "type": "surface", // не fill: живёт в объёме
1042
+ "source-layer": "park",
1043
+ "elevation": 3.2, // метры над землёй; отрицательное — ниже
1044
+ "bevel": 2.5, // ширина фаски по горизонтали
1045
+ "bevelDrop": 0.7, // на сколько фаска опускается
1046
+ "cornerRadius": 9, // скругление в плане
1047
+ "paint": { "color": "#cfe5bd", "material": "foliage", "sideShade": 0.14 }
1048
+ }
1049
+ ```
1050
+
1051
+ ### Лесенка высот: у каждой поверхности своя ступень
1052
+
1053
+ Плоские зоны в городе лежат друг на друге — парковка во дворе, спортплощадка в парке, школьный
1054
+ участок в жилом квартале. Если две поверхности встали на одну высоту, их грани начинают спорить за
1055
+ глубину: край двоится и мерцает при движении камеры (z-fighting). Поэтому **высоты обязаны быть
1056
+ разными**, а порядок — по вложенности: чем чаще слой лежит ПОВЕРХ других, тем он выше.
1057
+
1058
+ Действующая лесенка в `styles/osm-city.json` (метры):
1059
+
1060
+ | высота | слой | |
1061
+ |---|---|---|
1062
+ | −0.087 | `water` | вода утоплена |
1063
+ | −0.05 | `water-pool` | |
1064
+ | 0.02 | `landuse-residential` | самая широкая зона — основание |
1065
+ | 0.03 | `landuse-industrial` | |
1066
+ | 0.035 | `landcover-sand` | |
1067
+ | 0.045 | `landcover-wetland` | |
1068
+ | 0.055 | `landuse-civic` | школы, больницы |
1069
+ | 0.065 | `aeroway-area` | |
1070
+ | 0.075 | `landuse-cemetery` | |
1071
+ | 0.088 | `landuse-sport` | |
1072
+ | 0.125 | `landcover-grass` | |
1073
+ | 0.15 | `park` | |
1074
+ | 0.175 | `landcover-wood` | |
1075
+ | 0.19 | `landuse-parking` | выше всех: встречается и во дворе, и в парке |
1076
+
1077
+ Добавляешь новую поверхность — дай ей СВОЮ ступень, а не «как у соседней». Значения мелкие
1078
+ (сантиметры), на вид они не влияют: работает только порядок и подсветка кромки.
1079
+
1080
+ ```ts
1081
+ map.setStyle(OsmGL.RELIEF_STYLE) // готовый вариант
1082
+ node scripts/make-relief-style.mjs // пересобрать из базового
1083
+ ```
1084
+
1085
+ Зелень поднимается над землёй, вода уходит вниз, площадки лежат своими
1086
+ уровнями. Каждая поверхность — это верх, **фаска** и юбка до земли; фаска и есть
1087
+ «край без уголков»: верхняя грань сдвинута внутрь, и переход к вертикали идёт
1088
+ наклонной полосой. Вместе со скруглением контура в плане (`cornerRadius`) край
1089
+ получается мягким с обеих сторон.
1090
+
1091
+ Отдельный тип слоя, а не `fill` с высотой: поверхность пишет глубину, получает
1092
+ свет и тень из общей карты, а материал (рябь, кроны) кладётся **только на верх**
1093
+ — на фаске он читался бы полосами поперёк.
1094
+
1095
+ Отдельный тип и не `fill-extrusion`: у здания высота приходит из данных, стены
1096
+ несут фасад с окнами, а здесь уровень задаёт стиль, он бывает отрицательным, и
1097
+ вместо стены нужен тонкий борт.
1098
+
1099
+ Дороги поднимаются `paint.elevation` (метры) — это **юниформ**, геометрия не
1100
+ пересобирается. В готовом стиле полотно лежит чуть выше земли, чтобы его не
1101
+ проглотил приподнятый двор, а тропинки — на уровне зелени, по которой они идут.
1102
+
1103
+ **Борт утопленной поверхности не берёт свет.** У воды, опущенной ниже земли,
1104
+ юбка идёт вверх и упирается в грунт: физически это откос берега, и освещённый
1105
+ борт читается тёмной каймой вдоль всей береговой линии. Поэтому у поверхности с
1106
+ отрицательным уровнем `paint.sideLight` по умолчанию 1 — освещённый борт
1107
+ смешивается с плоским цветом слоя. Именно смешивание с плоским цветом, а не
1108
+ наклон нормали к вертикали: земля у нас вообще не геометрия, света она не
1109
+ получает, и борт с любым, даже правильно посчитанным светом остаётся лентой. По
1110
+ той же причине в рельефном стиле у утопленных слоёв `sideShade: 0`.
1111
+
1112
+ ★ **Цвет борта — свой, а не фон стиля.** Одно время утопленный борт красился
1113
+ фоном («борт это берег, то есть та же земля вокруг»). Мысль верна ровно там, где
1114
+ вокруг воды и правда голая земля: пруд посреди парка получал светлую кайму
1115
+ шириной с фаску, и она читалась не берегом, а **зазором между водой и травой**.
1116
+ Чем окружён водоём, слой не знает и знать не может, поэтому чужим цветом борт не
1117
+ красит. Кому нужен берег — задаёт `paint.sideColor` явно.
1118
+
1119
+ Переопределяется на слое — `paint.sideColor`, `paint.sideLight`, `paint.sideShade`;
1120
+ у приподнятых поверхностей поведение прежнее, иначе парк потеряет объём.
1121
+
1122
+ **Остров внутри воды закрывается крышкой — только в ГЛУБИНУ.** Плоские слои
1123
+ рисуются без записи глубины (земля это краска, а не геометрия), поэтому
1124
+ утопленную воду перекрывать нечем: сквозь остров посреди реки была видна вода за
1125
+ ним. Дырка в контуре утопленной поверхности получает горизонтальную крышку на
1126
+ уровне земли, но её индексы лежат ОТДЕЛЬНО (`capIndexOffset`) и рисуются своим
1127
+ проходом с выключенной записью цвета: на острове могут быть свои слои — парк,
1128
+ пляж, застройка, — и закрашивать его землёй значило бы менять карту вместо того,
1129
+ чтобы починить перекрытие. Проход идёт по всем тайлам сразу, до цветного:
1130
+ вода соседнего тайла, нарисованная раньше чужой крышки, уже легла бы краской, и
1131
+ глубина её не отменит.
1132
+
1133
+ Сдвиг контура внутрь ради фаски делается по биссектрисе и **проверяется**: если
1134
+ площадь схлопнулась или контур вывернулся, фаска для этой фигуры не строится.
1135
+ Иначе узкая протока превращалась бы в узел самопересечений.
1136
+
1137
+ **Соседние полигоны слоя склеиваются.** Река в данных нарезана на десяток
1138
+ полигонов, парк граничит с газоном того же слоя. Каждый сам по себе не знает о
1139
+ соседе и строит борт по всему своему краю — на общей границе два борта
1140
+ складываются в отчётливый гребень посреди сплошной воды. Поэтому рёбра,
1141
+ встречающиеся в слое дважды, считаются внутренними: ни борта, ни скругления, ни
1142
+ сдвига под фаску на них нет, и слой читается одной поверхностью. Остаётся случай
1143
+ T-образного стыка (у соседа на общем ребре лишняя вершина) — там рёбра не
1144
+ совпадают точь-в-точь, и тонкая линия может остаться.
1145
+
1146
+ **ГОЧА (стоила пустых тайлов).** Место в буфере бронирует КАЖДЫЙ, кто пишет
1147
+ вершины, и по своему точному числу. Оценка «на фигуру целиком» была занижена:
1148
+ контур даёт верх + фаску + юбку, то есть вдевятеро больше вершин, чем в нём
1149
+ точек, — и на кольце длиннее ~30 точек запись уходила за границу `DataView`.
1150
+ Симптом при этом не «пропал слой», а «на некоторых зумах тайлы не грузятся»:
1151
+ исключение обрывало сборку ВСЕГО тайла, ответ не уходил вовсе, и на карте
1152
+ оставались одни подписи. Поэтому же тесселяция каждого слоя обёрнута в `try` —
1153
+ сбойный слой теперь теряет только себя, а его имя уезжает в `diag.failed` и в
1154
+ предупреждение консоли. На синтетическом прямоугольнике это не ловится (четыре
1155
+ точки), проверка идёт по настоящим тайлам всех зумов.
1156
+
1157
+ ## Скругление углов
1158
+
1159
+ ```jsonc
1160
+ {
1161
+ "id": "building",
1162
+ "type": "fill-extrusion",
1163
+ "cornerRadius": 1.6, // метры; 0 или нет свойства — острые углы
1164
+ "cornerSegments": 2, // отрезков на ПРЯМОЙ УГОЛ (и потолок на дугу)
1165
+ "paint": { … }
1166
+ }
1167
+ ```
1168
+
1169
+ ```ts
1170
+ map.setStyle(OsmGL.SOFT_STYLE) // готовый стиль со скруглением
1171
+ node scripts/make-soft-style.mjs // пересобрать его из базового
1172
+ ```
1173
+
1174
+ Радиус задаётся **на слой**, поэтому у здания это фаска в метр-два, у зелени и
1175
+ воды — естественная граница в десяток метров, а у землепользования что-то между.
1176
+ Одинаковый радиус на всех превращает мелкие дворы в кляксы, а дома оставляет
1177
+ острыми.
1178
+
1179
+ У дома скругляется контур, а значит и стены, и **крыша**: она строится по тому
1180
+ же кольцу. Угол срезается на равную длину по обоим рёбрам, срез заполняется
1181
+ квадратичной кривой Безье. Скругляются только **выпуклые** углы: срез вогнутого
1182
+ уходит внутрь контура и даёт самопересечение, которого триангуляция не прощает.
1183
+ На границе тайла срез не делается — у соседа он пришёлся бы на другое место, и
1184
+ на стыке получилась бы ступенька.
1185
+
1186
+ Это **стиль, а не тема**: скругление считает воркер при тесселяции, то есть его
1187
+ смена пересобирает тайлы. Тема по устройству бесплатна — она меняет только цвета
1188
+ и свет, и класть в неё геометрию значило бы отменить это свойство.
1189
+
1190
+ Цена честная: на настоящем тайле Душанбе меш зданий вырастает примерно вдвое
1191
+ (88 → 187 тыс. вершин при двух отрезках на дугу, втрое при трёх). Поэтому у
1192
+ зданий в `SOFT_STYLE` отрезков два, а мелкие уступы короче трети радиуса не
1193
+ скругляются вовсе — на глаз их не видно, а вершин они добавляют половину.
1194
+
1195
+ ### Дробность дуги
1196
+
1197
+ `cornerSegments` — это отрезки на **прямой угол**, а не потолок. Раньше шаг дуги
1198
+ был жёстко 22,5°, и прямой угол получал ровно четыре отрезка независимо от того,
1199
+ что стояло в стиле: потолок срабатывал только на поворотах круче 90°, а таких в
1200
+ городе почти нет. Поднимать число в стиле было бессмысленно, и водоём на близком
1201
+ зуме читался срезанной фаской. Теперь шаг выводится из числа (90°/N) и не бывает
1202
+ грубее прежних 22,5° — стили с `segments ≤ 4` дают ту же геометрию, что и до
1203
+ правки, до точки.
1204
+
1205
+ **У поверхностей скругляется КАЖДЫЙ выпуклый угол** (`minCutRatio: 0`), а не
1206
+ только те, где срез вышел не меньше трети радиуса. У зданий этот порог экономит
1207
+ меш на мелких уступах, а берег из таких уступов и состоит: тайлы кончаются на
1208
+ z14, планетайлер упрощает контур до рёбер в пять-семь метров, и пропуская их, мы
1209
+ пропускаем ровно те углы, из-за которых водоём выглядит рубленым. Вода в
1210
+ `CITY_STYLE` получает 16 отрезков на угол, зелень и площадки — 12, здания
1211
+ остаются на двух. Поверхности от этого тяжелеют примерно вдвое-втрое (вода на
1212
+ z13 — 12 → 33 тыс. вершин), но в абсолютных числах это доли бюджета зданий.
1213
+
1214
+ ★ **Координата вершины поверхности — Float32, а не Int16 единиц тайла.** У
1215
+ зданий Int16 хватает: единица тайла это около полуметра на z14, нашем последнем
1216
+ зуме с данными, а деталей такого размера у дома нет. У поверхности есть — берег
1217
+ это пояс фаски шириной 0,43 единицы, то есть уже полутора округлений. На целой
1218
+ сетке было два видимых дефекта разом: дуга из отрезков по 0,7 единицы садилась
1219
+ на неё лесенкой (19 точек кольца из 89 попадали в одну точку сетки), а ширина
1220
+ берегового пояса гуляла от 0,24 до 0,45 единицы. На z21 видно и то и другое, так
1221
+ что «сделали глаже» выглядело ХУЖЕ, чем было. Четыре лишних байта на вершину
1222
+ (stride 12 → 16) платит только поверхность: их на тайле десятки тысяч против
1223
+ сотен тысяч у зданий.
1224
+
1225
+ ## Материалы плоских слоёв
1226
+
1227
+ ```jsonc
1228
+ "paint": {
1229
+ "color": "#a5cbe3",
1230
+ "material": "water", // flat | water | foliage | sand | asphalt
1231
+ "materialScale": 14, // размер рисунка в МЕТРАХ: у воды — длина волны
1232
+ "materialSpeed": 1, // скорость ряби; 0 — статичная вода без перерисовок
1233
+ "materialStrength": 1 // выраженность, 0 — та же сплошная заливка
1234
+ }
1235
+ ```
1236
+
1237
+ У сплошной заливки нет нормали, поэтому свет на неё не ложится вовсе — карта
1238
+ читается бумажной. Материал даёт поверхности **микрорельеф**, и дальше работает
1239
+ тот же свет, что у зданий: вода бликует, кроны получают светотень, асфальт
1240
+ перестаёт быть однотонным пятном. Всё считается процедурно, ни одной текстуры.
1241
+
1242
+ Настраивается целиком из стиля, поэтому «сплошной» вариант никуда не делся:
1243
+ нет `material` — слой рисуется ровно как раньше, без единой лишней инструкции в
1244
+ шейдере. Темы переопределяют это теми же paint-полями.
1245
+
1246
+ Координата рисунка **сквозная через тайлы**: начало тайла в метрах приходит
1247
+ юниформом (с остатком от 65 км, чтобы не терять точность Float32). Возьми её
1248
+ внутри тайла — и рисунок начнётся заново на каждой границе: по швам пойдут
1249
+ полосы, которые сразу замечают глазами и не видит ни один тест. Дымовой тест
1250
+ теперь ставит камеру ровно на границу тайлов z14 и меряет разрыв между соседними
1251
+ столбцами кадра.
1252
+
1253
+ ★ **Поверхности перекрывают линию разреза на единицу тайла**
1254
+ (`SURFACE_CUT_BLEED`). Обрезка по квадрату тайла точная, но НА ЭКРАНЕ соседние
1255
+ тайлы считают общую границу разными матрицами, и во Float32 они расходятся на
1256
+ доли пикселя: половину пикселей шва не накрывает никто, и сквозь воду
1257
+ проступает земля отдельными точками — волосяная трещина ровно по стыку (замер:
1258
+ пиксель 174,225,250 вместо 151,221,255, то есть четверть пикселя земли).
1259
+ Вершины, лежащие НА разрезе, выдвигаются наружу: соседи перекрываются вместо
1260
+ того, чтобы иногда не сходиться, а перекрытие двух одинаковых по цвету и высоте
1261
+ поверхностей невидимо. Бортов это не касается — на линии разреза их не строят.
1262
+
1263
+ ★ **Обрезает нахлёст ТРАФАРЕТ, а не арифметика вершин** (`drawTileStencil`).
1264
+ Перед проходом поверхностей каждый видимый тайл красит свой квадрат в трафарет
1265
+ своим номером, и дальше слой рисует только «свои» пиксели (`stencilFunc EQUAL`).
1266
+ Так делает mapbox-gl-js (`_renderTileClippingMasks` + `stencilModeForClipping`),
1267
+ и это единственное честное лекарство: два соседних квада делят пиксели границы
1268
+ по правилу заполнения растеризатора — без пропусков и без повторов, на любом
1269
+ зуме и при любом наклоне. Отсюда и вся конструкция: геометрия обязана ЗАЕЗЖАТЬ
1270
+ за границу (`a_bleed`), трафарет её ровно там и срежет.
1271
+
1272
+ Полумеры не работают, и обе проверены на карте: обрезать геометрию точно по
1273
+ квадрату — остаётся щель (соседи считают границу разными матрицами и во Float32
1274
+ расходятся на доли пикселя); дать нахлёст без трафарета — у полупрозрачного слоя
1275
+ перекрытие ложится второй краской, и полоса выходит заметнее трещины (это сразу
1276
+ проявилось на парке с `opacity: 0.75`). Трафарет закрывает оба случая разом,
1277
+ поэтому нахлёст кладётся всем слоям без оглядки на прозрачность.
1278
+
1279
+ Номера, а не «покрасить пиксель один раз»: слоёв поверхностей девять, и чистить
1280
+ трафарет под каждый было бы дороже одного прохода на кадр. Трафарет
1281
+ восьмибитный, поэтому при более чем 255 видимых тайлах разметка пропускается —
1282
+ это мелкие зумы, где шва и не видно.
1283
+
1284
+ ★ **Метры в этой координате — МЕРКАТОРНЫЕ, а не настоящие.** Сначала ширина
1285
+ тайла бралась по широте его собственного центра (`metersPerTile`), и это тихо
1286
+ ломало ровно то, ради чего координата заведена. У соседей по вертикали широта
1287
+ разная, ширина тайла отличается на пару миллиметров, а начало считается как
1288
+ `x × ширина`, где `x` — сотни тысяч: на z18 два соседних ряда расходились на
1289
+ **324 метра**, то есть больше чем на две ширины тайла. Вертикальный шов был
1290
+ цел, а горизонтальный резал водоём пополам — особенно заметно на бегущей ряби.
1291
+ Меркаторные метры от широты не зависят, поэтому непрерывны и по x, и по y, и
1292
+ между зумами (у родителя тайл вдвое шире, а `x` вдвое меньше — начало то же).
1293
+
1294
+ Обратно в настоящие метры (чтобы рябь осталась четырнадцатиметровой, а черепица
1295
+ черепицей) переводит делитель по широте **центра карты**, общий на кадр:
1296
+ делится и масштаб, и начало, поэтому размеры прежние, а непрерывность точная.
1297
+ По широте центра, а не тайла, именно потому, что делитель обязан быть у всех
1298
+ тайлов один — иначе шов возвращается. Цена: при движении с юга на север рисунок
1299
+ незаметно «дышит» (по Таджикистану — шесть процентов на четыреста километров).
1300
+
1301
+ Рисунок гаснет по `fwidth` — числу метров на пиксель, — причём порог считается
1302
+ **от размера рисунка**: рябь в 14 м и дюны в 26 м исчезают из виду на разных
1303
+ зумах, и общий порог либо гасил бы дюны раньше времени, либо оставлял бы воду
1304
+ рябить пикселями.
1305
+
1306
+ Две вещи, которые пришлось выяснить на воде. Настоящая рябь наклонена на единицы
1307
+ градусов, и любая формула блика на такой нормали даёт ноль — для блика нормаль
1308
+ намеренно «крутим» круче, для рассеянного света берём честную. А три чистых
1309
+ синуса дают идеально ровные параллельные гребни, читающиеся штриховкой, поэтому
1310
+ фаза сбивается медленным шумом.
1311
+
1312
+ ## Небо и дымка
1313
+
1314
+ ```ts
1315
+ map.setSky({ skyColor: '#7fb2e5', horizonColor: '#dfe6ee', fogRange: [1200, 9000] })
1316
+ map.setSky(false)
1317
+ ```
1318
+
1319
+ Небо — **вертикальный градиент во весь экран**, рисуемый первым. Геометрии у него нет:
1320
+ крена у нашей камеры не бывает, поэтому горизонт всегда горизонтальная линия, а её
1321
+ экранную координату даёт `transform.horizonScreenY()`. Скайбокс с кубической текстурой
1322
+ дал бы ровно тот же результат втрое дороже.
1323
+
1324
+ Ниже горизонта заливается цветом **дымки**, а не неба. Туда же уходит дальняя земля —
1325
+ совпадение цветов делает стык невидимым, и край данных перестаёт читаться как обрыв.
1326
+ Ради этого всё и сделано.
1327
+
1328
+ Дальность дымки (`fogRange`) меряется **в расстояниях от камеры до центра карты**, как
1329
+ в Mapbox, а не в метрах. Это не мелочь: при взгляде СВЕРХУ вся видимая земля равноудалена
1330
+ от камеры (от 1.0 в центре кадра до 1.15 у края), и порог в метрах либо накрывает весь
1331
+ кадр дымкой, либо не срабатывает никогда — подобрать его на все зумы нельзя. В
1332
+ относительных единицах порог 1.35 сам собой оказывается за пределами вида сверху и
1333
+ попадает в кадр только при наклоне, где земля действительно уходит вдаль.
1334
+
1335
+ Глубина во фрагменте берётся как `1.0 / gl_FragCoord.w`; это работает в любом шейдере,
1336
+ что важно — слои земли рисуются с выключенным тестом глубины, и на буфер глубины
1337
+ полагаться нельзя.
1338
+
1339
+ Каждая тема задаёт своё небо: у ночной оно тёмное, у бледной почти белое.
1340
+
1341
+ **На глобусе небо становится космосом**: тёмный фон, звёзды и светящаяся кайма
1342
+ атмосферы вокруг планеты (`spaceColor`, `atmosphereColor`, `starIntensity`). Рисует
1343
+ это тот же полноэкранный проход. Звёзды закреплены в мире, а не на экране, поэтому
1344
+ при повороте камеры они стоят на месте, а не ползут вместе с кадром.
1345
+
1346
+ **Подписи дымка не берёт** — они рисуются в экранных координатах, и глубины во фрагменте
1347
+ у них нет. Поэтому подписи дальше конца дымки просто не размещаются: иначе над затянутой
1348
+ далью висела бы россыпь совершенно чётких названий.
1349
+
1350
+ ## Тени
1351
+
1352
+ По умолчанию **выключены**: проход глубины идёт по всей объёмной геометрии.
1353
+
1354
+ ```ts
1355
+ map.setShadows(true)
1356
+ map.setShadows({ strength: 0.6, color: '#5a6b86', resolution: 2048, bias: 0.0015 })
1357
+ ```
1358
+
1359
+ Направление берётся у ключевого источника света (`setLight`), поэтому отдельной ручки
1360
+ «куда падает тень» нет — солнце одно.
1361
+
1362
+ Одна карта теней на кадр, объём источника **подгоняется под видимую землю**: при
1363
+ наклоне 70° видимая площадь больше, чем сверху, в десятки раз, и фиксированный объём
1364
+ либо обрезал бы тени спереди, либо тратил бы все тексели на пустоту у горизонта.
1365
+ Фильтрация аппаратная (`sampler2DShadow` + `textureProj`), сверху 2×2 выборка.
1366
+
1367
+ **На плоскую графику тень кладётся ОДНИМ проходом** — квадрат на тайл с умножающим
1368
+ блендингом поверх всех уже нарисованных слоёв. Слоёв земли три десятка и они
1369
+ перекрываются: выборка в каждом фрагменте каждого слоя означала бы платить за тень
1370
+ столько раз, сколько слоёв под пикселем. Заодно тень сама ложится на дороги, воду и
1371
+ объекты приложения, без единой строки в их шейдерах.
1372
+
1373
+ **Глубина и цвет тени не настраиваются, а выводятся из света**: `ambient / (ambient
1374
+ + directional)` по каждому каналу — ровно то, что остаётся от освещения, когда солнце
1375
+ загорожено. Поэтому при ярком солнце тени глубже, при синем небе — синее.
1376
+
1377
+ **На объёме тень гасит только направленный свет**, а не итоговый цвет. В тени
1378
+ остаются ambient и заполняющий источник, поэтому стена сохраняет свой цвет и просто
1379
+ теряет солнце; умножение итогового цвета на оттенок давало серое пятно.
1380
+
1381
+ Тень отбрасывают только здания — у плоской земли нет объёма, и гонять её через второй
1382
+ проход было бы чистой потерей. Планы этажей (`overlay`) тень не принимают: они внутри
1383
+ здания.
1384
+
1385
+ Координата в карте теней считается СВОЕЙ матрицей на слой, а не из клипа камеры.
1386
+ Почему так — в [porting/shadows.md](porting/shadows.md); коротко: восстановление
1387
+ позиции из уже сжатой перспективой глубины во Float32 разваливается, и все здания
1388
+ уходят в собственную тень.
1389
+
1390
+ Освещение заодно стало естественнее: ambient зависит от нормали (небо ярче со стороны
1391
+ солнца, грань вниз видит меньше неба), а свет применяется с гаммой — прямое умножение
1392
+ sRGB-цвета пересвечивало светлое и заваливало тёмное.
1393
+
1394
+ ## Освещение
1395
+
1396
+ Ровный ambient + **два направленных источника**: ключевой (key) задаёт светотень,
1397
+ заполняющий (fill) светит примерно с противоположной стороны и вытягивает грани,
1398
+ отвёрнутые от ключевого. Тот же набор — ambient + dir1 + dir2 — используют картовые
1399
+ движки, и на зданиях он принципиально лучше полусферы, с которой мы начинали: у
1400
+ полусферы освещённость зависит только от `normal.z`, а у ЛЮБОЙ вертикальной стены
1401
+ `normal.z = 0`, значит все четыре стороны дома получали ОДИНАКОВЫЙ ambient и отличались
1402
+ лишь единственным солнечным членом — дома читались плоскими.
1403
+
1404
+ Азимуты по умолчанию намеренно НЕ диагональные (120/300, а не 135/315): на диагонали
1405
+ пары стен восток/юг и запад/север получают равную яркость и объём снова пропадает.
1406
+ На текущих значениях четыре стороны дают примерно 122, 135, 137 и 160 из 255.
1407
+
1408
+ ## Как устроено
1409
+
1410
+ ```
1411
+ Map ──┬── Transform камера: матрицы, project/unproject, пирамида видимости
1412
+ ├── GestureManager панорама/зум/поворот/наклон + инерция
1413
+ ├── TileSource ──── coveringTiles → WorkerPool → LRU-кеш тайлов
1414
+ │ │
1415
+ │ воркер: fetch → MVT → генераторы мешей
1416
+ │ └── transfer буферов без копирования
1417
+ ├── Style ───────── слои, фильтры, значения по зуму, свет
1418
+ └── LayerRenderer заливки → линии → экструзия; VAO на пару (тайл × слой)
1419
+ ```
1420
+
1421
+ **Координаты.** Единая система — «мировые пиксели»: нормализованный меркатор × `worldSize(zoom)`,
1422
+ ось Y вниз. Высота хранится в **метрах**, а матрица домножает Z на `pixelsPerMeter` — поэтому
1423
+ одно и то же здание корректно выглядит на любом зуме без пересборки буфера.
1424
+
1425
+ **Точность.** Матрицы считаются в `Float64` (на z=20 мировые координаты доходят до ~5·10⁸ пикселей —
1426
+ во `Float32` там уже дрожание), а в шейдер уходит `Float32` уже после переноса начала координат
1427
+ в тайл. Вершины лежат в локальных координатах MVT `[0..extent]`.
1428
+
1429
+ **LOD.** `coveringTiles` дробит тайл, пока его проекция на экран крупнее тайлового квадрата.
1430
+ При наклонённой камере это само собой даёт уровни детализации: у горизонта тайлы далеко,
1431
+ дробление там останавливается раньше, и вместо тысяч тайлов до горизонта получается пара десятков.
1432
+
1433
+ **Форматы вершин.** Заливка — 4 байта (только позиция). Линия — 12 байт: оба конца отрезка
1434
+ (`a_p0`, `a_p1` — одинаковы у всех четырёх вершин капсулы) + `a_corner` (какой конец и какая
1435
+ сторона). Здание — 12 байт:
1436
+ `a_pos` 2×Int16, `a_height` Uint16 (метры × 4), `a_tint` Uint8, `a_ao` Uint8, `a_normal` 3×Int8n.
1437
+ Высота ушла из Float32 в Uint16 ради места под `a_tint`: без разброса оттенка квартал выглядит
1438
+ одной сплошной массой. Вершины между рёбрами намеренно не переиспользуются — иначе усреднится
1439
+ нормаль и углы дома «поплывут».
1440
+
1441
+ **Рисунок вдоль линии.** Каждая вершина несёт расстояние от начала ломаной, поэтому
1442
+ пунктир (`dashArray`) и стрелки направления (`arrow`) идут НЕПРЕРЫВНО вдоль всей дороги,
1443
+ а не начинаются заново на каждом отрезке. Пунктир применён к тропам и границам;
1444
+ стрелки в базовый стиль намеренно НЕ включены — сплошь усыпанная шевронами улица
1445
+ выглядит грязно, и на подложке 2GIS их тоже нет. Возможность остаётся для слоя пробок. Шеврон считается аналитически в
1446
+ шейдере — текстуры нет, поэтому он чёткий на любом зуме и не занимает места в атласе
1447
+ (у MapGL для этого своя функция `sdf_chevron`). Масштаб рисунка задаётся один на тайл:
1448
+ если считать по экранной длине каждого отрезка, при наклоне соседние отрезки получат
1449
+ разный масштаб и пунктир порвётся на стыках.
1450
+
1451
+ **Линии рисуются «капсулами»**: на каждый отрезок — четырёхугольник, раздутый на
1452
+ пол-ширины во все стороны, а точную форму вырезает фрагментный шейдер по расстоянию до
1453
+ отрезка. Отсюда бесплатно берутся круглые стыки, круглые концы и аналитическое
1454
+ сглаживание края. Так сделано вместо угловых стыков (miter), с которых начинали: там на
1455
+ концах дорог оставались рубленые торцы, а на перекрёстках зияли выемки. Раздувание
1456
+ считается ПОСЛЕ проекции, в экранных пикселях — иначе при наклонённой камере ближние
1457
+ дороги раздувало бы, а дальние истончало.
1458
+
1459
+ ## Проверка
1460
+
1461
+ ```bash
1462
+ npm run typecheck
1463
+ npm test # 166 тестов: геометрия, стиль, темы, подписи, объекты, этажи, тени, небо, конвейер
1464
+ npm run demo # в отдельном окне
1465
+ node scripts/smoke.mjs # рендер в headless-браузере, 4 ракурса
1466
+ ```
1467
+
1468
+ `npm test` включает сквозной прогон на **настоящем** тайле из `data/tiles/tajikistan.mbtiles`
1469
+ (разбор MVT → экструзия → проверка намотки и нормалей). Синтетика тут бесполезна: ошибки
1470
+ вылезают именно на реальных данных — дырки в полигонах, высоты строкой, незамкнутые кольца.
1471
+ Если файла нет, эти тесты помечаются пропущенными, а не падают.
1472
+
1473
+ `scripts/smoke.mjs` гоняет **несколько ракурсов**, и это не перестраховка: баг с
1474
+ `ELEMENT_ARRAY_BUFFER` (см. ниже) проявлялся только на одиночном тайле — при нескольких
1475
+ тайлах часть рисовалась, и карта выглядела рабочей.
1476
+
1477
+ Он же проверяет **единство цвета иконки и текста**: слой POI красится в чистый красный,
1478
+ зелёный и синий, и от всех закрашенных пикселей требуется попасть в целевой ТОН
1479
+ (соотношение каналов, а не яркость — у сглаженного края буквы та же краска, но меньшая
1480
+ интенсивность). Ломается это требование тихо: достаточно разойтись кодировкам SDF,
1481
+ и картинка ещё будет похожа на правду.
1482
+
1483
+ ## Грабли, на которые уже наступили
1484
+
1485
+ - **`ELEMENT_ARRAY_BUFFER` — состояние VAO, а не глобальное.** Создание индексного буфера
1486
+ соседнего тайла, пока привязан VAO предыдущего, молча переписывало тому привязку: тайл
1487
+ начинал рисовать чужими индексами по своим вершинам. Ошибок GL нет, кадр не падает — тайл
1488
+ просто исчезает. Лечится тем, что буферы создаются только со снятым VAO (`gl/buffer.ts`).
1489
+ - **Экспорт класса с именем `Map`.** `const { Map } = OsmGL` в глобальном скрипте затеняет
1490
+ встроенный `Map`, и `Evented` начинает бесконечно строить наш класс → переполнение стека
1491
+ в минифицированном коде. Лечится захватом встроенных конструкторов при загрузке бандла
1492
+ (`core/natives.ts`).
1493
+ - **Шаблон тайлов и `new URL()`.** Приводить адрес к абсолютному нужно ПОСЛЕ подстановки
1494
+ `{z}/{x}/{y}`: иначе скобки кодируются в `%7B`/`%7D` и подстановка перестаёт совпадать.
1495
+ Абсолютный адрес нужен потому, что воркер поднят из Blob URL и относительные пути
1496
+ относительно `blob:` не разбираются.
1497
+ - **`readPixels` для проверки кадра.** При `preserveDrawingBuffer:false` буфер после
1498
+ композитинга пуст, и исправный движок читается как чёрный кадр. Проверять только по
1499
+ скриншоту.
1500
+ - **Намотка стен: пространство тайла ЛЕВОСТОРОННЕЕ** (x на восток, y на юг, z вверх), да
1501
+ ещё проекция переворачивает Y. При «интуитивном» обходе лицевой в NDC оказывается стена,
1502
+ ОТВЁРНУТАЯ от камеры: backface-culling срезал ближние стены, и дома выглядели пустыми
1503
+ коробками — было видно внутреннюю поверхность дальней стены. Обход стен — `bl,tr,br`.
1504
+ Проверять правосторонним векторным произведением тут НЕЛЬЗЯ: оно даёт вектор,
1505
+ противоположный внешней нормали, и тест «зеленел» на вывернутой намотке.
1506
+ - **Капсула линии скручивалась бабочкой.** Перпендикуляр считался от направления
1507
+ «на соседний конец», а на дальнем конце оно противоположно — углы квада перекрещивались,
1508
+ и дороги превращались в цепочку линз. Концы отрезка хранятся канонически (p0, p1
1509
+ одинаковы у всех четырёх вершин).
1510
+ - **`gl_FragCoord` — в пикселях БУФЕРА, а стиль — в CSS-пикселях.** Ширину линии надо
1511
+ домножать на плотность, иначе на экране с dpr=2 дороги вдвое тоньше.
1512
+ - **`sampler2DShadow` требует сравнивающей текстуры ВСЕГДА**, даже когда шейдер до
1513
+ выборки не доходит. Без неё текстура для этого типа сэмплера «неполна», поведение не
1514
+ определено, и слой молча не рисует ничего — карта осталась с одними подписями, БЕЗ
1515
+ единой ошибки GL. Лечится заглушкой 1×1.
1516
+ - **Юниформ `u_shadow_map` надо ставить каждой программе.** Забыли в проходе зданий —
1517
+ сэмплер остался на блоке 0 с атласом подписей, и здания перестали рисоваться с
1518
+ `INVALID_OPERATION`. Видно это только через `gl.getError()`.
1519
+ - **Номера атрибутов у разных программ разные.** VAO привязывает буфер к НОМЕРУ, а не
1520
+ к имени, поэтому общий меш зданий и прохода глубины подавал бы вершины не в те
1521
+ атрибуты. Лечится `bindAttribLocation` до линковки.
1522
+ - **Стиль правил ОБЩИЙ образец.** `Style` держал ссылки на слои спецификации, а встроенный
1523
+ стиль — модуль, один на весь бандл. `setLayerVisible`/`setPaintProperty` правили слой на
1524
+ месте, то есть сам `DEFAULT_STYLE`. Проявлялось отложенно и совершенно непонятно: слой,
1525
+ когда-то выключенный, ПРОПАДАЛ навсегда после следующего `setTheme`, потому что тема
1526
+ строилась из уже испорченного образца. Нашёл дымовой тест — планы этажей перестали
1527
+ строиться после прогона проверки цветов, которая гасит все слои кроме одного.
1528
+ - **`visible: false` фильтровался только при разборе стиля**, а `layerVisibleAt` его не
1529
+ проверял — `setLayerVisible` молча ничего не выключал.
1530
+ - **Подписи не появлялись из-за курицы и яйца.** На первом кадре глифов ещё нет, значит
1531
+ не размещается ничего, `quads === 0` — и ранний выход не давал уйти запросу на догрузку
1532
+ шрифтов. Запрос теперь отправляется ДО проверки на пустоту.
1533
+ - **Край SDF на бинарной маске.** У маски без сглаживания пикселя с нулевым расстоянием не
1534
+ существует: соседние дают 223 и 159, а граница 192 проходит МЕЖДУ ними. Тест должен
1535
+ проверять пересечение, а не значение в одном пикселе.
1536
+
1537
+ ## Состояние
1538
+
1539
+ Готово: камера и жесты, конвейер тайлов с воркерами и LRU (несколько источников), разбор
1540
+ MVT, стиль с фильтрами и интерполяцией по зуму, заливки, линии-капсулы со скруглениями
1541
+ (пунктир, стрелки, смещение), слой 3D-зданий (экструзия, крыши, дырки, AO, разброс
1542
+ оттенка, свет ambient + key + fill, анимация появления), подписи и SVG-иконки в общем
1543
+ SDF-атласе с разрешением коллизий, четыре темы, отсечение по пирамиде видимости с LOD.
1544
+
1545
+ Плюс рантайм-объекты приложения (маршруты, зоны, окружности, DOM-маркеры) с попаданием
1546
+ по клику, планы этажей с переключателем, тени от зданий и небо с дымкой.
1547
+
1548
+ Дальше по очереди: пикинг по тайловым фичам (здания и POI — там уже через GPU), подписи
1549
+ ВДОЛЬ линии (сейчас улица подписывается горизонтально в середине самого длинного звена),
1550
+ GeoJSON-источник, DEM, glTF-модели. Постпроцессное сглаживание не планируется: контекст
1551
+ и так с MSAA 4×. Полный разбор того, что осталось перенести, — в [PORTING.md](PORTING.md)
1552
+ и по подсистемам в [porting/](porting/).