Initial commit: browser OSM city generator
This commit is contained in:
commit
2d4bad0dc7
32 files changed
+5915
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
world.json
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Город сверху — генератор карты по OpenStreetMap (MVP)
|
||||||
|
|
||||||
|
Выделяете на карте небольшой город или район → по данным OSM строится подробная карта
|
||||||
|
с видом строго сверху: дороги реальной ширины с тротуарами и разметкой, парковки
|
||||||
|
с машинами, деревья, и **у каждого здания — этажи, комнаты, двери, окна, лестницы
|
||||||
|
и мебель**, нарисованные простыми геометрическими фигурами, как на плане квартиры.
|
||||||
|
|
||||||
|
Всё работает в браузере, без сервера: данные берутся из Overpass API, генерация
|
||||||
|
идёт в Web Worker'ах, готовый мир сохраняется в IndexedDB.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Нужен Node.js 18+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173/ (пересборка на лету)
|
||||||
|
npm run build # сборка в dist/ — можно положить на любой статический хостинг
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Без интернета можно проверить на образце: «Другие способы» → «Сгенерировать из выгрузки
|
||||||
|
Overpass» → `samples/balzers-2013.json` (Бальцерс, Лихтенштейн, данные OSM 2013 года).
|
||||||
|
|
||||||
|
Генерация из файла без браузера (для отладки генератора):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cli -- samples/balzers-2013.json 47.058 9.492 47.074 9.518 world.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Управление
|
||||||
|
|
||||||
|
- перетаскивание — сдвиг, колесо / щипок — масштаб, двойной клик — приблизить;
|
||||||
|
- справа — этаж для всей карты (⌂ — крыши), `PgUp`/`PgDn` или `[` `]`;
|
||||||
|
- клик по зданию — карточка: вид, этажность (из OSM или оценка), квартиры, спальные места,
|
||||||
|
свой переключатель этажей только для этого здания;
|
||||||
|
- «Экспорт мира» — весь мир одним файлом `.json.gz` (будущий формат для игры).
|
||||||
|
|
||||||
|
## Как устроена генерация
|
||||||
|
|
||||||
|
```
|
||||||
|
Overpass JSON ─► city.ts ──────────────► зоны, дороги, контуры зданий, деревья, парковки
|
||||||
|
│ classify.ts вид здания (по тегам, POI внутри, landuse, площади) и этажность
|
||||||
|
▼
|
||||||
|
interior.ts (в воркерах) ─► этажи: сетка 0.5 м в осях здания
|
||||||
|
│ схема: секции с подъездами | коридор | простая раскладка
|
||||||
|
│ деление на комнаты по программе (м²), несколько попыток с оценкой
|
||||||
|
│ двери = остовное дерево по графу смежности, санузлы и спальни — тупики
|
||||||
|
│ вход с фасада к дороге, окна с шагом по типу здания
|
||||||
|
▼
|
||||||
|
furnish.ts + catalog.ts ──► мебель по правилам для каждого типа комнаты
|
||||||
|
▼
|
||||||
|
finalize.ts ──────────────► общие модели предметов, округление до 1 см
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Сид** здания = OSM-id ⊕ сид мира — один и тот же город всегда генерируется одинаково.
|
||||||
|
- Многоквартирные дома — **секционные**: подъезд с лестницей (и лифтом от 5 этажей),
|
||||||
|
площадка, 1–4 квартиры вокруг неё; на первом этаже — проход к входу, у домов с магазинами
|
||||||
|
вместо квартир магазины со своими входами с улицы.
|
||||||
|
- Офисы, школы, больницы, гостиницы — **коридорная** схема.
|
||||||
|
- Дома, магазины, кафе, цеха, склады, храмы, гаражи — **простая** раскладка
|
||||||
|
(у гаражных рядов — отдельные боксы с воротами).
|
||||||
|
|
||||||
|
## Модель данных мира (`src/types.ts`)
|
||||||
|
|
||||||
|
Заложено под будущую симуляцию:
|
||||||
|
|
||||||
|
| Что | Зачем потом |
|
||||||
|
|---|---|
|
||||||
|
| `Door.rooms = [комната, комната \| 'outside']` | рёбра графа навигации внутри здания |
|
||||||
|
| `Stairs` (одно место на всех этажах, `up`/`down`), лифты | переходы между этажами |
|
||||||
|
| `Unit` — квартира / магазин / офис с `roomIds` и `beds` | домохозяйства и рабочие места для жителей |
|
||||||
|
| `Prop.blocksMove`, `Prop.blocksSight`, `tags` (`sleep:2`, `sit:4`, `work`, `cook`, `storage`…) | коллизии, линия обзора, что агент может сделать с предметом |
|
||||||
|
| `Wall` с разрывами под двери и окна, `Window` | коллизии и видимость (окна прозрачны для взгляда) |
|
||||||
|
| `Road` с шириной, тротуарами, `oneway`, слоями | граф улиц для перемещения по городу |
|
||||||
|
| все объекты с `id` | хранение состояния: открытая дверь, взятый предмет |
|
||||||
|
|
||||||
|
Координаты — метры в локальной проекции вокруг центра области (x — восток, y — юг).
|
||||||
|
|
||||||
|
## Ограничения MVP
|
||||||
|
|
||||||
|
- Область до 6 км². Публичные серверы Overpass рассчитаны на умеренную нагрузку; для
|
||||||
|
публичной игры понадобится своё зеркало или заранее нарезанные данные (например, экстракт
|
||||||
|
Geofabrik → статические файлы).
|
||||||
|
- Подвалы, мансарды, балконы, береговая линия морей пока не генерируются.
|
||||||
|
- Нестандартные контуры (сильно изломанные) дают менее аккуратные планировки.
|
||||||
|
|
||||||
|
## Данные и лицензии
|
||||||
|
|
||||||
|
Данные карты © участники OpenStreetMap, лицензия ODbL. Если вы распространяете
|
||||||
|
сгенерированные миры, укажите «© OpenStreetMap contributors». Тайлы на экране выбора
|
||||||
|
области — стандартные тайлы OSM по их [правилам использования](https://operations.osmfoundation.org/policies/tiles/);
|
||||||
|
поиск — Nominatim (не чаще раза в секунду).
|
||||||
|
|
||||||
|
Идеи частично подсказаны проектом [KnoxMap](https://github.com/spytheeuclidean-a11y/knoxmap);
|
||||||
|
код написан с нуля.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Сборка: node build.mjs [--serve] [--port 5173]
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
import { cpSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const portArg = process.argv.indexOf('--port');
|
||||||
|
const port = portArg > 0 ? +process.argv[portArg + 1] : 5173;
|
||||||
|
|
||||||
|
mkdirSync('dist', { recursive: true });
|
||||||
|
cpSync('public', 'dist', { recursive: true });
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
entryPoints: { main: 'src/main.ts', worker: 'src/gen/worker.ts' },
|
||||||
|
outdir: 'dist',
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
target: 'es2022',
|
||||||
|
sourcemap: true,
|
||||||
|
minify: !serve,
|
||||||
|
logLevel: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
const ctx = await esbuild.context(options);
|
||||||
|
await ctx.watch();
|
||||||
|
const { host } = await ctx.serve({ servedir: 'dist', port });
|
||||||
|
console.log(`\n Открыть: http://localhost:${port}/\n`);
|
||||||
|
void host;
|
||||||
|
} else {
|
||||||
|
await esbuild.build(options);
|
||||||
|
}
|
||||||
Generated
+549
@@ -0,0 +1,549 @@
|
|||||||
|
{
|
||||||
|
"name": "citygen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "citygen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||||
|
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "citygen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Генератор подробной карты города по данным OpenStreetMap: планировки, этажи и мебель простыми фигурами, вид сверху.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node build.mjs --serve",
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"typecheck": "tsc -p .",
|
||||||
|
"cli": "tsx scripts/cli.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Город сверху</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ebe7de;
|
||||||
|
--card: rgba(252, 250, 246, 0.96);
|
||||||
|
--ink: #2f2c28;
|
||||||
|
--muted: #7a736a;
|
||||||
|
--line: #d8d2c7;
|
||||||
|
--accent: #e8590c;
|
||||||
|
--accent-ink: #fff;
|
||||||
|
--ok: #2f7d32;
|
||||||
|
--warn: #b26a00;
|
||||||
|
--bad: #c62828;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
||||||
|
#app { position: fixed; inset: 0; }
|
||||||
|
button, input { font: inherit; color: inherit; }
|
||||||
|
a { color: #8a4b1f; }
|
||||||
|
code { font-size: 12px; background: #eee8de; padding: 1px 4px; border-radius: 4px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 24px rgba(40, 30, 20, 0.12);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #b9b0a2; }
|
||||||
|
.btn.on { background: #fde8dc; border-color: var(--accent); color: #9a3a06; }
|
||||||
|
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; padding: 10px 14px; font-size: 15px; }
|
||||||
|
.btn.primary:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.btn.file { display: block; text-align: left; }
|
||||||
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.col { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||||
|
.hint { font-size: 12px; color: var(--muted); }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; user-select: none; }
|
||||||
|
.attribution { position: absolute; right: 6px; bottom: 4px; font-size: 11px; color: #555; background: rgba(255, 255, 255, 0.8); padding: 1px 6px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* ---------- выбор области */
|
||||||
|
.picker-stage { position: absolute; inset: 0; }
|
||||||
|
.map-box { position: absolute; inset: 0; touch-action: none; }
|
||||||
|
.map-canvas { display: block; cursor: grab; touch-action: none; }
|
||||||
|
.picker-panel {
|
||||||
|
position: absolute; z-index: 2; top: 16px; left: 16px; width: min(380px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 32px); overflow: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 20px; letter-spacing: 0.2px; }
|
||||||
|
.lead { margin: 0; font-size: 13.5px; color: #514b43; line-height: 1.4; }
|
||||||
|
.search { display: flex; gap: 6px; }
|
||||||
|
.search input, .field input { font-size: 14px; flex: 1; min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; background: #fff; }
|
||||||
|
.results { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.result { text-align: left; border: 0; background: #f4f0e9; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-size: 12.5px; }
|
||||||
|
.result:hover { background: #ece5da; }
|
||||||
|
.sel-info { font-size: 13px; padding: 6px 8px; border-radius: 6px; background: #f4f0e9; }
|
||||||
|
.sel-info.ok { color: var(--ok); }
|
||||||
|
.sel-info.warn { color: var(--warn); }
|
||||||
|
.sel-info.bad { color: var(--bad); }
|
||||||
|
.field { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
|
||||||
|
.more summary { cursor: pointer; font-size: 13px; color: var(--muted); }
|
||||||
|
.err { color: var(--bad); font-size: 13px; background: #fdecea; border-radius: 6px; padding: 8px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ---------- прогресс */
|
||||||
|
.overlay { position: fixed; inset: 0; z-index: 50; background: rgba(30, 26, 22, 0.35); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.overlay[hidden] { display: none; }
|
||||||
|
.progress-card { width: min(420px, calc(100vw - 32px)); padding: 18px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.p-title { font-weight: 700; }
|
||||||
|
.p-text { font-size: 13px; color: #514b43; min-height: 1.3em; }
|
||||||
|
.bar { height: 8px; border-radius: 99px; background: #e8e2d8; overflow: hidden; }
|
||||||
|
.bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.2s; }
|
||||||
|
.progress-card .btn { align-self: flex-end; }
|
||||||
|
|
||||||
|
/* ---------- просмотр */
|
||||||
|
.viewer { position: absolute; inset: 0; }
|
||||||
|
.world-canvas { display: block; touch-action: none; cursor: grab; }
|
||||||
|
.world-canvas:active { cursor: grabbing; }
|
||||||
|
.viewer-top { position: absolute; top: 12px; left: 12px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; max-width: min(640px, calc(100vw - 100px)); }
|
||||||
|
.viewer-top .title { font-weight: 700; font-size: 16px; }
|
||||||
|
.viewer-top .stats { font-size: 12px; color: var(--muted); }
|
||||||
|
.floor-bar { position: absolute; right: 12px; top: 12px; padding: 8px 6px; display: flex; flex-direction: column; align-items: center; gap: 6px; max-height: calc(100vh - 60px); }
|
||||||
|
.floor-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.floor-list { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
|
||||||
|
.floor-btn { width: 36px; height: 30px; border: 1px solid var(--line); background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
.floor-btn.on { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||||
|
.floor-btn.sm { width: 30px; height: 26px; font-size: 12px; }
|
||||||
|
.floor-hint { font-size: 10px; color: var(--muted); }
|
||||||
|
.info { position: absolute; left: 12px; bottom: 36px; width: min(320px, calc(100vw - 90px)); padding: 12px; font-size: 13px; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.info[hidden] { display: none; }
|
||||||
|
.info .title { font-weight: 700; font-size: 15px; padding-right: 20px; }
|
||||||
|
.info .sub { color: var(--muted); font-size: 12px; }
|
||||||
|
.info .kv { display: flex; gap: 6px; align-items: baseline; }
|
||||||
|
.info .kv span { color: var(--muted); flex: 1; }
|
||||||
|
.info .kv i { color: var(--muted); font-size: 11px; }
|
||||||
|
.info .rooms { font-size: 12px; color: #514b43; }
|
||||||
|
.info .links { display: flex; gap: 12px; font-size: 12px; }
|
||||||
|
.info .close { position: absolute; top: 6px; right: 8px; border: 0; background: none; font-size: 20px; cursor: pointer; color: var(--muted); }
|
||||||
|
.floor-pick { display: flex; flex-wrap: wrap; gap: 3px; margin: 4px 0; }
|
||||||
|
.scale { position: absolute; left: 12px; bottom: 10px; height: 18px; border: 2px solid #3e3a35; border-top: 0; font-size: 11px; text-align: center; color: #3e3a35; background: rgba(255, 255, 255, 0.55); line-height: 16px; min-width: 30px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.viewer-top .stats { display: none; }
|
||||||
|
.picker-panel { top: auto; bottom: 8px; left: 8px; max-height: 55vh; }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"elements":[{"type":"node","id":525,"lat":47.0730496,"lon":9.5090656,"tags":{"highway":"crossing"}},{"type":"node","id":543,"lat":47.0645085,"lon":9.4989914,"tags":{"created_by":"JOSM"}},{"type":"node","id":544,"lat":47.0643891,"lon":9.4990192,"tags":{"created_by":"JOSM"}},{"type":"node","id":545,"lat":47.0642406,"lon":9.4990026,"tags":{"created_by":"JOSM"}},{"type":"node","id":546,"lat":47.0639765,"lon":9.4987881,"tags":{"created_by":"JOSM"}},{"type":"node","id":547,"lat":47.0634813,"lon":9.4986065,"tags":{"created_by":"JOSM"}},{"type":"node","id":548,"lat":47.0626962,"lon":9.4985,"tags":{"created_by":"JOSM"}},{"type":"node","id":549,"lat":47.0634309,"lon":9.4983774,"tags":{"amenity":"parking","created_by":"Potlatch 0.4b"}},{"type":"node","id":550,"lat":47.0623698,"lon":9.5003127,"tags":{"created_by":"JOSM"}},{"type":"node","id":552,"lat":47.0633096,"lon":9.498554,"tags":{"created_by":"JOSM"}},{"type":"node","id":553,"lat":47.0633822,"lon":9.4987468,"tags":{"created_by":"JOSM"}},{"type":"node","id":554,"lat":47.0637454,"lon":9.4989449,"tags":{"created_by":"JOSM"}},{"type":"node","id":555,"lat":47.0638841,"lon":9.4990604,"tags":{"created_by":"JOSM"}},{"type":"node","id":556,"lat":47.0638908,"lon":9.4992448,"tags":{"created_by":"JOSM"}},{"type":"node","id":557,"lat":47.0641152,"lon":9.4993838,"tags":{"created_by":"JOSM"}},{"type":"node","id":558,"lat":47.0641768,"lon":9.4996996,"tags":{"created_by":"JOSM"}},{"type":"node","id":559,"lat":47.0642557,"lon":9.5000786,"tags":{"created_by":"JOSM"}},{"type":"node","id":560,"lat":47.0644189,"lon":9.5008578,"tags":{"created_by":"JOSM"}},{"type":"node","id":561,"lat":47.0645453,"lon":9.5009157,"tags":{"created_by":"JOSM"}},{"type":"node","id":562,"lat":47.0648085,"lon":9.5009367,"tags":{"created_by":"JOSM"}},{"type":"node","id":567,"lat":47.0647137,"lon":9.500226,"tags":{"created_by":"JOSM"}},{"type":"node","id":568,"lat":47.0648769,"lon":9.5002523,"tags":{"created_by":"JOSM"}},{"type":"node","id":572,"lat":47.0651353,"lon":9.5007185,"tags":{"barrier":"entrance","historic":"castle","name":"Schloss Gutenberg"}},{"type":"node","id":700,"lat":47.0672684,"lon":9.5000348,"tags":{"created_by":"Potlatch 0.10f","openGeoDB:auto_update":"population,place,name,is_in","openGeoDB:community_identification_number":"G7003","openGeoDB:is_in":"Schweiz,Europe","openGeoDB:is_in_loc_id":"107","openGeoDB:layer":"6","openGeoDB:location":"political_structure","openGeoDB:loc_id":"79997","openGeoDB:name":"Balzers","openGeoDB:postal_codes":"9496","openGeoDB:sort_name":"BALZERS","openGeoDB:type":"ausländisches Areal Liechtenstein","openGeoDB:version":"0.2.6.11 / 2007-12-04 / http://fa-technik.adfc.de/code/opengeodb/dump/"}},{"type":"node","id":701,"lat":47.0666667,"lon":9.5,"tags":{"created_by":"opengeodb2osm0.5.2","is_in":"Liechtenstein,Europe","name":"Balzers","openGeoDB:auto_update":"population,place,name,is_in","openGeoDB:is_in":"Liechtenstein,Europe","openGeoDB:is_in_loc_id":"35165","openGeoDB:layer":"6","openGeoDB:location":"political_structure","openGeoDB:loc_id":"32383","openGeoDB:name":"Balzers","openGeoDB:postal_codes":"9496","openGeoDB:type":"100600000","openGeoDB:version":"0.2.6.11 / 2007-12-04 / http://fa-technik.adfc.de/code/opengeodb/dump/","place":"village"}},{"type":"node","id":864,"lat":47.0644048,"lon":9.5081998,"tags":{"highway":"crossing"}},{"type":"node","id":5094,"lat":47.0667802,"lon":9.4972271,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Schlossweg","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Schlossweg","uic_ref":"8574408","wheelchair":"yes"}},{"type":"node","id":5096,"lat":47.0615226,"lon":9.4977689,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Mälsnerdorf","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Mälsnerdorf","uic_ref":"8574405"}},{"type":"node","id":5099,"lat":47.063698,"lon":9.503314,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Rietstrasse","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Post","uic_ref":"8509611"}},{"type":"node","id":5107,"lat":47.0669658,"lon":9.5074143,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Höfle","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Hotel Post","uic_ref":"8574404"}},{"type":"node","id":5110,"lat":47.069404,"lon":9.507005,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Alter Pfarrhof","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Alter Friedhof","uic_ref":"8574403"}},{"type":"node","id":6195,"lat":47.066597,"lon":9.497576,"tags":{"bus":"yes","ele":"474","highway":"bus_stop","is_in":"Balzers","name":"Schlossweg","operator":"LBA","public_transport":"platform","uic_name":"Balzers, Schlossweg","uic_ref":"8574408","wheelchair":"yes"}},{"type":"node","id":6196,"lat":47.0634335,"lon":9.4922517,"tags":{"bus":"yes","highway":"bus_stop","is_in":"Balzers","nameLine truncated
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Генерация мира из файла Overpass JSON без браузера.
|
||||||
|
// npx tsx scripts/cli.ts input.json south west north east [out.json]
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { generateWorldSync } from '../src/gen/generate';
|
||||||
|
|
||||||
|
const [inp, s, w, n, e, out] = process.argv.slice(2);
|
||||||
|
if (!inp || !e) {
|
||||||
|
console.error('usage: tsx scripts/cli.ts input.json south west north east [out.json]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const els = JSON.parse(readFileSync(inp, 'utf8')).elements;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const world = generateWorldSync(els, [+s, +w, +n, +e], inp, 1);
|
||||||
|
const dt = Date.now() - t0;
|
||||||
|
|
||||||
|
const kinds: Record<string, number> = {};
|
||||||
|
const roomKinds: Record<string, number> = {};
|
||||||
|
let floors = 0, rooms = 0, props = 0, doors = 0, windows = 0, noEntrance = 0, units = 0, beds = 0;
|
||||||
|
for (const b of world.buildings) {
|
||||||
|
kinds[b.kind] = (kinds[b.kind] ?? 0) + 1;
|
||||||
|
floors += b.floors.length;
|
||||||
|
units += b.units.length;
|
||||||
|
beds += b.beds;
|
||||||
|
for (const f of b.floors) {
|
||||||
|
rooms += f.rooms.length;
|
||||||
|
props += f.props.length;
|
||||||
|
doors += f.doors.length;
|
||||||
|
windows += f.windows.length;
|
||||||
|
for (const r of f.rooms) roomKinds[r.kind] = (roomKinds[r.kind] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
if (b.floors[0] && !b.floors[0].doors.some((d) => d.rooms[1] === 'outside')) noEntrance++;
|
||||||
|
}
|
||||||
|
console.log(`${dt} ms; buildings ${world.buildings.length}, areas ${world.areas.length}, roads ${world.roads.length}, street props ${world.props.length}`);
|
||||||
|
console.log('kinds', kinds);
|
||||||
|
console.log(`floors ${floors}, rooms ${rooms}, doors ${doors}, windows ${windows}, furniture ${props}, units ${units}, beds ${beds}, no entrance ${noEntrance}`);
|
||||||
|
console.log('rooms', roomKinds);
|
||||||
|
const json = JSON.stringify(world);
|
||||||
|
console.log(`world json ${(json.length / 1e6).toFixed(1)} MB`);
|
||||||
|
if (out) writeFileSync(out, json);
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Каталог мебели: каждый предмет — набор простых фигур.
|
||||||
|
// Локальные оси предмета: x — вдоль ширины, y — в глубину; y < 0 — «спинка» (к стене), y > 0 — лицевая сторона.
|
||||||
|
|
||||||
|
import type { Shape } from '../types';
|
||||||
|
|
||||||
|
export interface Def {
|
||||||
|
kind: string;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
shapes: Shape[];
|
||||||
|
/** Высокий предмет: не ставим перед окнами, закрывает обзор. */
|
||||||
|
tall?: boolean;
|
||||||
|
blocksMove?: boolean;
|
||||||
|
/** Сколько свободного места нужно перед предметом (м). */
|
||||||
|
front?: number;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const R = (x: number, y: number, w: number, h: number, s: Shape['s'] = 'base', r?: number): Shape => ({ t: 'rect', x, y, w, h, s, r });
|
||||||
|
const O = (x: number, y: number, r: number, s: Shape['s'] = 'base'): Shape => ({ t: 'circle', x, y, r, s });
|
||||||
|
const Ln = (x1: number, y1: number, x2: number, y2: number, s: Shape['s'] = 'line'): Shape => ({ t: 'line', x1, y1, x2, y2, s });
|
||||||
|
|
||||||
|
export function bed(double: boolean): Def {
|
||||||
|
const w = double ? 1.6 : 0.9, d = 2.0;
|
||||||
|
const shapes: Shape[] = [R(0, 0, w, d, 'wood', 0.05), R(0, 0.05, w - 0.1, d - 0.15, 'soft', 0.06)];
|
||||||
|
if (double) shapes.push(R(-0.38, -d / 2 + 0.3, 0.62, 0.34, 'base', 0.08), R(0.38, -d / 2 + 0.3, 0.62, 0.34, 'base', 0.08));
|
||||||
|
else shapes.push(R(0, -d / 2 + 0.3, 0.66, 0.34, 'base', 0.08));
|
||||||
|
shapes.push(R(0, 0.32, w - 0.08, d * 0.58, 'fabric', 0.05));
|
||||||
|
return { kind: double ? 'bed_double' : 'bed_single', w, d, shapes, front: 0.5, tags: [`sleep:${double ? 2 : 1}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bedWithNightstands(): Def {
|
||||||
|
const b = bed(true);
|
||||||
|
const w = 1.6 + 2 * 0.5;
|
||||||
|
const shapes = [...b.shapes];
|
||||||
|
for (const sx of [-1, 1]) shapes.push(R(sx * (0.8 + 0.25), -0.78, 0.44, 0.4, 'wood', 0.03), O(sx * (0.8 + 0.25), -0.78, 0.1, 'accent'));
|
||||||
|
return { ...b, kind: 'bed_double', w, shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hospitalBed = (): Def => ({
|
||||||
|
kind: 'hospital_bed', w: 1.0, d: 2.1, front: 0.6, tags: ['sleep:1'],
|
||||||
|
shapes: [R(0, 0, 1.0, 2.1, 'metal', 0.06), R(0, 0.05, 0.86, 1.9, 'soft', 0.05), R(0, -0.75, 0.6, 0.3, 'base', 0.08), Ln(-0.47, -0.5, -0.47, 0.6, 'dark'), Ln(0.47, -0.5, 0.47, 0.6, 'dark')],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nightstand = (): Def => ({ kind: 'nightstand', w: 0.45, d: 0.4, shapes: [R(0, 0, 0.45, 0.4, 'wood', 0.03), O(0, 0, 0.1, 'accent')] });
|
||||||
|
|
||||||
|
export const wardrobe = (w = 1.2): Def => ({
|
||||||
|
kind: 'wardrobe', w, d: 0.6, tall: true, front: 0.6, tags: ['storage'],
|
||||||
|
shapes: [R(0, 0, w, 0.6, 'wood', 0.02), Ln(0, -0.28, 0, 0.28), Ln(-w / 2 + 0.05, 0.27, w / 2 - 0.05, 0.27)],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const desk = (w = 1.2): Def => ({
|
||||||
|
kind: 'desk', w, d: 1.15, front: 0.2, tags: ['work', 'sit:1'],
|
||||||
|
shapes: [R(0, -0.27, w, 0.6, 'wood', 0.03), R(-w / 2 + 0.25, -0.35, 0.36, 0.25, 'dark', 0.02), O(0, 0.25, 0.24, 'dark')],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sofa = (w = 2.0): Def => ({
|
||||||
|
kind: 'sofa', w, d: 0.9, front: 0.4, tags: [`sit:${Math.round(w / 0.65)}`],
|
||||||
|
shapes: [R(0, 0, w, 0.9, 'fabric', 0.12), R(0, -0.33, w - 0.1, 0.22, 'soft', 0.08), R(-w / 2 + 0.1, 0.05, 0.18, 0.78, 'soft', 0.06), R(w / 2 - 0.1, 0.05, 0.18, 0.78, 'soft', 0.06), Ln(-w / 6, -0.2, -w / 6, 0.4), Ln(w / 6, -0.2, w / 6, 0.4)],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function sofaSet(): Def {
|
||||||
|
const s = sofa(2.1);
|
||||||
|
const shapes = s.shapes.map((sh) => shift(sh, 0, -0.55));
|
||||||
|
shapes.push(R(0, 0.6, 1.05, 0.55, 'wood', 0.06), R(0, 0.3, 2.4, 1.5, 'accent', 0.1));
|
||||||
|
// ковёр под набором — первым, чтобы был снизу
|
||||||
|
shapes.unshift(shapes.pop()!);
|
||||||
|
return { kind: 'sofa_set', w: 2.4, d: 2.0, shapes, front: 0.3, tags: s.tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const armchair = (): Def => ({
|
||||||
|
kind: 'armchair', w: 0.85, d: 0.85, tags: ['sit:1'],
|
||||||
|
shapes: [R(0, 0, 0.85, 0.85, 'fabric', 0.15), R(0, -0.3, 0.75, 0.2, 'soft', 0.08), R(0, 0.08, 0.5, 0.5, 'soft', 0.1)],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tvStand = (): Def => ({ kind: 'tv', w: 1.6, d: 0.45, front: 1.2, shapes: [R(0, 0, 1.6, 0.45, 'wood', 0.03), R(0, -0.08, 1.2, 0.07, 'dark')] });
|
||||||
|
export const bookshelf = (w = 1.0): Def => ({ kind: 'bookshelf', w, d: 0.35, tall: true, front: 0.5, shapes: [R(0, 0, w, 0.35, 'wood', 0.02), Ln(-w / 2 + 0.05, 0, w / 2 - 0.05, 0)] });
|
||||||
|
export const plant = (): Def => ({ kind: 'plant', w: 0.5, d: 0.5, blocksMove: true, shapes: [O(0, 0, 0.22, 'base'), O(0, 0, 0.26, 'green')] });
|
||||||
|
|
||||||
|
export function diningTable(seats: 4 | 6): Def {
|
||||||
|
const tw = seats === 4 ? 1.2 : 1.8, th = 0.85;
|
||||||
|
const shapes: Shape[] = [];
|
||||||
|
const n = seats / 2;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = -tw / 2 + (tw / n) * (i + 0.5);
|
||||||
|
shapes.push(R(x, -th / 2 - 0.3, 0.42, 0.42, 'wood', 0.06), R(x, th / 2 + 0.3, 0.42, 0.42, 'wood', 0.06));
|
||||||
|
}
|
||||||
|
shapes.push(R(0, 0, tw, th, 'wood', 0.04));
|
||||||
|
return { kind: 'dining_table', w: tw + 0.2, d: th + 1.2, shapes, tags: [`sit:${seats}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kitchenLine(L: number): Def {
|
||||||
|
const shapes: Shape[] = [R(0, 0, L, 0.62, 'base', 0.02)];
|
||||||
|
// плита, мойка, разделочные зоны
|
||||||
|
const stoveX = -L / 2 + Math.min(L * 0.3, 0.9);
|
||||||
|
shapes.push(R(stoveX, 0, 0.6, 0.56, 'dark', 0.03));
|
||||||
|
for (const [dx, dy] of [[-0.14, -0.13], [0.14, -0.13], [-0.14, 0.13], [0.14, 0.13]]) shapes.push(O(stoveX + dx, dy, 0.09, 'metal'));
|
||||||
|
const sinkX = L / 2 - Math.min(L * 0.3, 0.75);
|
||||||
|
shapes.push(R(sinkX, 0.02, 0.6, 0.44, 'metal', 0.06), O(sinkX + 0.12, 0.02, 0.05, 'dark'));
|
||||||
|
return { kind: 'kitchen', w: L, d: 0.62, front: 0.9, shapes, tags: ['cook'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fridge = (): Def => ({ kind: 'fridge', w: 0.62, d: 0.66, tall: true, front: 0.7, shapes: [R(0, 0, 0.62, 0.66, 'base', 0.04), Ln(-0.25, 0.3, 0.25, 0.3, 'dark')], tags: ['food'] });
|
||||||
|
export const toilet = (): Def => ({ kind: 'toilet', w: 0.45, d: 0.72, front: 0.5, shapes: [R(0, -0.26, 0.45, 0.2, 'base', 0.04), O(0, 0.08, 0.2, 'base'), O(0, 0.1, 0.12, 'water')] });
|
||||||
|
export const basin = (): Def => ({ kind: 'basin', w: 0.55, d: 0.45, front: 0.5, shapes: [R(0, 0, 0.55, 0.45, 'base', 0.08), O(0, 0.03, 0.14, 'water')] });
|
||||||
|
export const bathtub = (): Def => ({ kind: 'bathtub', w: 1.7, d: 0.76, shapes: [R(0, 0, 1.7, 0.76, 'base', 0.12), R(0.05, 0, 1.45, 0.56, 'water', 0.2), O(-0.6, 0, 0.04, 'dark')] });
|
||||||
|
export const shower = (): Def => ({ kind: 'shower', w: 0.9, d: 0.9, shapes: [R(0, 0, 0.9, 0.9, 'base', 0.02), R(0, 0, 0.8, 0.8, 'water', 0.02), O(0, 0, 0.04, 'dark'), Ln(-0.45, 0.45, 0.45, 0.45, 'glass')] });
|
||||||
|
export const washer = (): Def => ({ kind: 'washer', w: 0.6, d: 0.6, front: 0.5, shapes: [R(0, 0, 0.6, 0.6, 'base', 0.04), O(0, 0.04, 0.2, 'dark'), O(0, 0.04, 0.14, 'glass')] });
|
||||||
|
export const coatRack = (): Def => ({ kind: 'coat_rack', w: 0.9, d: 0.35, shapes: [R(0, 0, 0.9, 0.35, 'wood', 0.03), O(-0.25, 0.02, 0.09, 'fabric'), O(0.05, 0.02, 0.09, 'fabric'), O(0.3, 0.02, 0.09, 'accent')] });
|
||||||
|
export const shoeCabinet = (): Def => ({ kind: 'shoe_cabinet', w: 0.8, d: 0.35, shapes: [R(0, 0, 0.8, 0.35, 'wood', 0.02)] });
|
||||||
|
export const shelf = (w = 1.2): Def => ({ kind: 'shelf', w, d: 0.5, tall: true, front: 0.7, tags: ['storage'], shapes: [R(0, 0, w, 0.5, 'metal', 0.02), Ln(-w / 2, 0, w / 2, 0, 'dark'), R(-w / 4, 0, w / 3, 0.36, 'wood', 0.02), R(w / 4, 0, w / 4, 0.3, 'accent', 0.02)] });
|
||||||
|
export const crate = (): Def => ({ kind: 'crate', w: 0.8, d: 0.8, shapes: [R(0, 0, 0.8, 0.8, 'wood', 0.03), Ln(-0.36, -0.36, 0.36, 0.36), Ln(-0.36, 0.36, 0.36, -0.36)], tags: ['container'] });
|
||||||
|
export const boiler = (): Def => ({ kind: 'boiler', w: 0.6, d: 0.6, tall: true, shapes: [O(0, 0, 0.29, 'metal'), O(0, 0, 0.1, 'dark')] });
|
||||||
|
|
||||||
|
export function gondola(L: number): Def {
|
||||||
|
const shapes: Shape[] = [R(0, 0, L, 1.0, 'base', 0.03), Ln(-L / 2 + 0.05, 0, L / 2 - 0.05, 0, 'dark')];
|
||||||
|
for (let x = -L / 2 + 0.5; x < L / 2 - 0.2; x += 0.9) shapes.push(R(x + 0.2, -0.25, 0.6, 0.3, 'accent', 0.02), R(x + 0.25, 0.25, 0.5, 0.3, 'fabric', 0.02));
|
||||||
|
return { kind: 'gondola', w: L, d: 1.0, tall: true, front: 1.2, tags: ['goods'], shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallShelf(L: number): Def {
|
||||||
|
const shapes: Shape[] = [R(0, 0, L, 0.5, 'base', 0.02)];
|
||||||
|
for (let x = -L / 2 + 0.45; x < L / 2 - 0.2; x += 0.9) shapes.push(R(x, 0.02, 0.7, 0.32, 'accent', 0.02));
|
||||||
|
return { kind: 'wall_shelf', w: L, d: 0.5, tall: true, front: 1.0, tags: ['goods'], shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fridgeDisplay = (L = 2.4): Def => ({ kind: 'fridge_display', w: L, d: 0.85, tall: true, front: 1.2, tags: ['goods'], shapes: [R(0, 0, L, 0.85, 'metal', 0.03), R(0, 0.15, L - 0.1, 0.45, 'glass', 0.02)] });
|
||||||
|
export const checkout = (): Def => ({ kind: 'checkout', w: 0.9, d: 2.0, front: 0, tags: ['work', 'trade'], shapes: [R(0, 0, 0.9, 2.0, 'base', 0.04), R(0, 0.35, 0.5, 1.0, 'dark', 0.03), R(0.15, -0.6, 0.35, 0.3, 'metal', 0.02)] });
|
||||||
|
export const counter = (L = 1.8): Def => ({ kind: 'counter', w: L, d: 0.65, front: 1.0, tags: ['work', 'trade'], shapes: [R(0, 0, L, 0.65, 'wood', 0.04), R(-L / 2 + 0.35, 0, 0.35, 0.3, 'dark', 0.02)] });
|
||||||
|
export const clothesRack = (): Def => ({ kind: 'rack', w: 1.4, d: 0.6, front: 0.8, tags: ['goods'], shapes: [Ln(-0.7, 0, 0.7, 0, 'metal'), O(-0.45, 0, 0.17, 'fabric'), O(-0.15, 0, 0.17, 'accent'), O(0.15, 0, 0.17, 'fabric'), O(0.45, 0, 0.17, 'soft')] });
|
||||||
|
|
||||||
|
export function cafeTable(): Def {
|
||||||
|
return {
|
||||||
|
kind: 'cafe_table', w: 1.5, d: 1.5, tags: ['sit:4'],
|
||||||
|
shapes: [O(0, -0.55, 0.2, 'wood'), O(0, 0.55, 0.2, 'wood'), O(-0.55, 0, 0.2, 'wood'), O(0.55, 0, 0.2, 'wood'), O(0, 0, 0.38, 'base')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proStove = (): Def => ({ kind: 'pro_stove', w: 1.2, d: 0.8, front: 1.0, tags: ['cook'], shapes: [R(0, 0, 1.2, 0.8, 'metal', 0.03), O(-0.3, -0.15, 0.13, 'dark'), O(0.3, -0.15, 0.13, 'dark'), O(-0.3, 0.18, 0.13, 'dark'), O(0.3, 0.18, 0.13, 'dark')] });
|
||||||
|
export const prepTable = (L = 1.6): Def => ({ kind: 'prep_table', w: L, d: 0.7, front: 0.9, tags: ['cook'], shapes: [R(0, 0, L, 0.7, 'metal', 0.02), Ln(-L / 2 + 0.05, 0.3, L / 2 - 0.05, 0.3, 'dark')] });
|
||||||
|
export const machine = (): Def => ({ kind: 'machine', w: 2.4, d: 1.4, front: 0.8, tags: ['work'], shapes: [R(0, 0, 2.4, 1.4, 'metal', 0.05), R(-0.4, 0, 1.1, 0.8, 'dark', 0.04), O(0.75, 0, 0.3, 'accent'), Ln(-1.2, 0.55, 1.2, 0.55, 'accent')] });
|
||||||
|
export const workbench = (L = 2.0): Def => ({ kind: 'workbench', w: L, d: 0.75, front: 0.8, tags: ['work'], shapes: [R(0, 0, L, 0.75, 'wood', 0.02), R(-L / 2 + 0.3, 0, 0.3, 0.3, 'metal'), R(L / 4, -0.15, 0.6, 0.25, 'accent', 0.02)] });
|
||||||
|
|
||||||
|
export function palletRack(L: number): Def {
|
||||||
|
const shapes: Shape[] = [R(0, 0, L, 1.1, 'base', 0.01)];
|
||||||
|
for (let x = -L / 2 + 0.7; x < L / 2 - 0.3; x += 1.4) shapes.push(R(x, 0, 1.15, 0.95, 'wood', 0.02), Ln(x - 0.5, -0.4, x + 0.5, 0.4, 'dark'));
|
||||||
|
for (let x = -L / 2; x <= L / 2 + 1e-6; x += 2.8) shapes.push(R(Math.min(x, L / 2 - 0.05), 0, 0.1, 1.1, 'accent'));
|
||||||
|
return { kind: 'pallet_rack', w: L, d: 1.1, tall: true, front: 1.5, tags: ['storage'], shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pallet = (): Def => ({ kind: 'pallet', w: 1.2, d: 1.0, tags: ['container'], shapes: [R(0, 0, 1.2, 1.0, 'wood', 0.02), R(0, 0, 1.0, 0.8, 'accent', 0.03)] });
|
||||||
|
|
||||||
|
export function studentDesk(): Def {
|
||||||
|
return {
|
||||||
|
kind: 'student_desk', w: 1.3, d: 1.05, tags: ['sit:2'],
|
||||||
|
shapes: [R(0, -0.2, 1.3, 0.55, 'wood', 0.03), R(-0.32, 0.3, 0.4, 0.38, 'dark', 0.06), R(0.32, 0.3, 0.4, 0.38, 'dark', 0.06)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teacherDesk = (): Def => ({ kind: 'teacher_desk', w: 1.5, d: 1.2, tags: ['work', 'sit:1'], shapes: [R(0, 0.2, 1.5, 0.7, 'wood', 0.03), O(0, -0.35, 0.24, 'dark')] });
|
||||||
|
export const board = (L = 3.0): Def => ({ kind: 'board', w: L, d: 0.12, blocksMove: false, front: 1.5, shapes: [R(0, 0, L, 0.1, 'dark')] });
|
||||||
|
export const pew = (L: number): Def => ({ kind: 'pew', w: L, d: 0.55, tags: [`sit:${Math.floor(L / 0.55)}`], shapes: [R(0, 0.06, L, 0.42, 'wood', 0.04), R(0, -0.2, L, 0.1, 'dark')] });
|
||||||
|
export const altar = (): Def => ({ kind: 'altar', w: 2.0, d: 0.9, front: 1.5, shapes: [R(0, 0, 2.0, 0.9, 'base', 0.03), R(0, 0, 2.1, 0.3, 'accent'), O(-0.7, 0, 0.06, 'accent'), O(0.7, 0, 0.06, 'accent')] });
|
||||||
|
|
||||||
|
export function officeCluster(): Def {
|
||||||
|
const shapes: Shape[] = [];
|
||||||
|
for (const sx of [-0.7, 0.7]) {
|
||||||
|
shapes.push(R(sx, -0.35, 1.38, 0.7, 'wood', 0.02), R(sx, 0.35, 1.38, 0.7, 'wood', 0.02));
|
||||||
|
shapes.push(R(sx - 0.3, -0.5, 0.4, 0.18, 'dark'), R(sx - 0.3, 0.5, 0.4, 0.18, 'dark'));
|
||||||
|
shapes.push(O(sx, -1.0, 0.24, 'dark'), O(sx, 1.0, 0.24, 'dark'));
|
||||||
|
}
|
||||||
|
return { kind: 'desk_cluster', w: 2.8, d: 2.6, tags: ['work', 'sit:4'], shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meetingTable(L: number): Def {
|
||||||
|
const shapes: Shape[] = [];
|
||||||
|
const n = Math.max(2, Math.floor((L - 0.4) / 0.7));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = -L / 2 + 0.2 + ((L - 0.4) / n) * (i + 0.5);
|
||||||
|
shapes.push(O(x, -0.85, 0.23, 'dark'), O(x, 0.85, 0.23, 'dark'));
|
||||||
|
}
|
||||||
|
shapes.push(R(0, 0, L, 1.1, 'wood', 0.2));
|
||||||
|
return { kind: 'meeting_table', w: L + 0.2, d: 2.2, tags: [`sit:${n * 2}`, 'work'], shapes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filing = (): Def => ({ kind: 'filing_cabinet', w: 0.5, d: 0.6, tall: true, front: 0.6, shapes: [R(0, 0, 0.5, 0.6, 'metal', 0.02), Ln(-0.2, 0.0, 0.2, 0.0, 'dark')] });
|
||||||
|
export const mailboxes = (): Def => ({ kind: 'mailboxes', w: 1.2, d: 0.3, shapes: [R(0, 0, 1.2, 0.3, 'metal', 0.02), Ln(-0.3, -0.14, -0.3, 0.14, 'dark'), Ln(0, -0.14, 0, 0.14, 'dark'), Ln(0.3, -0.14, 0.3, 0.14, 'dark')] });
|
||||||
|
|
||||||
|
export function carDef(): Def {
|
||||||
|
return {
|
||||||
|
kind: 'car', w: 1.8, d: 4.4, tags: ['vehicle'],
|
||||||
|
shapes: [R(0, 0, 1.8, 4.4, 'car', 0.45), R(0, -0.75, 1.5, 0.55, 'glass', 0.15), R(0, 1.25, 1.45, 0.4, 'glass', 0.12), R(0, 0.25, 1.5, 1.4, 'dark', 0.2)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shift(s: Shape, dx: number, dy: number): Shape {
|
||||||
|
if (s.t === 'line') return { ...s, x1: s.x1 + dx, x2: s.x2 + dx, y1: s.y1 + dy, y2: s.y2 + dy };
|
||||||
|
return { ...s, x: s.x + dx, y: s.y + dy };
|
||||||
|
}
|
||||||
+412
@@ -0,0 +1,412 @@
|
|||||||
|
// Уровень города: зоны, дороги, контуры зданий, уличные объекты.
|
||||||
|
// Интерьеры генерируются отдельно (interior.ts) — в воркерах.
|
||||||
|
|
||||||
|
import type { OsmElement, BBox } from '../osm/overpass';
|
||||||
|
import type { Area, AreaKind, Building, Prop, Ring, Road, RoadKind, Vec, World } from '../types';
|
||||||
|
import { Projector } from './project';
|
||||||
|
import {
|
||||||
|
bboxOf, centroid, cleanRing, dist, distToSegment, minAreaRectAngle,
|
||||||
|
pointInRing, pointInRings, ringArea, rotate, signedArea,
|
||||||
|
} from '../util/geom';
|
||||||
|
import { Rng, hashString } from '../util/rng';
|
||||||
|
import { SpatialGrid } from '../util/spatial';
|
||||||
|
import { classify, clampLevels, defaultLevels, parseLevels } from './classify';
|
||||||
|
import { carShapes, CAR_COLORS, treeProp } from './props';
|
||||||
|
|
||||||
|
export interface InteriorJob {
|
||||||
|
building: Building;
|
||||||
|
/** Ближайшие к зданию участки дорог: [a, b, вес]. Вход ставится к дороге. */
|
||||||
|
roadSegs: [Vec, Vec, number][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANDUSE: Record<string, AreaKind> = {
|
||||||
|
residential: 'residential', commercial: 'commercial', retail: 'retail', industrial: 'industrial',
|
||||||
|
farmland: 'farmland', farm: 'farmland', farmyard: 'farmyard', grass: 'grass', village_green: 'grass',
|
||||||
|
recreation_ground: 'grass', meadow: 'meadow', forest: 'forest', orchard: 'orchard', vineyard: 'vineyard',
|
||||||
|
cemetery: 'cemetery', construction: 'construction', brownfield: 'construction', greenfield: 'meadow',
|
||||||
|
railway: 'railway', allotments: 'garden', basin: 'water', reservoir: 'water', plant_nursery: 'garden',
|
||||||
|
flowerbed: 'garden', garages: 'industrial', religious: 'grass', education: 'school',
|
||||||
|
};
|
||||||
|
const NATURAL: Record<string, AreaKind> = {
|
||||||
|
water: 'water', wood: 'forest', scrub: 'scrub', heath: 'scrub', grassland: 'meadow', wetland: 'wetland',
|
||||||
|
beach: 'beach', sand: 'sand', bare_rock: 'sand', scree: 'sand',
|
||||||
|
};
|
||||||
|
const LEISURE: Record<string, AreaKind> = {
|
||||||
|
park: 'park', garden: 'garden', pitch: 'pitch', track: 'pitch', playground: 'playground',
|
||||||
|
golf_course: 'grass', swimming_pool: 'water', dog_park: 'grass', common: 'grass',
|
||||||
|
};
|
||||||
|
const AMENITY_AREA: Record<string, AreaKind> = {
|
||||||
|
parking: 'parking', school: 'school', kindergarten: 'school', college: 'school', university: 'school',
|
||||||
|
hospital: 'hospital', grave_yard: 'cemetery', marketplace: 'pedestrian', fuel: 'pedestrian',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AREA_TIER: Record<AreaKind, number> = {
|
||||||
|
residential: 0, commercial: 0, industrial: 0, retail: 0, farmland: 0, railway: 0, school: 0, hospital: 0, construction: 0,
|
||||||
|
farmyard: 1, forest: 1, meadow: 1, grass: 1, park: 1, scrub: 1, orchard: 1, vineyard: 1, cemetery: 1, garden: 1,
|
||||||
|
wetland: 1, beach: 1, sand: 1,
|
||||||
|
pitch: 2, playground: 2, parking: 2, pedestrian: 2, water: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROAD_WIDTH: Record<string, number> = {
|
||||||
|
motorway: 11, trunk: 10, primary: 9, secondary: 8, tertiary: 7, residential: 6, unclassified: 5.5,
|
||||||
|
living_street: 5, service: 3.6, pedestrian: 5, track: 3, footway: 1.8, cycleway: 2, path: 1.2, steps: 2,
|
||||||
|
bridleway: 1.5, road: 5,
|
||||||
|
};
|
||||||
|
const SIDEWALK: Record<string, number> = { primary: 2.2, secondary: 2, tertiary: 1.8, residential: 1.5, living_street: 0, trunk: 0 };
|
||||||
|
|
||||||
|
function areaKindOf(t: Record<string, string>): AreaKind | null {
|
||||||
|
if (t.building) return null;
|
||||||
|
if (t.landuse && LANDUSE[t.landuse]) return LANDUSE[t.landuse];
|
||||||
|
if (t.natural && NATURAL[t.natural]) return NATURAL[t.natural];
|
||||||
|
if (t.waterway === 'riverbank' || t.waterway === 'dock') return 'water';
|
||||||
|
if (t.leisure && LEISURE[t.leisure]) return LEISURE[t.leisure];
|
||||||
|
if (t.amenity && AMENITY_AREA[t.amenity]) return AMENITY_AREA[t.amenity];
|
||||||
|
if (t.place === 'square' || t['area:highway'] || (t.highway === 'pedestrian' && t.area === 'yes')) return 'pedestrian';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сборка колец мультиполигона из кусков путей. */
|
||||||
|
function assembleRings(parts: Vec[][]): Vec[][] {
|
||||||
|
const pool = parts.filter((p) => p.length >= 2).map((p) => p.slice());
|
||||||
|
const rings: Vec[][] = [];
|
||||||
|
const same = (a: Vec, b: Vec) => Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
|
||||||
|
while (pool.length) {
|
||||||
|
let cur = pool.shift()!;
|
||||||
|
let grew = true;
|
||||||
|
while (!same(cur[0], cur[cur.length - 1]) && grew) {
|
||||||
|
grew = false;
|
||||||
|
for (let i = 0; i < pool.length; i++) {
|
||||||
|
const p = pool[i];
|
||||||
|
const end = cur[cur.length - 1];
|
||||||
|
if (same(end, p[0])) cur = cur.concat(p.slice(1));
|
||||||
|
else if (same(end, p[p.length - 1])) cur = cur.concat(p.slice(0, -1).reverse());
|
||||||
|
else if (same(cur[0], p[p.length - 1])) cur = p.slice(0, -1).concat(cur);
|
||||||
|
else if (same(cur[0], p[0])) cur = p.slice(1).reverse().concat(cur);
|
||||||
|
else continue;
|
||||||
|
pool.splice(i, 1);
|
||||||
|
grew = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length >= 4) rings.push(cur);
|
||||||
|
}
|
||||||
|
return rings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Poly {
|
||||||
|
outer: Ring;
|
||||||
|
holes: Ring[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function polysOfElement(el: OsmElement, proj: Projector): Poly[] {
|
||||||
|
if (el.type === 'way' && el.geometry) {
|
||||||
|
const g = el.geometry;
|
||||||
|
if (g.length < 4) return [];
|
||||||
|
const a = g[0], b = g[g.length - 1];
|
||||||
|
if (a.lat !== b.lat || a.lon !== b.lon) return [];
|
||||||
|
return [{ outer: g.map((p) => proj.toXY(p.lat, p.lon)), holes: [] }];
|
||||||
|
}
|
||||||
|
if (el.type === 'relation' && el.members) {
|
||||||
|
const outers = assembleRings(el.members.filter((m) => m.role !== 'inner' && m.geometry).map((m) => m.geometry!.map((p) => proj.toXY(p.lat, p.lon))));
|
||||||
|
const inners = assembleRings(el.members.filter((m) => m.role === 'inner' && m.geometry).map((m) => m.geometry!.map((p) => proj.toXY(p.lat, p.lon))));
|
||||||
|
return outers.map((o) => ({ outer: o, holes: inners.filter((h) => pointInRing(h[0], o)) }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ориентируем кольца единообразно: внешнее — по часовой (в осях y-вниз), дыры — против. */
|
||||||
|
function orient(r: Ring, outer: boolean): Ring {
|
||||||
|
const a = signedArea(r);
|
||||||
|
return (a > 0) === outer ? r : r.slice().reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed: number): { world: World; jobs: InteriorJob[] } {
|
||||||
|
const lat0 = (bbox[0] + bbox[2]) / 2, lon0 = (bbox[1] + bbox[3]) / 2;
|
||||||
|
const proj = new Projector(lat0, lon0);
|
||||||
|
const p0 = proj.toXY(bbox[2], bbox[1]);
|
||||||
|
const p1 = proj.toXY(bbox[0], bbox[3]);
|
||||||
|
const bounds = { minX: p0[0], minY: p0[1], maxX: p1[0], maxY: p1[1] };
|
||||||
|
const inBounds = (p: Vec, m = 0) => p[0] >= bounds.minX - m && p[0] <= bounds.maxX + m && p[1] >= bounds.minY - m && p[1] <= bounds.maxY + m;
|
||||||
|
const rng = new Rng(seed);
|
||||||
|
|
||||||
|
const areas: Area[] = [];
|
||||||
|
const roads: Road[] = [];
|
||||||
|
const props: Prop[] = [];
|
||||||
|
const pois: { p: Vec; tags: Record<string, string> }[] = [];
|
||||||
|
const buildingEls: { el: OsmElement; poly: Poly }[] = [];
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const t = el.tags ?? {};
|
||||||
|
if (el.type === 'node') {
|
||||||
|
if (el.lat == null || el.lon == null) continue;
|
||||||
|
const p = proj.toXY(el.lat, el.lon);
|
||||||
|
if (!inBounds(p, 5)) continue;
|
||||||
|
if (t.natural === 'tree') props.push(treeProp(`t${el.id}`, p, rng, t));
|
||||||
|
else if (t.highway === 'street_lamp') props.push({ id: `l${el.id}`, kind: 'lamp', pos: p, rot: 0, w: 0.4, d: 0.4, shapes: [{ t: 'circle', x: 0, y: 0, r: 0.22, s: 'metal' }], blocksMove: true, blocksSight: false });
|
||||||
|
else if (t.amenity === 'bench') props.push({ id: `b${el.id}`, kind: 'bench', pos: p, rot: rng.range(0, Math.PI), w: 1.8, d: 0.6, shapes: [{ t: 'rect', x: 0, y: 0, w: 1.8, h: 0.55, s: 'wood' }, { t: 'rect', x: 0, y: -0.22, w: 1.8, h: 0.12, s: 'dark' }], blocksMove: true, blocksSight: false, tags: ['sit'] });
|
||||||
|
else if (t.amenity === 'fountain') props.push({ id: `f${el.id}`, kind: 'fountain', pos: p, rot: 0, w: 4, d: 4, shapes: [{ t: 'circle', x: 0, y: 0, r: 2, s: 'base' }, { t: 'circle', x: 0, y: 0, r: 1.7, s: 'water' }], blocksMove: true, blocksSight: false });
|
||||||
|
if (t.shop || t.amenity || t.office || t.craft || t.tourism || t.healthcare) pois.push({ p, tags: t });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.building && t.building !== 'no' && t.building !== 'roof' && !t['building:part']) {
|
||||||
|
for (const poly of polysOfElement(el, proj)) buildingEls.push({ el, poly });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Дороги, рельсы, водотоки
|
||||||
|
const hw = t.highway, rw = t.railway, ww = t.waterway;
|
||||||
|
const isLinear = el.type === 'way' && el.geometry && (hw || rw || ww) && !(t.area === 'yes') && !(ww === 'riverbank');
|
||||||
|
if (isLinear && ROAD_WIDTH[hw] !== undefined || (isLinear && (rw || ww))) {
|
||||||
|
const pts = el.geometry!.map((g) => proj.toXY(g.lat, g.lon));
|
||||||
|
let kind: RoadKind | null = null;
|
||||||
|
let width = 0, sidewalk = 0;
|
||||||
|
if (hw && ROAD_WIDTH[hw] !== undefined) {
|
||||||
|
kind = (['motorway', 'trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'service', 'living_street', 'pedestrian', 'track', 'footway', 'cycleway', 'path', 'steps'] as const).find((k) => hw.startsWith(k)) ?? (hw === 'bridleway' ? 'path' : 'unclassified');
|
||||||
|
width = ROAD_WIDTH[hw.replace('_link', '')] ?? 5;
|
||||||
|
if (hw.endsWith('_link')) width *= 0.75;
|
||||||
|
if (hw === 'service' && (t.service === 'parking_aisle')) width = 5;
|
||||||
|
if (hw === 'service' && (t.service === 'driveway')) width = 3;
|
||||||
|
const lanes = parseInt(t.lanes);
|
||||||
|
if (isFinite(lanes) && lanes > 0 && ROAD_WIDTH[hw] >= 5) width = Math.max(width * 0.6, lanes * 3.2);
|
||||||
|
const w = parseFloat(t.width);
|
||||||
|
if (isFinite(w) && w > 0.5 && w < 60) width = w;
|
||||||
|
sidewalk = SIDEWALK[hw] ?? 0;
|
||||||
|
if (t.sidewalk === 'no' || t.sidewalk === 'none' || t.sidewalk === 'separate') sidewalk = 0;
|
||||||
|
else if (t.sidewalk && sidewalk === 0 && width >= 5) sidewalk = 1.5;
|
||||||
|
} else if (rw) {
|
||||||
|
if (rw === 'platform') continue;
|
||||||
|
kind = rw === 'tram' ? 'tram' : 'rail';
|
||||||
|
width = 1.6;
|
||||||
|
} else if (ww) {
|
||||||
|
kind = ww === 'river' ? 'river' : ww === 'canal' ? 'canal' : ww === 'stream' ? 'stream' : 'ditch';
|
||||||
|
width = { river: 14, canal: 8, stream: 2.5, ditch: 1.2 }[kind as 'river'];
|
||||||
|
const w = parseFloat(t.width);
|
||||||
|
if (isFinite(w) && w > 0.3 && w < 300) width = w;
|
||||||
|
if (t.tunnel === 'culvert') continue;
|
||||||
|
}
|
||||||
|
if (!kind) continue;
|
||||||
|
roads.push({
|
||||||
|
id: `r${el.id}`, osmId: `way/${el.id}`, kind, pts, width, sidewalk,
|
||||||
|
lanes: parseInt(t.lanes) || 0, oneway: t.oneway === 'yes',
|
||||||
|
layer: parseInt(t.layer) || 0, bridge: !!t.bridge && t.bridge !== 'no', tunnel: !!t.tunnel && t.tunnel !== 'no',
|
||||||
|
name: t.name,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ak = areaKindOf(t);
|
||||||
|
if (ak) {
|
||||||
|
for (const poly of polysOfElement(el, proj)) {
|
||||||
|
const bb = bboxOf(poly.outer);
|
||||||
|
if (bb[2] < bounds.minX || bb[0] > bounds.maxX || bb[3] < bounds.minY || bb[1] > bounds.maxY) continue;
|
||||||
|
areas.push({ id: `a${el.type[0]}${el.id}_${areas.length}`, osmId: `${el.type}/${el.id}`, kind: ak, rings: [orient(cleanRing(poly.outer, 0.01), true), ...poly.holes.map((h) => orient(h, false))], name: t.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.natural === 'tree_row' && el.type === 'way' && el.geometry) {
|
||||||
|
const pts = el.geometry.map((g) => proj.toXY(g.lat, g.lon));
|
||||||
|
let acc = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const L = dist(pts[i - 1], pts[i]);
|
||||||
|
for (let s = acc; s < L; s += 8) {
|
||||||
|
const q: Vec = [pts[i - 1][0] + ((pts[i][0] - pts[i - 1][0]) * s) / L, pts[i - 1][1] + ((pts[i][1] - pts[i - 1][1]) * s) / L];
|
||||||
|
if (inBounds(q)) props.push(treeProp(`tr${el.id}_${props.length}`, q, rng, {}));
|
||||||
|
}
|
||||||
|
acc = (acc - L) % 8;
|
||||||
|
if (acc < 0) acc += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок отрисовки зон: крупные подложки → зелень → площадки → вода
|
||||||
|
areas.sort((a, b) => AREA_TIER[a.kind] - AREA_TIER[b.kind] || ringArea(b.rings[0]) - ringArea(a.rings[0]));
|
||||||
|
|
||||||
|
// ---- Индексы для запросов
|
||||||
|
const landGrid = new SpatialGrid<Area>(100);
|
||||||
|
for (const a of areas) if (AREA_TIER[a.kind] <= 1) landGrid.insert(a, bboxOf(a.rings[0]));
|
||||||
|
type Seg = { a: Vec; b: Vec; road: Road };
|
||||||
|
const segGrid = new SpatialGrid<Seg>(40);
|
||||||
|
for (const r of roads) {
|
||||||
|
if (r.tunnel) continue;
|
||||||
|
for (let i = 1; i < r.pts.length; i++) {
|
||||||
|
const a = r.pts[i - 1], b = r.pts[i];
|
||||||
|
const m = r.width / 2 + r.sidewalk + 1;
|
||||||
|
segGrid.insert({ a, b, road: r }, [Math.min(a[0], b[0]) - m, Math.min(a[1], b[1]) - m, Math.max(a[0], b[0]) + m, Math.max(a[1], b[1]) + m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const poiGrid = new SpatialGrid<{ p: Vec; tags: Record<string, string> }>(30);
|
||||||
|
for (const p of pois) poiGrid.insert(p, [p.p[0], p.p[1], p.p[0], p.p[1]]);
|
||||||
|
|
||||||
|
// ---- Здания
|
||||||
|
const buildings: Building[] = [];
|
||||||
|
const bGrid = new SpatialGrid<Building>(40);
|
||||||
|
for (const { el, poly } of buildingEls) {
|
||||||
|
let outer = cleanRing(poly.outer, 0.08);
|
||||||
|
if (outer.length < 3) continue;
|
||||||
|
const area = ringArea(outer) - poly.holes.reduce((s, h) => s + ringArea(h), 0);
|
||||||
|
if (area < 6 || area > 40000) continue;
|
||||||
|
const c = centroid(outer);
|
||||||
|
if (!inBounds(c)) continue;
|
||||||
|
outer = orient(outer, true);
|
||||||
|
const t = el.tags ?? {};
|
||||||
|
const id = `b${el.type[0]}${el.id}`;
|
||||||
|
const b: Building = {
|
||||||
|
id: buildings.some((x) => x.id === id) ? `${id}_${buildings.length}` : id,
|
||||||
|
osmId: `${el.type}/${el.id}`,
|
||||||
|
kind: 'house', footprint: outer, holes: poly.holes.map((h) => orient(cleanRing(h, 0.08), false)).filter((h) => h.length >= 3),
|
||||||
|
area, levels: 1, levelsSource: 'estimate', frame: { origin: [0, 0], angle: minAreaRectAngle(outer) },
|
||||||
|
floors: [], units: [], beds: 0, seed: hashString(`${el.type}/${el.id}`) ^ seed,
|
||||||
|
name: t.name,
|
||||||
|
address: t['addr:street'] ? `${t['addr:street']} ${t['addr:housenumber'] ?? ''}`.trim() : t['addr:housenumber'],
|
||||||
|
};
|
||||||
|
(b as any)._tags = t;
|
||||||
|
buildings.push(b);
|
||||||
|
bGrid.insert(b, bboxOf(outer));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of buildings) {
|
||||||
|
const t = (b as any)._tags as Record<string, string>;
|
||||||
|
const brng = new Rng(b.seed);
|
||||||
|
const bb = bboxOf(b.footprint);
|
||||||
|
const inside = poiGrid.query(bb).filter((p) => pointInRing(p.p, b.footprint)).map((p) => p.tags);
|
||||||
|
const c = centroid(b.footprint);
|
||||||
|
let landuse: string | undefined;
|
||||||
|
for (const a of landGrid.query([c[0], c[1], c[0], c[1]])) {
|
||||||
|
if (AREA_TIER[a.kind] === 0 && pointInRings(c, a.rings)) landuse = a.kind;
|
||||||
|
if (a.kind === 'farmyard' && pointInRings(c, a.rings)) landuse = 'farmyard';
|
||||||
|
}
|
||||||
|
const near = bGrid.query([bb[0] - 15, bb[1] - 15, bb[2] + 15, bb[3] + 15]);
|
||||||
|
const nearBigger = near.some((o) => o !== b && o.area > b.area * 2.5 && o.area > 60);
|
||||||
|
const cls = classify({ tags: t, pois: inside, area: b.area, landuse, nearBigger }, brng);
|
||||||
|
b.kind = cls.kind;
|
||||||
|
b.use = cls.use;
|
||||||
|
if (!b.name) b.name = inside.find((p) => p.name)?.name;
|
||||||
|
const lv = parseLevels(t);
|
||||||
|
if (lv) {
|
||||||
|
b.levels = lv.levels;
|
||||||
|
b.levelsSource = lv.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Этажность без тегов: медиана соседей с тегами, иначе по виду
|
||||||
|
const tagged = buildings.filter((b) => b.levelsSource !== 'estimate');
|
||||||
|
const tGrid = new SpatialGrid<Building>(100);
|
||||||
|
for (const b of tagged) tGrid.insert(b, bboxOf(b.footprint));
|
||||||
|
for (const b of buildings) {
|
||||||
|
if (b.levelsSource !== 'estimate') continue;
|
||||||
|
const brng = new Rng(b.seed ^ 0x51ed);
|
||||||
|
const c = centroid(b.footprint);
|
||||||
|
const ns = tGrid.query([c[0] - 120, c[1] - 120, c[0] + 120, c[1] + 120])
|
||||||
|
.filter((o) => o.kind === b.kind || (o.kind === 'apartments' && b.kind === 'mixed') || (o.kind === 'mixed' && b.kind === 'apartments'))
|
||||||
|
.map((o) => o.levels).sort((x, y) => x - y);
|
||||||
|
b.levels = ns.length >= 3 ? clampLevels(b.kind, ns[ns.length >> 1]) : defaultLevels(b.kind, b.area, brng);
|
||||||
|
if (b.area < 45) b.levels = Math.min(b.levels, 1);
|
||||||
|
}
|
||||||
|
for (const b of buildings) delete (b as any)._tags;
|
||||||
|
|
||||||
|
// ---- Уличные объекты: деревья в лесах/парках, машины на парковках
|
||||||
|
const blockedByBuilding = (p: Vec, m: number) =>
|
||||||
|
bGrid.query([p[0] - m, p[1] - m, p[0] + m, p[1] + m]).some((b) => pointInRing(p, b.footprint) || distToRing(p, b.footprint) < m);
|
||||||
|
const nearRoad = (p: Vec, m: number) =>
|
||||||
|
segGrid.query([p[0] - m, p[1] - m, p[0] + m, p[1] + m]).some((s) => distToSegment(p, s.a, s.b) < s.road.width / 2 + s.road.sidewalk + m);
|
||||||
|
|
||||||
|
let treeBudget = 40000;
|
||||||
|
for (const a of areas) {
|
||||||
|
const spacing = a.kind === 'forest' ? 6 : a.kind === 'park' ? 14 : a.kind === 'orchard' ? 7 : a.kind === 'cemetery' ? 16 : a.kind === 'scrub' ? 8 : 0;
|
||||||
|
if (!spacing) continue;
|
||||||
|
const bb = bboxOf(a.rings[0]);
|
||||||
|
const x0 = Math.max(bb[0], bounds.minX), x1 = Math.min(bb[2], bounds.maxX);
|
||||||
|
const y0 = Math.max(bb[1], bounds.minY), y1 = Math.min(bb[3], bounds.maxY);
|
||||||
|
const ar = new Rng(hashString(a.id) ^ seed);
|
||||||
|
for (let y = y0; y < y1 && treeBudget > 0; y += spacing)
|
||||||
|
for (let x = x0; x < x1 && treeBudget > 0; x += spacing) {
|
||||||
|
const p: Vec = [x + ar.range(0, spacing * 0.8), y + ar.range(0, spacing * 0.8)];
|
||||||
|
if (a.kind === 'park' && ar.chance(0.35)) continue;
|
||||||
|
if (!pointInRings(p, a.rings)) continue;
|
||||||
|
if (blockedByBuilding(p, 2) || nearRoad(p, 1.5)) continue;
|
||||||
|
props.push(treeProp(`ta${a.id}_${treeBudget}`, p, ar, a.kind === 'scrub' ? { scrub: '1' } : a.kind === 'orchard' ? { orchard: '1' } : {}));
|
||||||
|
treeBudget--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Парковки: ряды мест 2.5×5 м с проездами 6 м
|
||||||
|
for (const a of areas) {
|
||||||
|
if (a.kind !== 'parking') continue;
|
||||||
|
const outer = a.rings[0];
|
||||||
|
if (ringArea(outer) < 60) continue;
|
||||||
|
const ang = minAreaRectAngle(outer);
|
||||||
|
const local = outer.map((p) => rotate(p, -ang));
|
||||||
|
const lb = bboxOf(local);
|
||||||
|
const pr = new Rng(hashString(a.id) ^ seed);
|
||||||
|
const fill = pr.range(0.45, 0.85);
|
||||||
|
// полоса: ряд машин 5 м, проезд 6 м, ряд 5 м, ...
|
||||||
|
let y = lb[1] + 0.5;
|
||||||
|
let row = 0;
|
||||||
|
while (y + 5 <= lb[3]) {
|
||||||
|
for (let x = lb[0] + 0.3; x + 2.5 <= lb[2]; x += 2.5) {
|
||||||
|
const corners: Vec[] = [[x, y], [x + 2.5, y], [x + 2.5, y + 5], [x, y + 5]].map((q) => rotate(q as Vec, ang));
|
||||||
|
if (!corners.every((q) => pointInRings(q, a.rings))) continue;
|
||||||
|
const cc = rotate([x + 1.25, y + 2.5], ang);
|
||||||
|
if (!inBounds(cc) || blockedByBuilding(cc, 2)) continue;
|
||||||
|
// разметка места рисуем всегда, машину — с вероятностью
|
||||||
|
props.push({ id: `ps${a.id}_${props.length}`, kind: 'parking_spot', pos: cc, rot: ang, w: 2.5, d: 5, shapes: [{ t: 'line', x1: -1.25, y1: -2.5, x2: -1.25, y2: 2.5, s: 'line' }, { t: 'line', x1: 1.25, y1: -2.5, x2: 1.25, y2: 2.5, s: 'line' }], blocksMove: false, blocksSight: false });
|
||||||
|
if (pr.chance(fill)) {
|
||||||
|
const flip = row % 2 === 1 ? Math.PI : 0;
|
||||||
|
props.push({ id: `c${a.id}_${props.length}`, kind: 'car', pos: cc, rot: ang + flip + pr.range(-0.04, 0.04), w: 1.8, d: 4.4, shapes: carShapes(), blocksMove: true, blocksSight: false, color: pr.pick(CAR_COLORS), tags: ['vehicle'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y += row % 2 === 0 ? 5 + 6 : 5 + 0.0;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Машины вдоль жилых улиц (параллельная парковка)
|
||||||
|
for (const r of roads) {
|
||||||
|
if (!['residential', 'tertiary', 'living_street', 'unclassified'].includes(r.kind) || r.tunnel || r.bridge) continue;
|
||||||
|
const rr = new Rng(hashString(r.id) ^ seed);
|
||||||
|
if (!rr.chance(0.6)) continue;
|
||||||
|
for (let i = 1; i < r.pts.length; i++) {
|
||||||
|
const a = r.pts[i - 1], b = r.pts[i];
|
||||||
|
const L = dist(a, b);
|
||||||
|
if (L < 12) continue;
|
||||||
|
const dir: Vec = [(b[0] - a[0]) / L, (b[1] - a[1]) / L];
|
||||||
|
const nrm: Vec = [-dir[1], dir[0]];
|
||||||
|
for (const side of [1, -1]) {
|
||||||
|
for (let s = 6; s < L - 6; s += 6) {
|
||||||
|
if (!rr.chance(0.28)) continue;
|
||||||
|
const off = r.width / 2 - 1.1;
|
||||||
|
const p: Vec = [a[0] + dir[0] * s + nrm[0] * off * side, a[1] + dir[1] * s + nrm[1] * off * side];
|
||||||
|
if (!inBounds(p) || blockedByBuilding(p, 1.5)) continue;
|
||||||
|
props.push({ id: `cs${r.id}_${props.length}`, kind: 'car', pos: p, rot: Math.atan2(dir[1], dir[0]) + (side > 0 ? Math.PI / 2 : -Math.PI / 2) + rr.range(-0.03, 0.03), w: 1.8, d: 4.4, shapes: carShapes(), blocksMove: true, blocksSight: false, color: rr.pick(CAR_COLORS), tags: ['vehicle'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Задания на интерьеры: здание + ближайшие дороги
|
||||||
|
const jobs: InteriorJob[] = buildings.map((b) => {
|
||||||
|
const bb = bboxOf(b.footprint);
|
||||||
|
const segs = segGrid.query([bb[0] - 40, bb[1] - 40, bb[2] + 40, bb[3] + 40])
|
||||||
|
.filter((s) => !['rail', 'tram', 'river', 'stream', 'canal', 'ditch'].includes(s.road.kind))
|
||||||
|
.map((s): [Vec, Vec, number] => [s.a, s.b, s.road.kind === 'footway' || s.road.kind === 'path' || s.road.kind === 'cycleway' ? 0.6 : s.road.kind === 'service' ? 0.8 : 1]);
|
||||||
|
return { building: b, roadSegs: segs.slice(0, 200) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const world: World = {
|
||||||
|
version: 1,
|
||||||
|
id: `w${Date.now().toString(36)}`,
|
||||||
|
name,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
seed,
|
||||||
|
origin: { lat: lat0, lon: lon0 },
|
||||||
|
bbox,
|
||||||
|
bounds,
|
||||||
|
areas,
|
||||||
|
roads: roads.filter((r) => r.pts.some((p) => inBounds(p, 200))),
|
||||||
|
buildings,
|
||||||
|
props: props.filter((p) => inBounds(p.pos, 2)),
|
||||||
|
models: {},
|
||||||
|
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||||
|
};
|
||||||
|
return { world, jobs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function distToRing(p: Vec, r: Ring): number {
|
||||||
|
let d = Infinity;
|
||||||
|
for (let i = 0; i < r.length; i++) d = Math.min(d, distToSegment(p, r[i], r[(i + 1) % r.length]));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { BuildingKind } from '../types';
|
||||||
|
import { Rng } from '../util/rng';
|
||||||
|
|
||||||
|
export interface ClassifyInput {
|
||||||
|
tags: Record<string, string>;
|
||||||
|
/** Теги POI-точек внутри контура. */
|
||||||
|
pois: Record<string, string>[];
|
||||||
|
area: number;
|
||||||
|
landuse?: string;
|
||||||
|
/** Есть ли рядом здание побольше (для гаражей/сараев во дворах). */
|
||||||
|
nearBigger: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILDING_TAG: Record<string, BuildingKind> = {
|
||||||
|
house: 'house', detached: 'house', semidetached_house: 'house', terrace: 'house', bungalow: 'house',
|
||||||
|
residential: 'apartments', apartments: 'apartments', dormitory: 'apartments',
|
||||||
|
hut: 'shed', cabin: 'house', static_caravan: 'shed',
|
||||||
|
commercial: 'office', office: 'office', retail: 'retail', supermarket: 'retail', kiosk: 'retail', shop: 'retail',
|
||||||
|
industrial: 'industrial', manufacture: 'industrial', factory: 'industrial', warehouse: 'warehouse', storage_tank: 'shed',
|
||||||
|
hangar: 'warehouse', service: 'shed', transformer_tower: 'shed', hotel: 'hotel',
|
||||||
|
school: 'school', kindergarten: 'school', college: 'school', university: 'school',
|
||||||
|
hospital: 'hospital', clinic: 'hospital',
|
||||||
|
church: 'church', chapel: 'church', cathedral: 'church', mosque: 'church', synagogue: 'church', temple: 'church', religious: 'church',
|
||||||
|
civic: 'civic', public: 'civic', government: 'civic', townhall: 'civic', fire_station: 'civic', train_station: 'civic',
|
||||||
|
sports_hall: 'warehouse', sports_centre: 'warehouse', stadium: 'civic',
|
||||||
|
garage: 'garage', garages: 'garage', carport: 'garage', parking: 'garage',
|
||||||
|
shed: 'shed', barn: 'farm', farm_auxiliary: 'farm', stable: 'farm', cowshed: 'farm', greenhouse: 'farm', farm: 'house',
|
||||||
|
restaurant: 'restaurant',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMENITY_KIND: Record<string, BuildingKind> = {
|
||||||
|
restaurant: 'restaurant', cafe: 'restaurant', fast_food: 'restaurant', bar: 'restaurant', pub: 'restaurant', biergarten: 'restaurant', ice_cream: 'restaurant',
|
||||||
|
bank: 'office', pharmacy: 'retail', post_office: 'office', townhall: 'civic', police: 'civic', fire_station: 'civic', library: 'civic',
|
||||||
|
courthouse: 'civic', community_centre: 'civic', theatre: 'civic', cinema: 'civic', arts_centre: 'civic',
|
||||||
|
school: 'school', kindergarten: 'school', college: 'school', university: 'school',
|
||||||
|
hospital: 'hospital', clinic: 'hospital', doctors: 'office', dentist: 'office', veterinary: 'office',
|
||||||
|
place_of_worship: 'church', fuel: 'retail', car_wash: 'garage', marketplace: 'retail',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NON_HOME_AMENITY = new Set(Object.keys(AMENITY_KIND));
|
||||||
|
|
||||||
|
/** Подтип для ключевого POI внутри здания: 'supermarket', 'bakery', 'restaurant', 'bank'… */
|
||||||
|
export function useOf(t: Record<string, string>): string | undefined {
|
||||||
|
if (t.shop) return t.shop;
|
||||||
|
if (t.amenity && NON_HOME_AMENITY.has(t.amenity)) return t.amenity;
|
||||||
|
if (t.office) return 'office';
|
||||||
|
if (t.craft) return 'workshop';
|
||||||
|
if (t.tourism === 'hotel' || t.tourism === 'hostel' || t.tourism === 'guest_house') return 'hotel';
|
||||||
|
if (t.healthcare) return 'clinic';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classify(inp: ClassifyInput, rng: Rng): { kind: BuildingKind; use?: string } {
|
||||||
|
const t = inp.tags;
|
||||||
|
const b = t.building;
|
||||||
|
let kind: BuildingKind | undefined = BUILDING_TAG[b];
|
||||||
|
// Сами теги здания (магазин, амнити на контуре)
|
||||||
|
const own = useOf(t);
|
||||||
|
const poiUses = inp.pois.map(useOf).filter(Boolean) as string[];
|
||||||
|
const use = own ?? poiUses[0];
|
||||||
|
|
||||||
|
const commercialPoi = (u?: string): BuildingKind | undefined => {
|
||||||
|
if (!u) return undefined;
|
||||||
|
if (u === 'hotel') return 'hotel';
|
||||||
|
if (u === 'office') return 'office';
|
||||||
|
if (u === 'workshop') return 'industrial';
|
||||||
|
if (u === 'clinic') return 'office';
|
||||||
|
if (AMENITY_KIND[u]) return AMENITY_KIND[u];
|
||||||
|
return 'retail'; // shop=*
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!kind || b === 'yes' || b === 'commercial' || b === 'retail') {
|
||||||
|
const ck = commercialPoi(use);
|
||||||
|
if (ck) kind = ck;
|
||||||
|
}
|
||||||
|
if (t.amenity && AMENITY_KIND[t.amenity]) kind = AMENITY_KIND[t.amenity];
|
||||||
|
if (t.tourism === 'hotel') kind = 'hotel';
|
||||||
|
|
||||||
|
if (!kind) {
|
||||||
|
const a = inp.area;
|
||||||
|
const lu = inp.landuse;
|
||||||
|
if (a < 20) kind = 'shed';
|
||||||
|
else if (a < 45 && inp.nearBigger) kind = rng.chance(0.6) ? 'garage' : 'shed';
|
||||||
|
else if (lu === 'industrial') kind = a > 1500 ? 'warehouse' : a < 60 ? 'shed' : 'industrial';
|
||||||
|
else if (lu === 'commercial') kind = a > 2500 ? 'retail' : 'office';
|
||||||
|
else if (lu === 'retail') kind = 'retail';
|
||||||
|
else if (lu === 'farmyard' || lu === 'farmland' || lu === 'meadow') kind = a > 150 ? 'farm' : a < 60 ? 'shed' : 'house';
|
||||||
|
else if (a < 280) kind = 'house';
|
||||||
|
else if (a < 2500) kind = 'apartments';
|
||||||
|
else kind = 'warehouse';
|
||||||
|
}
|
||||||
|
// магазин на первом этаже многоэтажного жилого дома
|
||||||
|
if ((kind === 'retail' || kind === 'restaurant') && (b === 'apartments' || b === 'residential')) kind = 'mixed';
|
||||||
|
return { kind, use };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLevels(t: Record<string, string>): { levels: number; source: 'osm' | 'height' } | null {
|
||||||
|
const lv = parseFloat(t['building:levels']);
|
||||||
|
if (isFinite(lv) && lv >= 1) return { levels: Math.min(60, Math.round(lv)), source: 'osm' };
|
||||||
|
const h = parseFloat(t.height ?? t['building:height']);
|
||||||
|
if (isFinite(h) && h > 2) return { levels: Math.max(1, Math.min(60, Math.round(h / 3.2))), source: 'height' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Этажность по умолчанию по виду и площади. */
|
||||||
|
export function defaultLevels(kind: BuildingKind, area: number, rng: Rng): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'house': return area < 70 ? 1 : area < 110 ? rng.pick([1, 2, 2]) : 2;
|
||||||
|
case 'apartments': return area < 400 ? rng.pick([3, 3, 4]) : rng.pick([4, 5, 5, 6]);
|
||||||
|
case 'mixed': return rng.pick([3, 4, 4, 5]);
|
||||||
|
case 'office': return area < 300 ? 2 : rng.pick([3, 4, 5]);
|
||||||
|
case 'hotel': return rng.pick([3, 4, 5]);
|
||||||
|
case 'hospital': return rng.pick([3, 4]);
|
||||||
|
case 'school': return area < 600 ? 2 : rng.pick([2, 3]);
|
||||||
|
case 'civic': return 2;
|
||||||
|
case 'retail': return area > 1500 ? 1 : area < 200 ? 1 : rng.pick([1, 2]);
|
||||||
|
case 'restaurant': return 1;
|
||||||
|
default: return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разумные пределы этажности для вида (если берём по соседям). */
|
||||||
|
export function clampLevels(kind: BuildingKind, n: number): number {
|
||||||
|
const max: Partial<Record<BuildingKind, number>> = { house: 3, shed: 1, garage: 1, farm: 2, warehouse: 2, industrial: 3, church: 1, restaurant: 3 };
|
||||||
|
return Math.max(1, Math.min(max[kind] ?? 30, n));
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
|
||||||
|
|
||||||
|
import type { Building, Prop, Shape, World } from '../types';
|
||||||
|
|
||||||
|
const r2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||||
|
|
||||||
|
export class ModelLibrary {
|
||||||
|
private index = new Map<string, string>();
|
||||||
|
/** Модели, добавленные с последнего takeNew() — чтобы воркер отдавал только новые. */
|
||||||
|
private fresh: Record<string, Shape[]> = {};
|
||||||
|
constructor(public models: Record<string, Shape[]>, private prefix = '') {
|
||||||
|
for (const [id, shapes] of Object.entries(models)) this.index.set(JSON.stringify(shapes), id);
|
||||||
|
}
|
||||||
|
intern(p: Prop) {
|
||||||
|
if (!p.shapes) return;
|
||||||
|
const shapes = p.shapes.map(roundShape);
|
||||||
|
const key = JSON.stringify(shapes);
|
||||||
|
let id = this.index.get(key);
|
||||||
|
if (!id) {
|
||||||
|
id = `${p.kind}#${this.prefix}${this.index.size}`;
|
||||||
|
this.index.set(key, id);
|
||||||
|
this.models[id] = shapes;
|
||||||
|
this.fresh[id] = shapes;
|
||||||
|
}
|
||||||
|
p.model = id;
|
||||||
|
delete p.shapes;
|
||||||
|
}
|
||||||
|
takeNew(): Record<string, Shape[]> {
|
||||||
|
const f = this.fresh;
|
||||||
|
this.fresh = {};
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundShape(s: Shape): Shape {
|
||||||
|
if (s.t === 'line') return { ...s, x1: r2(s.x1), y1: r2(s.y1), x2: r2(s.x2), y2: r2(s.y2) };
|
||||||
|
if (s.t === 'circle') return { ...s, x: r2(s.x), y: r2(s.y), r: r2(s.r) };
|
||||||
|
return { ...s, x: r2(s.x), y: r2(s.y), w: r2(s.w), h: r2(s.h), ...(s.r !== undefined ? { r: r2(s.r) } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundProp(p: Prop) {
|
||||||
|
p.pos = [r2(p.pos[0]), r2(p.pos[1])];
|
||||||
|
p.rot = r3(p.rot);
|
||||||
|
p.w = r2(p.w);
|
||||||
|
p.d = r2(p.d);
|
||||||
|
if (!p.tags?.length) delete p.tags;
|
||||||
|
if (!p.roomId) delete p.roomId;
|
||||||
|
if (!p.color) delete p.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rv = (v: [number, number]): [number, number] => [r2(v[0]), r2(v[1])];
|
||||||
|
|
||||||
|
export function finalizeBuilding(b: Building, lib: ModelLibrary) {
|
||||||
|
b.footprint = b.footprint.map(rv);
|
||||||
|
b.holes = b.holes.map((h) => h.map(rv));
|
||||||
|
b.area = Math.round(b.area);
|
||||||
|
for (const f of b.floors) {
|
||||||
|
for (const r of f.rooms) {
|
||||||
|
r.rings = r.rings.map((rr) => rr.map(rv));
|
||||||
|
r.labelPos = rv(r.labelPos);
|
||||||
|
}
|
||||||
|
for (const w of f.walls) { w.a = rv(w.a); w.b = rv(w.b); }
|
||||||
|
for (const d of f.doors) { d.a = rv(d.a); d.b = rv(d.b); d.swingTo = rv(d.swingTo); }
|
||||||
|
for (const w of f.windows) { w.a = rv(w.a); w.b = rv(w.b); }
|
||||||
|
for (const s of f.stairs) { s.rings = s.rings.map((rr) => rr.map(rv)); s.from = rv(s.from); s.to = rv(s.to); }
|
||||||
|
for (const p of f.props) { roundProp(p); lib.intern(p); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeWorld(w: World, buildings = true) {
|
||||||
|
const lib = new ModelLibrary(w.models);
|
||||||
|
for (const a of w.areas) a.rings = a.rings.map((r) => r.map(rv));
|
||||||
|
for (const r of w.roads) r.pts = r.pts.map(rv);
|
||||||
|
for (const p of w.props) { roundProp(p); lib.intern(p); }
|
||||||
|
if (buildings) for (const b of w.buildings) finalizeBuilding(b, lib);
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
// Расстановка мебели по правилам для каждого типа комнаты.
|
||||||
|
// Работает в сетке здания: предмет занимает клетки, «спинкой» встаёт к стене,
|
||||||
|
// не перекрывает двери (зона открывания) и высокими предметами не закрывает окна.
|
||||||
|
|
||||||
|
import type { Building, Prop, Vec } from '../types';
|
||||||
|
import type { DoorL, FloorG, Grid, WindowL } from './interior';
|
||||||
|
import { C } from './interior';
|
||||||
|
import * as K from './catalog';
|
||||||
|
import type { Def } from './catalog';
|
||||||
|
import { Rng } from '../util/rng';
|
||||||
|
import { distToSegment } from '../util/geom';
|
||||||
|
import { CAR_COLORS } from './props';
|
||||||
|
|
||||||
|
export interface PropL {
|
||||||
|
prop: Prop;
|
||||||
|
pos: Vec;
|
||||||
|
rot: number;
|
||||||
|
room: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
g: Grid;
|
||||||
|
F: FloorG;
|
||||||
|
doors: DoorL[];
|
||||||
|
windows: WindowL[];
|
||||||
|
building: Building;
|
||||||
|
rng: Rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dir = 'N' | 'E' | 'S' | 'W';
|
||||||
|
const ROT: Record<Dir, number> = { N: 0, S: Math.PI, W: -Math.PI / 2, E: Math.PI / 2 };
|
||||||
|
|
||||||
|
export function furnishFloor(ctx: Ctx): PropL[] {
|
||||||
|
const { g, F, rng } = ctx;
|
||||||
|
const n = g.gw * g.gh;
|
||||||
|
const occ = new Uint8Array(n); // 0 свободно, 1 занято, 2 держать свободным
|
||||||
|
const win = new Uint8Array(n);
|
||||||
|
const out: PropL[] = [];
|
||||||
|
const cellsOf = new Map<number, number[]>();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const r = F.labels[i];
|
||||||
|
if (r < 0 || !g.mask[i]) continue;
|
||||||
|
if (!cellsOf.has(r)) cellsOf.set(r, []);
|
||||||
|
cellsOf.get(r)!.push(i);
|
||||||
|
}
|
||||||
|
const cc = (i: number): Vec => [((i % g.gw) + 0.5) * C, (((i / g.gw) | 0) + 0.5) * C];
|
||||||
|
// зоны дверей и окон
|
||||||
|
const around = (a: Vec, b: Vec, r: number, fn: (i: number, p: Vec) => void) => {
|
||||||
|
const x0 = Math.max(0, Math.floor((Math.min(a[0], b[0]) - r) / C)), x1 = Math.min(g.gw - 1, Math.floor((Math.max(a[0], b[0]) + r) / C));
|
||||||
|
const y0 = Math.max(0, Math.floor((Math.min(a[1], b[1]) - r) / C)), y1 = Math.min(g.gh - 1, Math.floor((Math.max(a[1], b[1]) + r) / C));
|
||||||
|
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { const i = y * g.gw + x; if (F.labels[i] >= 0) fn(i, cc(i)); }
|
||||||
|
};
|
||||||
|
for (const d of ctx.doors) {
|
||||||
|
const m: Vec = [(d.a[0] + d.b[0]) / 2, (d.a[1] + d.b[1]) / 2];
|
||||||
|
const half = Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) / 2;
|
||||||
|
around(d.a, d.b, half + 0.7, (i, p) => {
|
||||||
|
if (distToSegment(p, d.a, d.b) < 0.95 || Math.hypot(p[0] - m[0], p[1] - m[1]) < half + 0.6) occ[i] = 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const w of ctx.windows) around(w.a, w.b, 0.6, (i, p) => { if (distToSegment(p, w.a, w.b) < 0.6) win[i] = 1; });
|
||||||
|
// лестницы — вне мебели
|
||||||
|
for (const s of F.stairs) for (let y = s.block.y0; y < s.block.y1; y++) for (let x = s.block.x0; x < s.block.x1; x++) occ[y * g.gw + x] = 1;
|
||||||
|
|
||||||
|
const lab = (x: number, y: number) => (x < 0 || y < 0 || x >= g.gw || y >= g.gh ? -1 : F.labels[y * g.gw + x]);
|
||||||
|
const free = (x: number, y: number, room: number, tall?: boolean) => {
|
||||||
|
if (x < 0 || y < 0 || x >= g.gw || y >= g.gh) return false;
|
||||||
|
const i = y * g.gw + x;
|
||||||
|
return F.labels[i] === room && g.mask[i] === 1 && occ[i] === 0 && !(tall && win[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = (def: Def, pos: Vec, rot: number, room: number, cells: number[], frontCells: number[], color?: string) => {
|
||||||
|
for (const i of cells) occ[i] = 1;
|
||||||
|
for (const i of frontCells) if (occ[i] === 0) occ[i] = 2;
|
||||||
|
out.push({
|
||||||
|
pos, rot, room,
|
||||||
|
prop: {
|
||||||
|
id: '', kind: def.kind, pos: [0, 0], rot: 0, w: def.w, d: def.d, shapes: def.shapes,
|
||||||
|
blocksMove: def.blocksMove ?? true, blocksSight: !!def.tall, tags: def.tags, color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Поставить предмет спинкой к стене. */
|
||||||
|
const atWall = (room: number, def: Def, opt: { corner?: boolean; window?: boolean; tries?: number } = {}): boolean => {
|
||||||
|
const cells = cellsOf.get(room);
|
||||||
|
if (!cells) return false;
|
||||||
|
const A = Math.max(1, Math.ceil(def.w / C - 1e-6)), D = Math.max(1, Math.ceil(def.d / C - 1e-6));
|
||||||
|
const FD = Math.ceil((def.front ?? 0) / C - 1e-6);
|
||||||
|
let best: { s: number; dir: Dir; x0: number; y0: number; x1: number; y1: number } | null = null;
|
||||||
|
for (const i of cells) {
|
||||||
|
const cx = i % g.gw, cy = (i / g.gw) | 0;
|
||||||
|
for (const dir of ['N', 'E', 'S', 'W'] as Dir[]) {
|
||||||
|
let x0: number, y0: number, x1: number, y1: number;
|
||||||
|
if (dir === 'N') { x0 = cx; y0 = cy; x1 = cx + A; y1 = cy + D; }
|
||||||
|
else if (dir === 'S') { x0 = cx; y0 = cy - D + 1; x1 = cx + A; y1 = cy + 1; }
|
||||||
|
else if (dir === 'W') { x0 = cx; y0 = cy; x1 = cx + D; y1 = cy + A; }
|
||||||
|
else { x0 = cx - D + 1; y0 = cy; x1 = cx + 1; y1 = cy + A; }
|
||||||
|
let ok = true;
|
||||||
|
for (let y = y0; y < y1 && ok; y++) for (let x = x0; x < x1 && ok; x++) if (!free(x, y, room, def.tall)) ok = false;
|
||||||
|
if (!ok) continue;
|
||||||
|
// сзади должна быть стена по всей длине
|
||||||
|
let wallOk = true;
|
||||||
|
if (dir === 'N') for (let x = x0; x < x1; x++) wallOk &&= lab(x, y0 - 1) !== room;
|
||||||
|
if (dir === 'S') for (let x = x0; x < x1; x++) wallOk &&= lab(x, y1) !== room;
|
||||||
|
if (dir === 'W') for (let y = y0; y < y1; y++) wallOk &&= lab(x0 - 1, y) !== room;
|
||||||
|
if (dir === 'E') for (let y = y0; y < y1; y++) wallOk &&= lab(x1, y) !== room;
|
||||||
|
if (!wallOk) continue;
|
||||||
|
// перед предметом — проход
|
||||||
|
let frontOk = true;
|
||||||
|
for (let k = 1; k <= FD && frontOk; k++) {
|
||||||
|
if (dir === 'N') for (let x = x0; x < x1; x++) frontOk &&= lab(x, y1 - 1 + k) === room && occ[(y1 - 1 + k) * g.gw + x] !== 1;
|
||||||
|
if (dir === 'S') for (let x = x0; x < x1; x++) frontOk &&= lab(x, y0 - k) === room && occ[(y0 - k) * g.gw + x] !== 1;
|
||||||
|
if (dir === 'W') for (let y = y0; y < y1; y++) frontOk &&= lab(x1 - 1 + k, y) === room && occ[y * g.gw + x1 - 1 + k] !== 1;
|
||||||
|
if (dir === 'E') for (let y = y0; y < y1; y++) frontOk &&= lab(x0 - k, y) === room && occ[y * g.gw + x0 - k] !== 1;
|
||||||
|
}
|
||||||
|
if (!frontOk) continue;
|
||||||
|
let s = rng.next();
|
||||||
|
if (opt.corner) {
|
||||||
|
const sideA = dir === 'N' || dir === 'S' ? lab(x0 - 1, dir === 'N' ? y0 : y1 - 1) !== room : lab(dir === 'W' ? x0 : x1 - 1, y0 - 1) !== room;
|
||||||
|
const sideB = dir === 'N' || dir === 'S' ? lab(x1, dir === 'N' ? y0 : y1 - 1) !== room : lab(dir === 'W' ? x0 : x1 - 1, y1) !== room;
|
||||||
|
s += (sideA ? 2 : 0) + (sideB ? 2 : 0);
|
||||||
|
}
|
||||||
|
if (opt.window) {
|
||||||
|
let wn = 0;
|
||||||
|
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) wn += win[y * g.gw + x];
|
||||||
|
s += wn ? 3 : 0;
|
||||||
|
}
|
||||||
|
if (!best || s > best.s) best = { s, dir, x0, y0, x1, y1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return false;
|
||||||
|
const { dir, x0, y0, x1, y1 } = best;
|
||||||
|
const inset = 0.07 + def.d / 2;
|
||||||
|
let pos: Vec;
|
||||||
|
if (dir === 'N') pos = [((x0 + x1) / 2) * C, y0 * C + inset];
|
||||||
|
else if (dir === 'S') pos = [((x0 + x1) / 2) * C, y1 * C - inset];
|
||||||
|
else if (dir === 'W') pos = [x0 * C + inset, ((y0 + y1) / 2) * C];
|
||||||
|
else pos = [x1 * C - inset, ((y0 + y1) / 2) * C];
|
||||||
|
const used: number[] = [];
|
||||||
|
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) used.push(y * g.gw + x);
|
||||||
|
const front: number[] = [];
|
||||||
|
for (let k = 1; k <= FD; k++) {
|
||||||
|
if (dir === 'N') for (let x = x0; x < x1; x++) front.push((y1 - 1 + k) * g.gw + x);
|
||||||
|
if (dir === 'S') for (let x = x0; x < x1; x++) front.push((y0 - k) * g.gw + x);
|
||||||
|
if (dir === 'W') for (let y = y0; y < y1; y++) front.push(y * g.gw + x1 - 1 + k);
|
||||||
|
if (dir === 'E') for (let y = y0; y < y1; y++) front.push(y * g.gw + x0 - k);
|
||||||
|
}
|
||||||
|
return emit(def, pos, ROT[dir], room, used, front.filter((i) => i >= 0 && i < n));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Поставить предмет отдельно (стол, кластер), с отступом margin клеток, ближе к центру комнаты. */
|
||||||
|
const inMiddle = (room: number, def: Def, margin = 1, center?: Vec, color?: string): boolean => {
|
||||||
|
const cells = cellsOf.get(room);
|
||||||
|
if (!cells) return false;
|
||||||
|
const c = center ?? roomPole(room);
|
||||||
|
let best: { s: number; x0: number; y0: number; x1: number; y1: number; rot: boolean } | null = null;
|
||||||
|
for (const rotd of [false, true]) {
|
||||||
|
const A = Math.max(1, Math.ceil((rotd ? def.d : def.w) / C - 1e-6)), D = Math.max(1, Math.ceil((rotd ? def.w : def.d) / C - 1e-6));
|
||||||
|
for (const i of cells) {
|
||||||
|
const x0 = i % g.gw, y0 = (i / g.gw) | 0;
|
||||||
|
let ok = true;
|
||||||
|
for (let y = y0 - margin; y < y0 + D + margin && ok; y++)
|
||||||
|
for (let x = x0 - margin; x < x0 + A + margin && ok; x++) {
|
||||||
|
const inner = x >= x0 && x < x0 + A && y >= y0 && y < y0 + D;
|
||||||
|
if (inner) ok = free(x, y, room);
|
||||||
|
else ok = lab(x, y) === room && occ[y * g.gw + x] !== 1;
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
const s = -Math.hypot((x0 + A / 2) * C - c[0], (y0 + D / 2) * C - c[1]) + rng.next() * 0.3;
|
||||||
|
if (!best || s > best.s) best = { s, x0, y0, x1: x0 + A, y1: y0 + D, rot: rotd };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return false;
|
||||||
|
const used: number[] = [];
|
||||||
|
for (let y = best.y0; y < best.y1; y++) for (let x = best.x0; x < best.x1; x++) used.push(y * g.gw + x);
|
||||||
|
return emit(def, [((best.x0 + best.x1) / 2) * C, ((best.y0 + best.y1) / 2) * C], best.rot ? Math.PI / 2 : rng.chance(0.5) ? 0 : Math.PI, room, used, [], color);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Сетка одинаковых предметов (столики, парты, станки). */
|
||||||
|
const lattice = (room: number, make: () => Def, gapX: number, gapY: number, margin: number, alongLong = true, max = 400, faceRot?: number): number => {
|
||||||
|
const cells = cellsOf.get(room);
|
||||||
|
if (!cells) return 0;
|
||||||
|
const bb = roomBox(room);
|
||||||
|
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
|
||||||
|
const def0 = make();
|
||||||
|
const rotd = alongLong ? !longX : longX;
|
||||||
|
const A = Math.ceil((rotd ? def0.d : def0.w) / C - 1e-6), D = Math.ceil((rotd ? def0.w : def0.d) / C - 1e-6);
|
||||||
|
const sx = A + Math.round(gapX / C), sy = D + Math.round(gapY / C);
|
||||||
|
let placed = 0;
|
||||||
|
for (let y = bb[1] + margin; y + D <= bb[3] - margin && placed < max; y += sy)
|
||||||
|
for (let x = bb[0] + margin; x + A <= bb[2] - margin && placed < max; x += sx) {
|
||||||
|
let ok = true;
|
||||||
|
for (let yy = y - 1; yy < y + D + 1 && ok; yy++)
|
||||||
|
for (let xx = x - 1; xx < x + A + 1 && ok; xx++) {
|
||||||
|
const inner = xx >= x && xx < x + A && yy >= y && yy < y + D;
|
||||||
|
ok = inner ? free(xx, yy, room) : lab(xx, yy) === room && occ[yy * g.gw + xx] !== 1;
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
const used: number[] = [];
|
||||||
|
for (let yy = y; yy < y + D; yy++) for (let xx = x; xx < x + A; xx++) used.push(yy * g.gw + xx);
|
||||||
|
emit(make(), [(x + A / 2) * C, (y + D / 2) * C], faceRot ?? (rotd ? Math.PI / 2 : 0), room, used, []);
|
||||||
|
placed++;
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ряды стеллажей вдоль длинной стороны комнаты с проходами. */
|
||||||
|
const rows = (room: number, make: (L: number) => Def, depth: number, aisle: number, margin: number, maxLen = 12) => {
|
||||||
|
const bb = roomBox(room);
|
||||||
|
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
|
||||||
|
const D = Math.ceil(depth / C), step = D + Math.round(aisle / C);
|
||||||
|
const [a0, a1, b0, b1] = longX ? [bb[1], bb[3], bb[0], bb[2]] : [bb[0], bb[2], bb[1], bb[3]];
|
||||||
|
for (let a = a0 + margin; a + D <= a1 - margin; a += step) {
|
||||||
|
let runStart = -1;
|
||||||
|
const flush = (end: number) => {
|
||||||
|
let s = runStart;
|
||||||
|
while (end - s >= 4) {
|
||||||
|
const len = Math.min(end - s, Math.round(maxLen / C));
|
||||||
|
const L = len * C - 0.1;
|
||||||
|
const used: number[] = [];
|
||||||
|
for (let k = s; k < s + len; k++) for (let dd = a; dd < a + D; dd++) used.push(longX ? dd * g.gw + k : k * g.gw + dd);
|
||||||
|
const pos: Vec = longX ? [((s + s + len) / 2) * C, (a + D / 2) * C] : [(a + D / 2) * C, ((s + s + len) / 2) * C];
|
||||||
|
emit(make(L), pos, longX ? 0 : Math.PI / 2, room, used, []);
|
||||||
|
s += len + 3;
|
||||||
|
}
|
||||||
|
runStart = -1;
|
||||||
|
};
|
||||||
|
for (let k = b0 + margin; k <= b1 - margin; k++) {
|
||||||
|
let ok = k < b1 - margin;
|
||||||
|
for (let dd = a - 1; dd < a + D + 1 && ok; dd++) {
|
||||||
|
const x = longX ? k : dd, y = longX ? dd : k;
|
||||||
|
const inner = dd >= a && dd < a + D;
|
||||||
|
ok = inner ? free(x, y, room, true) : lab(x, y) === room && occ[y * g.gw + x] !== 1;
|
||||||
|
}
|
||||||
|
if (ok && runStart < 0) runStart = k;
|
||||||
|
if (!ok && runStart >= 0) flush(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomBox = (room: number): [number, number, number, number] => {
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const i of cellsOf.get(room) ?? []) {
|
||||||
|
const x = i % g.gw, y = (i / g.gw) | 0;
|
||||||
|
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + 1); y1 = Math.max(y1, y + 1);
|
||||||
|
}
|
||||||
|
return [x0, y0, x1, y1];
|
||||||
|
};
|
||||||
|
const roomPole = (room: number): Vec => {
|
||||||
|
const bb = roomBox(room);
|
||||||
|
return [((bb[0] + bb[2]) / 2) * C, ((bb[1] + bb[3]) / 2) * C];
|
||||||
|
};
|
||||||
|
const areaOf = (room: number) => (cellsOf.get(room)?.length ?? 0) * C * C;
|
||||||
|
const shortSide = (room: number) => { const bb = roomBox(room); return Math.min(bb[2] - bb[0], bb[3] - bb[1]) * C; };
|
||||||
|
|
||||||
|
const use = ctx.building.use ?? '';
|
||||||
|
for (const [room, cells] of cellsOf) {
|
||||||
|
const R = F.rooms[room];
|
||||||
|
if (R.dead || !cells.length) continue;
|
||||||
|
const a = areaOf(room);
|
||||||
|
switch (R.kind) {
|
||||||
|
case 'bedroom': {
|
||||||
|
if (a >= 10 && shortSide(room) >= 2.8) {
|
||||||
|
if (!atWall(room, K.bedWithNightstands())) atWall(room, K.bed(true));
|
||||||
|
} else if (!atWall(room, K.bed(a >= 9), { corner: true })) atWall(room, K.bed(false), { corner: true });
|
||||||
|
atWall(room, K.wardrobe(a > 12 ? 1.8 : 1.2), { corner: true });
|
||||||
|
if (a > 11) atWall(room, K.desk(1.2), { window: true });
|
||||||
|
if (a > 14 && rng.chance(0.5)) atWall(room, K.armchair(), { corner: true });
|
||||||
|
if (rng.chance(0.3)) atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'living': {
|
||||||
|
const studio = a < 30 && !F.rooms.some((r) => r.unit === R.unit && r.kind === 'bedroom' && !r.dead);
|
||||||
|
if (studio) {
|
||||||
|
atWall(room, K.bed(true), { corner: true });
|
||||||
|
if (!F.rooms.some((r) => r.unit === R.unit && r.kind === 'kitchen' && !r.dead)) atWall(room, K.kitchenLine(1.8));
|
||||||
|
}
|
||||||
|
if (!atWall(room, K.sofaSet())) atWall(room, K.sofa(1.8));
|
||||||
|
atWall(room, K.tvStand());
|
||||||
|
if (a > 16) atWall(room, K.armchair(), { corner: true });
|
||||||
|
atWall(room, K.bookshelf(rng.pick([0.8, 1.0, 1.6])));
|
||||||
|
if (a > 20 && !studio) inMiddle(room, K.diningTable(a > 26 ? 6 : 4), 1);
|
||||||
|
atWall(room, K.plant(), { corner: true });
|
||||||
|
if (rng.chance(0.5)) atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'kitchen': {
|
||||||
|
let placed = false;
|
||||||
|
for (const L of [3.6, 3.0, 2.4, 1.8, 1.2]) if (atWall(room, K.kitchenLine(L))) { placed = true; break; }
|
||||||
|
if (!placed) inMiddle(room, K.kitchenLine(1.2), 0);
|
||||||
|
atWall(room, K.fridge(), { corner: true });
|
||||||
|
if (a >= 8) inMiddle(room, K.diningTable(4), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'bath': {
|
||||||
|
if (a >= 4.2 && shortSide(room) >= 1.7) { if (!atWall(room, K.bathtub(), { corner: true })) atWall(room, K.shower(), { corner: true }); }
|
||||||
|
else atWall(room, K.shower(), { corner: true });
|
||||||
|
atWall(room, K.toilet());
|
||||||
|
atWall(room, K.basin());
|
||||||
|
if (a > 6) atWall(room, K.washer());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'wc': {
|
||||||
|
const n = ctx.building.kind === 'house' || a < 5 ? 1 : Math.min(4, Math.floor(a / 2.5));
|
||||||
|
for (let k = 0; k < n; k++) atWall(room, K.toilet(), { corner: k === 0 });
|
||||||
|
atWall(room, K.basin());
|
||||||
|
if (n > 1) atWall(room, K.basin());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hall': {
|
||||||
|
atWall(room, K.coatRack());
|
||||||
|
if (a > 5) atWall(room, K.shoeCabinet());
|
||||||
|
if (a > 9) atWall(room, K.plant(), { corner: true });
|
||||||
|
if (ctx.building.kind === 'civic' && a > 20) { atWall(room, K.counter(2.4)); atWall(room, K.sofa(2.0)); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'landing':
|
||||||
|
if (F.level === 0) atWall(room, K.mailboxes());
|
||||||
|
break;
|
||||||
|
case 'lobby': {
|
||||||
|
atWall(room, K.mailboxes());
|
||||||
|
atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'corridor': {
|
||||||
|
if (rng.chance(0.4)) atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'utility': {
|
||||||
|
atWall(room, K.washer());
|
||||||
|
atWall(room, K.boiler(), { corner: true });
|
||||||
|
atWall(room, K.shelf(1.0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'storage':
|
||||||
|
case 'backroom': {
|
||||||
|
for (let k = 0; k < 8; k++) if (!atWall(room, K.shelf(rng.pick([1.0, 1.2, 1.8])))) break;
|
||||||
|
for (let k = 0; k < (a > 15 ? 3 : 1); k++) inMiddle(room, K.crate(), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sales': {
|
||||||
|
if (['clothes', 'shoes', 'boutique', 'fashion'].includes(use)) {
|
||||||
|
lattice(room, K.clothesRack, 1.0, 1.2, 2);
|
||||||
|
atWall(room, K.counter(1.6));
|
||||||
|
for (let k = 0; k < 6; k++) if (!atWall(room, K.wallShelf(2.0))) break;
|
||||||
|
} else if (['bakery', 'cafe', 'butcher', 'pastry', 'deli', 'confectionery'].includes(use) || a < 25) {
|
||||||
|
if (!atWall(room, K.counter(Math.min(3.0, Math.max(1.2, shortSide(room) - 1))))) inMiddle(room, K.counter(1.2), 1);
|
||||||
|
for (let k = 0; k < 3; k++) if (!atWall(room, K.wallShelf(1.6))) break;
|
||||||
|
} else {
|
||||||
|
// супермаркет / обычный магазин: кассы у входа, стеллажи рядами, холодильники у стен
|
||||||
|
const ent = ctx.doors.find((d) => d.r2 === -1 && d.r1 === room);
|
||||||
|
const nCheck = a > 400 ? 4 : a > 120 ? 2 : 1;
|
||||||
|
for (let k = 0; k < nCheck; k++) inMiddle(room, K.checkout(), 1, ent ? [(ent.a[0] + ent.b[0]) / 2, (ent.a[1] + ent.b[1]) / 2] : undefined);
|
||||||
|
if (use === 'supermarket' || a > 150) for (let k = 0; k < 4; k++) if (!atWall(room, K.fridgeDisplay(2.4))) break;
|
||||||
|
for (let k = 0; k < 10; k++) if (!atWall(room, K.wallShelf(rng.pick([1.8, 2.4])))) break;
|
||||||
|
rows(room, K.gondola, 1.0, 1.6, 2, 10);
|
||||||
|
}
|
||||||
|
if (a > 20) atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dining': {
|
||||||
|
atWall(room, K.counter(Math.min(3.0, Math.max(1.4, shortSide(room) * 0.6))));
|
||||||
|
if (lattice(room, a > 30 && rng.chance(0.5) ? K.cafeTable : () => K.diningTable(4), 0.8, 0.9, 1) === 0) inMiddle(room, K.cafeTable(), 1);
|
||||||
|
atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'prokitchen': {
|
||||||
|
for (const L of [2.4, 1.8, 1.2]) if (atWall(room, K.prepTable(L))) break;
|
||||||
|
atWall(room, K.proStove());
|
||||||
|
atWall(room, K.fridge(), { corner: true });
|
||||||
|
atWall(room, K.fridge(), { corner: true });
|
||||||
|
if (a > 14) inMiddle(room, K.prepTable(1.6), 1);
|
||||||
|
atWall(room, K.shelf(1.2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'office': {
|
||||||
|
atWall(room, K.desk(1.4), { window: true });
|
||||||
|
if (a > 12) atWall(room, K.desk(1.4), { window: true });
|
||||||
|
atWall(room, K.filing(), { corner: true });
|
||||||
|
atWall(room, K.bookshelf(1.0));
|
||||||
|
if (rng.chance(0.6)) atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'openoffice': {
|
||||||
|
lattice(room, K.officeCluster, 1.4, 1.2, 2);
|
||||||
|
for (let k = 0; k < 3; k++) atWall(room, K.filing(), { corner: true });
|
||||||
|
atWall(room, K.plant(), { corner: true });
|
||||||
|
atWall(room, K.plant(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'meeting': {
|
||||||
|
const bb = roomBox(room);
|
||||||
|
const L = Math.max(1.6, Math.min(6, (Math.max(bb[2] - bb[0], bb[3] - bb[1]) * C) - 2.2));
|
||||||
|
if (!inMiddle(room, K.meetingTable(L), 1)) inMiddle(room, K.meetingTable(1.6), 1);
|
||||||
|
atWall(room, K.board(2.0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'staff': {
|
||||||
|
inMiddle(room, K.diningTable(4), 1);
|
||||||
|
atWall(room, K.kitchenLine(1.8));
|
||||||
|
atWall(room, K.sofa(1.8));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'classroom': {
|
||||||
|
atWall(room, K.board(3.0));
|
||||||
|
inMiddle(room, K.teacherDesk(), 1);
|
||||||
|
lattice(room, K.studentDesk, 0.7, 0.5, 2);
|
||||||
|
atWall(room, K.bookshelf(1.6));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ward': {
|
||||||
|
for (let k = 0; k < Math.max(1, Math.min(4, Math.floor(a / 8))); k++) if (!atWall(room, K.hospitalBed())) break;
|
||||||
|
atWall(room, K.nightstand());
|
||||||
|
atWall(room, K.armchair(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'guestroom': {
|
||||||
|
if (!atWall(room, K.bedWithNightstands())) if (!atWall(room, K.bed(true))) atWall(room, K.bed(false));
|
||||||
|
atWall(room, K.desk(1.2), { window: true });
|
||||||
|
atWall(room, K.wardrobe(1.0), { corner: true });
|
||||||
|
atWall(room, K.armchair(), { corner: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'workshop': {
|
||||||
|
for (let k = 0; k < 3; k++) atWall(room, K.workbench(rng.pick([1.6, 2.0])));
|
||||||
|
for (let k = 0; k < 4; k++) atWall(room, K.shelf(1.2));
|
||||||
|
inMiddle(room, K.machine(), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hall_ind': {
|
||||||
|
const kindB = ctx.building.kind;
|
||||||
|
if (kindB === 'warehouse' && !['sports_hall', 'sports_centre'].includes(use)) {
|
||||||
|
rows(room, K.palletRack, 1.1, 3.2, 3, 16);
|
||||||
|
lattice(room, K.pallet, 2.0, 2.0, 2, true, 6);
|
||||||
|
} else if (kindB === 'farm') {
|
||||||
|
lattice(room, K.pallet, 2.5, 2.5, 2, true, 8);
|
||||||
|
for (let k = 0; k < 4; k++) atWall(room, K.shelf(1.8));
|
||||||
|
} else if (['sports_hall', 'sports_centre'].includes(use)) {
|
||||||
|
atWall(room, K.bookshelf(2.0));
|
||||||
|
} else {
|
||||||
|
lattice(room, K.machine, 2.4, 2.6, 3, true, 40);
|
||||||
|
for (let k = 0; k < 4; k++) atWall(room, K.workbench(2.0));
|
||||||
|
for (let k = 0; k < 6; k++) atWall(room, K.shelf(1.8));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'nave': {
|
||||||
|
// алтарь у дальней от входа стены, скамьи рядами поперёк
|
||||||
|
const ent = ctx.doors.find((d) => d.r2 === -1);
|
||||||
|
const bb = roomBox(room);
|
||||||
|
const longX = bb[2] - bb[0] >= bb[3] - bb[1];
|
||||||
|
const cMid: Vec = [((bb[0] + bb[2]) / 2) * C, ((bb[1] + bb[3]) / 2) * C];
|
||||||
|
let far: Vec = cMid;
|
||||||
|
if (ent) {
|
||||||
|
const em: Vec = [(ent.a[0] + ent.b[0]) / 2, (ent.a[1] + ent.b[1]) / 2];
|
||||||
|
far = longX ? [em[0] < cMid[0] ? (bb[2] - 3) * C : (bb[0] + 3) * C, cMid[1]] : [cMid[0], em[1] < cMid[1] ? (bb[3] - 3) * C : (bb[1] + 3) * C];
|
||||||
|
}
|
||||||
|
inMiddle(room, K.altar(), 1, far);
|
||||||
|
const width = (longX ? bb[3] - bb[1] : bb[2] - bb[0]) * C;
|
||||||
|
const L = Math.max(1.5, Math.min(5, (width - 2.4) / 2));
|
||||||
|
lattice(room, () => K.pew(L), 1.6, 0.5, 2, false, 60, longX ? Math.PI / 2 : 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'garage': {
|
||||||
|
if (a > 14 && shortSide(room) >= 2.6) inMiddle(room, K.carDef(), 0, undefined, rng.pick(CAR_COLORS));
|
||||||
|
atWall(room, K.workbench(1.6));
|
||||||
|
atWall(room, K.shelf(1.2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OsmElement, BBox } from '../osm/overpass';
|
||||||
|
import type { World } from '../types';
|
||||||
|
import { buildCity, type InteriorJob } from './city';
|
||||||
|
import { generateInterior, type InteriorResult } from './interior';
|
||||||
|
import { finalizeWorld } from './finalize';
|
||||||
|
|
||||||
|
export type Progress = (stage: string, done: number, total: number) => void;
|
||||||
|
|
||||||
|
/** Применить результат генерации интерьера к зданию. */
|
||||||
|
export function applyInterior(world: World, buildingId: string, r: InteriorResult) {
|
||||||
|
const b = world.buildings.find((x) => x.id === buildingId);
|
||||||
|
if (!b) return;
|
||||||
|
b.floors = r.floors;
|
||||||
|
b.units = r.units;
|
||||||
|
b.beds = r.beds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Синхронная генерация целиком (для CLI и тестов; в браузере интерьеры считаются в воркерах). */
|
||||||
|
export function generateWorldSync(elements: OsmElement[], bbox: BBox, name: string, seed: number, progress?: Progress): World {
|
||||||
|
const { world, jobs } = buildCity(elements, bbox, name, seed);
|
||||||
|
jobs.forEach((job, i) => {
|
||||||
|
progress?.('interiors', i, jobs.length);
|
||||||
|
try {
|
||||||
|
const r = generateInterior(job);
|
||||||
|
job.building.floors = r.floors;
|
||||||
|
job.building.units = r.units;
|
||||||
|
job.building.beds = r.beds;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('interior failed', job.building.id, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finalizeWorld(world);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { InteriorJob };
|
||||||
+1446
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
|||||||
|
// Полный конвейер в браузере: OSM → город → интерьеры в пуле воркеров → упаковка.
|
||||||
|
|
||||||
|
import type { OsmElement, BBox } from '../osm/overpass';
|
||||||
|
import type { World } from '../types';
|
||||||
|
import { buildCity, type InteriorJob } from './city';
|
||||||
|
import { finalizeWorld } from './finalize';
|
||||||
|
|
||||||
|
export type OnProgress = (text: string, frac: number) => void;
|
||||||
|
|
||||||
|
export async function generateWorld(elements: OsmElement[], bbox: BBox, name: string, seed: number, onProgress: OnProgress, signal?: AbortSignal): Promise<World> {
|
||||||
|
onProgress('Строю улицы, зоны и контуры зданий…', 0.05);
|
||||||
|
await tick();
|
||||||
|
const { world, jobs } = buildCity(elements, bbox, name, seed);
|
||||||
|
finalizeWorld(world, false);
|
||||||
|
onProgress(`Планировки: 0 / ${jobs.length}`, 0.1);
|
||||||
|
await runInteriors(world, jobs, (done) => onProgress(`Планировки: ${done} / ${jobs.length} зданий`, 0.1 + 0.88 * (done / Math.max(1, jobs.length))), signal);
|
||||||
|
onProgress('Готово', 1);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
async function runInteriors(world: World, jobs: InteriorJob[], onDone: (n: number) => void, signal?: AbortSignal) {
|
||||||
|
const byId = new Map(world.buildings.map((b) => [b.id, b]));
|
||||||
|
// крупные здания раньше — меньше «хвост» в конце
|
||||||
|
const queue = jobs.slice().sort((a, b) => b.building.area * b.building.levels - a.building.area * a.building.levels);
|
||||||
|
const nWorkers = Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));
|
||||||
|
const workers: Worker[] = [];
|
||||||
|
let done = 0;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < nWorkers; i++) {
|
||||||
|
const w = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
|
||||||
|
w.postMessage({ type: 'init', index: i });
|
||||||
|
workers.push(w);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Workers unavailable, generating on main thread', e);
|
||||||
|
}
|
||||||
|
if (!workers.length) {
|
||||||
|
const { generateInterior } = await import('./interior');
|
||||||
|
const { finalizeBuilding, ModelLibrary } = await import('./finalize');
|
||||||
|
const lib = new ModelLibrary(world.models, 'm_');
|
||||||
|
for (const job of queue) {
|
||||||
|
const r = generateInterior(job);
|
||||||
|
Object.assign(job.building, r);
|
||||||
|
finalizeBuilding(job.building, lib);
|
||||||
|
onDone(++done);
|
||||||
|
if (done % 20 === 0) await tick();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let batchNo = 0;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let active = 0;
|
||||||
|
const feed = (w: Worker) => {
|
||||||
|
if (signal?.aborted) return reject(new DOMException('Отменено', 'AbortError'));
|
||||||
|
if (!queue.length) {
|
||||||
|
if (active === 0) resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// небольшие пачки: крупные здания по одному
|
||||||
|
const batch: InteriorJob[] = [];
|
||||||
|
let cost = 0;
|
||||||
|
while (queue.length && cost < 3000) {
|
||||||
|
const j = queue.shift()!;
|
||||||
|
batch.push(j);
|
||||||
|
cost += j.building.area * j.building.levels;
|
||||||
|
if (batch.length >= 30) break;
|
||||||
|
}
|
||||||
|
active++;
|
||||||
|
w.postMessage({ type: 'jobs', batch: batchNo++, jobs: batch });
|
||||||
|
};
|
||||||
|
for (const w of workers) {
|
||||||
|
w.onmessage = (ev) => {
|
||||||
|
const m = ev.data;
|
||||||
|
if (m.type !== 'done') return;
|
||||||
|
Object.assign(world.models, m.models);
|
||||||
|
for (const r of m.results) {
|
||||||
|
const b = byId.get(r.id);
|
||||||
|
if (!b) continue;
|
||||||
|
b.floors = r.floors;
|
||||||
|
b.units = r.units;
|
||||||
|
b.beds = r.beds;
|
||||||
|
b.footprint = r.footprint;
|
||||||
|
b.holes = r.holes;
|
||||||
|
b.area = r.area;
|
||||||
|
}
|
||||||
|
done += m.results.length;
|
||||||
|
onDone(done);
|
||||||
|
active--;
|
||||||
|
feed(w);
|
||||||
|
};
|
||||||
|
w.onerror = (e) => reject(new Error('Ошибка в воркере: ' + e.message));
|
||||||
|
feed(w);
|
||||||
|
}
|
||||||
|
}).finally(() => workers.forEach((w) => w.terminate()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Vec } from '../types';
|
||||||
|
|
||||||
|
const R = 6378137;
|
||||||
|
const D2R = Math.PI / 180;
|
||||||
|
|
||||||
|
/** Локальная равнопромежуточная проекция вокруг центра области: для нескольких км погрешность < 0.1%. */
|
||||||
|
export class Projector {
|
||||||
|
readonly k: number;
|
||||||
|
constructor(readonly lat0: number, readonly lon0: number) {
|
||||||
|
this.k = Math.cos(lat0 * D2R);
|
||||||
|
}
|
||||||
|
toXY(lat: number, lon: number): Vec {
|
||||||
|
return [R * (lon - this.lon0) * D2R * this.k, -R * (lat - this.lat0) * D2R];
|
||||||
|
}
|
||||||
|
toLatLon(p: Vec): { lat: number; lon: number } {
|
||||||
|
return { lat: this.lat0 - p[1] / R / D2R, lon: this.lon0 + p[0] / (R * D2R * this.k) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Prop, Shape, Vec } from '../types';
|
||||||
|
import { Rng } from '../util/rng';
|
||||||
|
|
||||||
|
export const CAR_COLORS = ['#c94c4c', '#3d6fb6', '#e8e8e8', '#2b2b2b', '#8c9298', '#d9c27a', '#4f8a5b', '#b5b9bd', '#6b4a8c', '#e07b39'];
|
||||||
|
|
||||||
|
export function carShapes(): Shape[] {
|
||||||
|
return [
|
||||||
|
{ t: 'rect', x: 0, y: 0, w: 1.8, h: 4.4, r: 0.45, s: 'car' },
|
||||||
|
{ t: 'rect', x: 0, y: -0.75, w: 1.5, h: 0.55, r: 0.15, s: 'glass' }, // лобовое
|
||||||
|
{ t: 'rect', x: 0, y: 1.25, w: 1.45, h: 0.4, r: 0.12, s: 'glass' }, // заднее
|
||||||
|
{ t: 'rect', x: 0, y: 0.25, w: 1.5, h: 1.4, r: 0.2, s: 'dark' }, // крыша
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treeProp(id: string, p: Vec, rng: Rng, t: Record<string, string>): Prop {
|
||||||
|
const scrub = !!t.scrub;
|
||||||
|
const r0 = scrub ? rng.range(0.8, 1.6) : t.orchard ? rng.range(1.6, 2.4) : rng.range(2.2, 4.2);
|
||||||
|
const r = Math.round(r0 * 5) / 5; // немного вариантов — общие модели
|
||||||
|
const shapes: Shape[] = [{ t: 'circle', x: 0, y: 0, r, s: 'green' }];
|
||||||
|
if (!scrub) shapes.push({ t: 'circle', x: 0, y: 0, r: 0.18, s: 'wood' });
|
||||||
|
return {
|
||||||
|
id, kind: scrub ? 'bush' : 'tree', pos: p, rot: 0, w: r * 2, d: r * 2, shapes,
|
||||||
|
// крона не мешает проходу, мешает только ствол; обзор частично закрывает
|
||||||
|
blocksMove: !scrub, blocksSight: false, tags: scrub ? ['cover'] : ['trunk:0.2'],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Web Worker: генерирует интерьеры пачками зданий.
|
||||||
|
import { generateInterior } from './interior';
|
||||||
|
import { finalizeBuilding, ModelLibrary } from './finalize';
|
||||||
|
import type { InteriorJob } from './city';
|
||||||
|
|
||||||
|
let lib = new ModelLibrary({}, 'w0_');
|
||||||
|
|
||||||
|
self.onmessage = (ev: MessageEvent) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
lib = new ModelLibrary({}, `w${msg.index}_`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'jobs') {
|
||||||
|
const results = (msg.jobs as InteriorJob[]).map((job) => {
|
||||||
|
const b = job.building;
|
||||||
|
try {
|
||||||
|
const r = generateInterior(job);
|
||||||
|
b.floors = r.floors;
|
||||||
|
b.units = r.units;
|
||||||
|
b.beds = r.beds;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('interior failed', b.id, e);
|
||||||
|
b.floors = [];
|
||||||
|
}
|
||||||
|
finalizeBuilding(b, lib);
|
||||||
|
return { id: b.id, floors: b.floors, units: b.units, beds: b.beds, footprint: b.footprint, holes: b.holes, area: b.area };
|
||||||
|
});
|
||||||
|
(self as unknown as Worker).postMessage({ type: 'done', batch: msg.batch, results, models: lib.takeNew() });
|
||||||
|
}
|
||||||
|
};
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
import { AreaPicker, bboxSizeKm } from './view/picker';
|
||||||
|
import { Viewer } from './view/viewer';
|
||||||
|
import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpass';
|
||||||
|
import { generateWorld } from './gen/pipeline';
|
||||||
|
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||||||
|
import type { World } from './types';
|
||||||
|
|
||||||
|
const MAX_KM2 = 6;
|
||||||
|
const WARN_KM2 = 2.5;
|
||||||
|
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
let viewer: Viewer | null = null;
|
||||||
|
let picker: AreaPicker | null = null;
|
||||||
|
|
||||||
|
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ прогресс
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'overlay';
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML = `<div class="card progress-card"><div class="p-title">Генерация</div><div class="p-text"></div><div class="bar"><i></i></div><button class="btn" data-a="cancel">Отмена</button></div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
let abort: AbortController | null = null;
|
||||||
|
overlay.querySelector('[data-a=cancel]')!.addEventListener('click', () => abort?.abort());
|
||||||
|
function progress(text: string, frac: number) {
|
||||||
|
overlay.hidden = false;
|
||||||
|
overlay.querySelector('.p-text')!.textContent = text;
|
||||||
|
(overlay.querySelector('.bar i') as HTMLElement).style.width = `${Math.round(frac * 100)}%`;
|
||||||
|
}
|
||||||
|
const hideProgress = () => (overlay.hidden = true);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ экраны
|
||||||
|
|
||||||
|
function showViewer(world: World) {
|
||||||
|
picker = null;
|
||||||
|
app.innerHTML = '';
|
||||||
|
viewer?.destroy();
|
||||||
|
viewer = new Viewer(app, world, {
|
||||||
|
onNew: () => showPicker(world.bbox),
|
||||||
|
onExport: () => downloadWorld(world),
|
||||||
|
});
|
||||||
|
(window as any).viewer = viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPicker(initial?: BBox) {
|
||||||
|
viewer?.destroy();
|
||||||
|
viewer = null;
|
||||||
|
app.innerHTML = '';
|
||||||
|
const stage = document.createElement('div');
|
||||||
|
stage.className = 'picker-stage';
|
||||||
|
app.appendChild(stage);
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'picker-panel card';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="brand">Город сверху</div>
|
||||||
|
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||||||
|
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||||||
|
<div class="results"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" data-a="select">▭ Выделить область</button>
|
||||||
|
<button class="btn ghost" data-a="quick">1×1 км в центре</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Или перетаскивайте с зажатым Shift.</div>
|
||||||
|
<div class="sel-info">Область не выбрана</div>
|
||||||
|
<label class="field">Название <input type="text" data-f="name" placeholder="например, Балцерс"></label>
|
||||||
|
<button class="btn primary" data-a="go" disabled>Сгенерировать</button>
|
||||||
|
<details class="more"><summary>Другие способы</summary>
|
||||||
|
<div class="col">
|
||||||
|
<button class="btn ghost" data-a="last" hidden>Открыть последний мир</button>
|
||||||
|
<label class="btn ghost file">Открыть мир (.json / .json.gz)<input type="file" accept=".json,.gz" data-f="world" hidden></label>
|
||||||
|
<label class="btn ghost file">Сгенерировать из выгрузки Overpass (.json)<input type="file" accept=".json,.gz" data-f="osm" hidden></label>
|
||||||
|
<div class="hint">Выгрузка Overpass должна быть в формате <code>[out:json] … out geom;</code>. Область берётся из выделения или из данных.</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="err" hidden></div>`;
|
||||||
|
stage.appendChild(panel);
|
||||||
|
const mapBox = document.createElement('div');
|
||||||
|
mapBox.className = 'map-box';
|
||||||
|
stage.appendChild(mapBox);
|
||||||
|
mapBox.insertAdjacentHTML('beforeend', '<div class="attribution">Карта © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors</div>');
|
||||||
|
|
||||||
|
const $ = <T extends Element>(s: string) => panel.querySelector(s) as T;
|
||||||
|
const nameInput = $<HTMLInputElement>('[data-f=name]');
|
||||||
|
const goBtn = $<HTMLButtonElement>('[data-a=go]');
|
||||||
|
const selBtn = $<HTMLButtonElement>('[data-a=select]');
|
||||||
|
const err = $<HTMLDivElement>('.err');
|
||||||
|
const showErr = (m: string | null) => { err.hidden = !m; err.textContent = m ?? ''; };
|
||||||
|
|
||||||
|
let bbox: BBox | null = null;
|
||||||
|
picker = new AreaPicker(mapBox, (b) => {
|
||||||
|
bbox = b;
|
||||||
|
const info = $<HTMLDivElement>('.sel-info');
|
||||||
|
if (!b) {
|
||||||
|
info.textContent = 'Область не выбрана';
|
||||||
|
info.className = 'sel-info';
|
||||||
|
goBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = bboxSizeKm(b);
|
||||||
|
info.textContent = `${s.w.toFixed(2)} × ${s.h.toFixed(2)} км · ${s.km2.toFixed(2)} км²` + (s.km2 > MAX_KM2 ? ' — слишком большая область' : s.km2 > WARN_KM2 ? ' — большая область, генерация займёт время' : '');
|
||||||
|
info.className = 'sel-info' + (s.km2 > MAX_KM2 ? ' bad' : s.km2 > WARN_KM2 ? ' warn' : ' ok');
|
||||||
|
goBtn.disabled = s.km2 > MAX_KM2;
|
||||||
|
});
|
||||||
|
if (initial) picker.setSelection(initial);
|
||||||
|
else picker.fit([47.06, 9.49, 47.075, 9.52]);
|
||||||
|
|
||||||
|
selBtn.addEventListener('click', () => {
|
||||||
|
picker!.selectMode = !picker!.selectMode;
|
||||||
|
selBtn.classList.toggle('on', picker!.selectMode);
|
||||||
|
});
|
||||||
|
$<HTMLButtonElement>('[data-a=quick]').addEventListener('click', () => picker!.selectAroundCenter(1));
|
||||||
|
|
||||||
|
const form = $<HTMLFormElement>('.search');
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = (form.querySelector('input') as HTMLInputElement).value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const box = $<HTMLDivElement>('.results');
|
||||||
|
box.innerHTML = '<div class="hint">Ищу…</div>';
|
||||||
|
try {
|
||||||
|
const res = await geocode(q);
|
||||||
|
box.innerHTML = res.length ? '' : '<div class="hint">Ничего не найдено</div>';
|
||||||
|
for (const r of res) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'result';
|
||||||
|
b.textContent = r.name;
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const s = bboxSizeKm(r.bbox);
|
||||||
|
picker!.fit(r.bbox);
|
||||||
|
if (s.km2 <= MAX_KM2 && s.km2 > 0.02) picker!.setSelection(r.bbox, false);
|
||||||
|
else {
|
||||||
|
picker!.fit([r.lat - 0.01, r.lon - 0.015, r.lat + 0.01, r.lon + 0.015]);
|
||||||
|
picker!.selectAroundCenter(1.2);
|
||||||
|
}
|
||||||
|
nameInput.value = r.name.split(',')[0];
|
||||||
|
box.innerHTML = '';
|
||||||
|
});
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
box.innerHTML = `<div class="hint">${esc((ex as Error).message)}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
goBtn.addEventListener('click', async () => {
|
||||||
|
if (!bbox) return;
|
||||||
|
showErr(null);
|
||||||
|
abort = new AbortController();
|
||||||
|
try {
|
||||||
|
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||||
|
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||||
|
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||||
|
} catch (ex) {
|
||||||
|
hideProgress();
|
||||||
|
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastBtn = $<HTMLButtonElement>('[data-a=last]');
|
||||||
|
if (await hasWorld()) lastBtn.hidden = false;
|
||||||
|
lastBtn.addEventListener('click', async () => {
|
||||||
|
progress('Загрузка сохранённого мира…', 0.5);
|
||||||
|
const w = await loadWorld();
|
||||||
|
hideProgress();
|
||||||
|
if (w) showViewer(w);
|
||||||
|
});
|
||||||
|
$<HTMLInputElement>('[data-f=world]').addEventListener('change', async (e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
try {
|
||||||
|
progress('Чтение файла…', 0.4);
|
||||||
|
const w = (await readJsonFile(f)) as World;
|
||||||
|
if (!w.buildings || !w.bounds) throw new Error('Это не файл мира');
|
||||||
|
hideProgress();
|
||||||
|
showViewer(w);
|
||||||
|
} catch (ex) {
|
||||||
|
hideProgress();
|
||||||
|
showErr((ex as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$<HTMLInputElement>('[data-f=osm]').addEventListener('change', async (e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
try {
|
||||||
|
progress('Чтение выгрузки…', 0.03);
|
||||||
|
const data = await readJsonFile(f);
|
||||||
|
const els: OsmElement[] = data.elements;
|
||||||
|
if (!Array.isArray(els)) throw new Error('В файле нет поля elements');
|
||||||
|
const b = bbox ?? bboxOfElements(els);
|
||||||
|
abort = new AbortController();
|
||||||
|
await runGeneration(els, b, nameInput.value.trim() || f.name.replace(/\.json.*$/, ''));
|
||||||
|
} catch (ex) {
|
||||||
|
hideProgress();
|
||||||
|
showErr((ex as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxOfElements(els: OsmElement[]): BBox {
|
||||||
|
let s = 90, w = 180, n = -90, e = -180;
|
||||||
|
for (const el of els)
|
||||||
|
if (el.type === 'node' && el.lat != null && el.lon != null) {
|
||||||
|
s = Math.min(s, el.lat); n = Math.max(n, el.lat); w = Math.min(w, el.lon); e = Math.max(e, el.lon);
|
||||||
|
} else if (el.geometry) for (const g of el.geometry) { s = Math.min(s, g.lat); n = Math.max(n, g.lat); w = Math.min(w, g.lon); e = Math.max(e, g.lon); }
|
||||||
|
return [s, w, n, e];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
|
||||||
|
// постоянный сид: один и тот же город всегда генерируется одинаково (сид здания = OSM-id ⊕ сид мира)
|
||||||
|
const seed = 1;
|
||||||
|
const t0 = performance.now();
|
||||||
|
const world = await generateWorld(els, bbox, name, seed, progress, abort?.signal);
|
||||||
|
console.info(`world generated in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||||||
|
progress('Сохранение…', 1);
|
||||||
|
try {
|
||||||
|
await saveWorld(world);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('save failed', e);
|
||||||
|
}
|
||||||
|
hideProgress();
|
||||||
|
showViewer(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ старт
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.has('last') && (await hasWorld())) {
|
||||||
|
const w = await loadWorld();
|
||||||
|
if (w) return showViewer(w);
|
||||||
|
}
|
||||||
|
showPicker();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// для отладки из консоли
|
||||||
|
(window as any).citygen = { runGeneration, showViewer };
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Загрузка данных OSM через Overpass API (вызывается прямо из браузера, CORS разрешён).
|
||||||
|
// Для публичной игры с множеством игроков нужно своё зеркало или статическая нарезка —
|
||||||
|
// публичные серверы Overpass рассчитаны на умеренную нагрузку.
|
||||||
|
|
||||||
|
export type BBox = [number, number, number, number]; // south, west, north, east
|
||||||
|
|
||||||
|
export interface OsmElement {
|
||||||
|
type: 'node' | 'way' | 'relation';
|
||||||
|
id: number;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
geometry?: { lat: number; lon: number }[];
|
||||||
|
members?: { type: string; ref: number; role: string; geometry?: { lat: number; lon: number }[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OVERPASS_ENDPOINTS = [
|
||||||
|
'https://overpass-api.de/api/interpreter',
|
||||||
|
'https://overpass.private.coffee/api/interpreter',
|
||||||
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildQuery(b: BBox): string {
|
||||||
|
const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`;
|
||||||
|
return `[out:json][timeout:120];
|
||||||
|
(
|
||||||
|
way["building"]${bb};
|
||||||
|
relation["building"]["type"="multipolygon"]${bb};
|
||||||
|
way["highway"]${bb};
|
||||||
|
way["railway"~"^(rail|tram|light_rail|narrow_gauge|subway|platform)$"]${bb};
|
||||||
|
way["waterway"]${bb};
|
||||||
|
way["landuse"]${bb};
|
||||||
|
relation["landuse"]${bb};
|
||||||
|
way["natural"]${bb};
|
||||||
|
relation["natural"]${bb};
|
||||||
|
way["leisure"]${bb};
|
||||||
|
relation["leisure"]${bb};
|
||||||
|
way["amenity"]${bb};
|
||||||
|
relation["amenity"]${bb};
|
||||||
|
way["place"~"square"]${bb};
|
||||||
|
way["area:highway"]${bb};
|
||||||
|
node["natural"="tree"]${bb};
|
||||||
|
node["shop"]${bb};
|
||||||
|
node["amenity"]${bb};
|
||||||
|
node["office"]${bb};
|
||||||
|
node["craft"]${bb};
|
||||||
|
node["tourism"]${bb};
|
||||||
|
node["healthcare"]${bb};
|
||||||
|
node["highway"="street_lamp"]${bb};
|
||||||
|
);
|
||||||
|
out geom;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise<OsmElement[]> {
|
||||||
|
const query = buildQuery(b);
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (const url of OVERPASS_ENDPOINTS) {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''}…`);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'data=' + encodeURIComponent(query),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (res.status === 429 || res.status === 504) {
|
||||||
|
lastErr = new Error(`Сервер перегружен (${res.status})`);
|
||||||
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.remark && /error|timed out|runtime/i.test(json.remark)) throw new Error(json.remark);
|
||||||
|
return json.elements as OsmElement[];
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted) throw e;
|
||||||
|
lastErr = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Не удалось загрузить данные OSM: ' + (lastErr instanceof Error ? lastErr.message : String(lastErr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeocodeResult {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
bbox: BBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск места через Nominatim (не чаще раза в секунду по правилам сервиса). */
|
||||||
|
export async function geocode(q: string): Promise<GeocodeResult[]> {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=ru&q=${encodeURIComponent(q)}`;
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('Поиск недоступен: HTTP ' + res.status);
|
||||||
|
const arr = await res.json();
|
||||||
|
return arr.map((r: any) => ({
|
||||||
|
name: r.display_name,
|
||||||
|
lat: +r.lat,
|
||||||
|
lon: +r.lon,
|
||||||
|
bbox: [+r.boundingbox[0], +r.boundingbox[2], +r.boundingbox[1], +r.boundingbox[3]] as BBox,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Хранение миров в IndexedDB (локально в браузере).
|
||||||
|
import type { World } from './types';
|
||||||
|
|
||||||
|
const DB = 'citygen';
|
||||||
|
const STORE = 'worlds';
|
||||||
|
|
||||||
|
function open(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB, 1);
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||||
|
const db = await open();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = db.transaction(STORE, mode);
|
||||||
|
const req = fn(t.objectStore(STORE));
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveWorld = (w: World) => tx('readwrite', (s) => s.put(w, 'last'));
|
||||||
|
export const loadWorld = () => tx<World | undefined>('readonly', (s) => s.get('last'));
|
||||||
|
export const hasWorld = async () => {
|
||||||
|
try {
|
||||||
|
return (await tx<number>('readonly', (s) => s.count('last'))) > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Скачать мир файлом (.json.gz, если браузер умеет сжимать). */
|
||||||
|
export async function downloadWorld(w: World) {
|
||||||
|
const json = JSON.stringify(w);
|
||||||
|
let blob: Blob = new Blob([json], { type: 'application/json' });
|
||||||
|
let ext = 'json';
|
||||||
|
if ('CompressionStream' in window) {
|
||||||
|
const cs = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
||||||
|
blob = await new Response(cs).blob();
|
||||||
|
ext = 'json.gz';
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${(w.name || 'world').replace(/[^\p{L}\p{N}_-]+/gu, '_').slice(0, 40)}.${ext}`;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать файл мира или Overpass JSON (поддерживает .gz). */
|
||||||
|
export async function readJsonFile(file: File): Promise<any> {
|
||||||
|
let stream: ReadableStream = file.stream();
|
||||||
|
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||||
|
if (head[0] === 0x1f && head[1] === 0x8b) stream = stream.pipeThrough(new DecompressionStream('gzip'));
|
||||||
|
return JSON.parse(await new Response(stream).text());
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
// Модель мира. Все координаты в метрах в локальной проекции мира:
|
||||||
|
// x — на восток, y — на юг (как на экране). Углы в радианах.
|
||||||
|
// Модель с самого начала рассчитана на будущую симуляцию: у всего есть id,
|
||||||
|
// двери и лестницы — это связи между комнатами (граф навигации),
|
||||||
|
// у объектов есть флаги «блокирует проход / блокирует обзор».
|
||||||
|
|
||||||
|
export type Vec = [number, number];
|
||||||
|
export type Ring = Vec[];
|
||||||
|
|
||||||
|
export interface World {
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
seed: number;
|
||||||
|
/** Центр проекции и исходная область в градусах [south, west, north, east]. */
|
||||||
|
origin: { lat: number; lon: number };
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
/** Границы области в метрах. */
|
||||||
|
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
areas: Area[];
|
||||||
|
roads: Road[];
|
||||||
|
buildings: Building[];
|
||||||
|
/** Уличные объекты: деревья, машины, скамейки, фонари. */
|
||||||
|
props: Prop[];
|
||||||
|
/** Библиотека общих моделей предметов: prop.model → набор фигур. */
|
||||||
|
models: Record<string, Shape[]>;
|
||||||
|
attribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaKind =
|
||||||
|
| 'residential' | 'commercial' | 'industrial' | 'retail' | 'farmland' | 'farmyard'
|
||||||
|
| 'grass' | 'park' | 'forest' | 'scrub' | 'meadow' | 'orchard' | 'vineyard'
|
||||||
|
| 'water' | 'wetland' | 'beach' | 'sand' | 'parking' | 'pitch' | 'playground'
|
||||||
|
| 'cemetery' | 'construction' | 'railway' | 'school' | 'hospital' | 'pedestrian' | 'garden';
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
id: string;
|
||||||
|
kind: AreaKind;
|
||||||
|
/** Первое кольцо — внешнее, остальные — дыры. */
|
||||||
|
rings: Ring[];
|
||||||
|
osmId?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoadKind =
|
||||||
|
| 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'residential'
|
||||||
|
| 'unclassified' | 'service' | 'living_street' | 'pedestrian' | 'track'
|
||||||
|
| 'footway' | 'cycleway' | 'path' | 'steps' | 'rail' | 'tram' | 'river' | 'stream' | 'canal' | 'ditch';
|
||||||
|
|
||||||
|
export interface Road {
|
||||||
|
id: string;
|
||||||
|
kind: RoadKind;
|
||||||
|
pts: Vec[];
|
||||||
|
/** Ширина проезжей части (или русла) в метрах. */
|
||||||
|
width: number;
|
||||||
|
/** Ширина тротуара с каждой стороны, 0 — нет. */
|
||||||
|
sidewalk: number;
|
||||||
|
lanes: number;
|
||||||
|
oneway: boolean;
|
||||||
|
layer: number;
|
||||||
|
bridge: boolean;
|
||||||
|
tunnel: boolean;
|
||||||
|
name?: string;
|
||||||
|
osmId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildingKind =
|
||||||
|
| 'house' | 'apartments' | 'mixed' | 'retail' | 'restaurant' | 'office' | 'industrial'
|
||||||
|
| 'warehouse' | 'school' | 'hospital' | 'hotel' | 'church' | 'civic' | 'garage' | 'shed' | 'farm';
|
||||||
|
|
||||||
|
export interface Building {
|
||||||
|
id: string;
|
||||||
|
osmId?: string;
|
||||||
|
kind: BuildingKind;
|
||||||
|
/** Подтип из OSM-тегов/POI: 'supermarket', 'bakery', 'pharmacy'… */
|
||||||
|
use?: string;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
footprint: Ring;
|
||||||
|
/** Внутренние дворы (дыры в контуре). */
|
||||||
|
holes: Ring[];
|
||||||
|
/** Площадь контура, м². */
|
||||||
|
area: number;
|
||||||
|
levels: number;
|
||||||
|
/** Откуда взялась этажность: 'osm' | 'height' | 'estimate'. */
|
||||||
|
levelsSource: 'osm' | 'height' | 'estimate';
|
||||||
|
/** Локальная система здания: поворот сетки планировки. */
|
||||||
|
frame: { origin: Vec; angle: number };
|
||||||
|
floors: Floor[];
|
||||||
|
/** Помещения-юниты: квартиры, магазины, офисы — будущие «домохозяйства» и «рабочие места». */
|
||||||
|
units: Unit[];
|
||||||
|
/** Сколько спальных мест (оценка жильцов). */
|
||||||
|
beds: number;
|
||||||
|
seed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Unit {
|
||||||
|
id: string;
|
||||||
|
kind: 'home' | 'shop' | 'office' | 'service' | 'common';
|
||||||
|
level: number;
|
||||||
|
roomIds: string[];
|
||||||
|
beds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomKind =
|
||||||
|
| 'hall' | 'landing' | 'living' | 'kitchen' | 'bedroom' | 'bath' | 'wc' | 'storage' | 'corridor'
|
||||||
|
| 'stairs' | 'lift' | 'lobby' | 'sales' | 'backroom' | 'office' | 'openoffice' | 'meeting'
|
||||||
|
| 'dining' | 'prokitchen' | 'workshop' | 'hall_ind' | 'classroom' | 'staff' | 'ward'
|
||||||
|
| 'guestroom' | 'nave' | 'garage' | 'utility' | 'void';
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
kind: RoomKind;
|
||||||
|
/** Контур комнаты (может быть несколько колец; дыры — по чётности). Рисовать с клипом по контуру здания. */
|
||||||
|
rings: Ring[];
|
||||||
|
area: number;
|
||||||
|
labelPos: Vec;
|
||||||
|
unitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wall {
|
||||||
|
a: Vec;
|
||||||
|
b: Vec;
|
||||||
|
thickness: number;
|
||||||
|
exterior: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Door {
|
||||||
|
id: string;
|
||||||
|
a: Vec;
|
||||||
|
b: Vec;
|
||||||
|
/** Комнаты по обе стороны; 'outside' — улица. Это ребро графа навигации. */
|
||||||
|
rooms: [string, string];
|
||||||
|
/** 'opening' — проём без полотна (арка, выход на лестницу). */
|
||||||
|
kind: 'interior' | 'entrance' | 'unit' | 'garage' | 'opening';
|
||||||
|
/** Сторона, куда открывается полотно (точка внутри комнаты). */
|
||||||
|
swingTo: Vec;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Window {
|
||||||
|
a: Vec;
|
||||||
|
b: Vec;
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stairs {
|
||||||
|
id: string;
|
||||||
|
rings: Ring[];
|
||||||
|
/** Ось марша: от низа к верху. */
|
||||||
|
from: Vec;
|
||||||
|
to: Vec;
|
||||||
|
width: number;
|
||||||
|
up: boolean;
|
||||||
|
down: boolean;
|
||||||
|
lift?: boolean;
|
||||||
|
/** id комнаты-лестничной клетки на этом этаже. */
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Floor {
|
||||||
|
level: number;
|
||||||
|
rooms: Room[];
|
||||||
|
walls: Wall[];
|
||||||
|
doors: Door[];
|
||||||
|
windows: Window[];
|
||||||
|
stairs: Stairs[];
|
||||||
|
props: Prop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Примитив в локальных координатах предмета (центр предмета в 0,0; «спинка» к стене при y<0). */
|
||||||
|
export type Shape =
|
||||||
|
| { t: 'rect'; x: number; y: number; w: number; h: number; r?: number; s?: ShapeStyle }
|
||||||
|
| { t: 'circle'; x: number; y: number; r: number; s?: ShapeStyle }
|
||||||
|
| { t: 'line'; x1: number; y1: number; x2: number; y2: number; s?: ShapeStyle };
|
||||||
|
|
||||||
|
/** Стиль: 'base' — основной предмет, 'soft' — ткань/подушка, 'dark' — техника/акцент, 'green' — растение, 'glass', 'metal'. */
|
||||||
|
export type ShapeStyle = 'base' | 'soft' | 'dark' | 'green' | 'glass' | 'metal' | 'wood' | 'water' | 'line' | 'car' | 'fabric' | 'accent';
|
||||||
|
|
||||||
|
export interface Prop {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
pos: Vec;
|
||||||
|
rot: number;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
/** Фигуры предмета; после сборки мира обычно вынесены в world.models (см. model). */
|
||||||
|
shapes?: Shape[];
|
||||||
|
model?: string;
|
||||||
|
blocksMove: boolean;
|
||||||
|
blocksSight: boolean;
|
||||||
|
roomId?: string;
|
||||||
|
/** Цвет для машин и т.п. (иначе по стилю). */
|
||||||
|
color?: string;
|
||||||
|
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { Ring, Vec } from '../types';
|
||||||
|
|
||||||
|
export const add = (a: Vec, b: Vec): Vec => [a[0] + b[0], a[1] + b[1]];
|
||||||
|
export const sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1]];
|
||||||
|
export const mul = (a: Vec, k: number): Vec => [a[0] * k, a[1] * k];
|
||||||
|
export const len = (a: Vec): number => Math.hypot(a[0], a[1]);
|
||||||
|
export const dist = (a: Vec, b: Vec): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||||
|
export const lerp = (a: Vec, b: Vec, t: number): Vec => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||||
|
|
||||||
|
export function rotate(p: Vec, ang: number): Vec {
|
||||||
|
const c = Math.cos(ang), s = Math.sin(ang);
|
||||||
|
return [p[0] * c - p[1] * s, p[0] * s + p[1] * c];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Знаковая площадь (положительная при обходе по часовой в экранных осях y-вниз). */
|
||||||
|
export function signedArea(r: Ring): number {
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0, n = r.length; i < n; i++) {
|
||||||
|
const p = r[i], q = r[(i + 1) % n];
|
||||||
|
a += p[0] * q[1] - q[0] * p[1];
|
||||||
|
}
|
||||||
|
return a / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ringArea = (r: Ring): number => Math.abs(signedArea(r));
|
||||||
|
|
||||||
|
export function centroid(r: Ring): Vec {
|
||||||
|
let a = 0, cx = 0, cy = 0;
|
||||||
|
for (let i = 0, n = r.length; i < n; i++) {
|
||||||
|
const p = r[i], q = r[(i + 1) % n];
|
||||||
|
const f = p[0] * q[1] - q[0] * p[1];
|
||||||
|
a += f;
|
||||||
|
cx += (p[0] + q[0]) * f;
|
||||||
|
cy += (p[1] + q[1]) * f;
|
||||||
|
}
|
||||||
|
if (Math.abs(a) < 1e-9) {
|
||||||
|
const s = r.reduce((acc, p) => add(acc, p), [0, 0] as Vec);
|
||||||
|
return mul(s, 1 / r.length);
|
||||||
|
}
|
||||||
|
return [cx / (3 * a), cy / (3 * a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointInRing(p: Vec, r: Ring): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||||
|
const xi = r[i][0], yi = r[i][1], xj = r[j][0], yj = r[j][1];
|
||||||
|
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointInRings(p: Vec, rings: Ring[]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (const r of rings) if (pointInRing(p, r)) inside = !inside;
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distToSegment(p: Vec, a: Vec, b: Vec): number {
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
const l2 = dx * dx + dy * dy;
|
||||||
|
let t = l2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2 : 0;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closestOnSegment(p: Vec, a: Vec, b: Vec): Vec {
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
const l2 = dx * dx + dy * dy;
|
||||||
|
let t = l2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2 : 0;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
return [a[0] + t * dx, a[1] + t * dy];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distToRingEdge(p: Vec, r: Ring): number {
|
||||||
|
let d = Infinity;
|
||||||
|
for (let i = 0; i < r.length; i++) d = Math.min(d, distToSegment(p, r[i], r[(i + 1) % r.length]));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bboxOf(pts: Vec[]): [number, number, number, number] {
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const p of pts) {
|
||||||
|
if (p[0] < x0) x0 = p[0];
|
||||||
|
if (p[1] < y0) y0 = p[1];
|
||||||
|
if (p[0] > x1) x1 = p[0];
|
||||||
|
if (p[1] > y1) y1 = p[1];
|
||||||
|
}
|
||||||
|
return [x0, y0, x1, y1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убрать замыкающую точку, дубликаты и почти-коллинеарные вершины. */
|
||||||
|
export function cleanRing(r: Ring, eps = 0.05): Ring {
|
||||||
|
let pts = r.slice();
|
||||||
|
if (pts.length > 1 && dist(pts[0], pts[pts.length - 1]) < 1e-6) pts.pop();
|
||||||
|
pts = pts.filter((p, i) => dist(p, pts[(i + 1) % pts.length]) > eps);
|
||||||
|
let changed = true;
|
||||||
|
while (changed && pts.length > 3) {
|
||||||
|
changed = false;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[(i - 1 + pts.length) % pts.length], b = pts[i], c = pts[(i + 1) % pts.length];
|
||||||
|
if (distToSegment(b, a, c) < eps) {
|
||||||
|
pts.splice(i, 1);
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выпуклая оболочка (монотонная цепь). */
|
||||||
|
export function convexHull(points: Vec[]): Vec[] {
|
||||||
|
const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||||
|
if (pts.length < 3) return pts;
|
||||||
|
const cross = (o: Vec, a: Vec, b: Vec) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||||
|
const lower: Vec[] = [];
|
||||||
|
for (const p of pts) {
|
||||||
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||||||
|
lower.push(p);
|
||||||
|
}
|
||||||
|
const upper: Vec[] = [];
|
||||||
|
for (let i = pts.length - 1; i >= 0; i--) {
|
||||||
|
const p = pts[i];
|
||||||
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
||||||
|
upper.push(p);
|
||||||
|
}
|
||||||
|
upper.pop();
|
||||||
|
lower.pop();
|
||||||
|
return lower.concat(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Угол прямоугольника минимальной площади, описанного вокруг контура (ориентация здания). */
|
||||||
|
export function minAreaRectAngle(r: Ring): number {
|
||||||
|
const hull = convexHull(r);
|
||||||
|
let best = Infinity, bestAng = 0;
|
||||||
|
for (let i = 0; i < hull.length; i++) {
|
||||||
|
const a = hull[i], b = hull[(i + 1) % hull.length];
|
||||||
|
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||||
|
const c = Math.cos(-ang), s = Math.sin(-ang);
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const p of hull) {
|
||||||
|
const x = p[0] * c - p[1] * s, y = p[0] * s + p[1] * c;
|
||||||
|
x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y);
|
||||||
|
}
|
||||||
|
const area = (x1 - x0) * (y1 - y0);
|
||||||
|
if (area < best - 1e-6) {
|
||||||
|
best = area;
|
||||||
|
// ориентируем так, чтобы длинная сторона шла по x
|
||||||
|
bestAng = x1 - x0 >= y1 - y0 ? ang : ang + Math.PI / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// нормируем в (-pi/2, pi/2]
|
||||||
|
while (bestAng > Math.PI / 2) bestAng -= Math.PI;
|
||||||
|
while (bestAng <= -Math.PI / 2) bestAng += Math.PI;
|
||||||
|
return bestAng;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function polylineLength(pts: Vec[]): number {
|
||||||
|
let l = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) l += dist(pts[i - 1], pts[i]);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пересекаются ли два bbox. */
|
||||||
|
export const bboxHit = (a: number[], b: number[]) => a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Детерминированный ГПСЧ: одно и то же здание с тем же сидом всегда генерируется одинаково.
|
||||||
|
|
||||||
|
export function hashString(s: string): number {
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Rng {
|
||||||
|
private s: number;
|
||||||
|
constructor(seed: number) {
|
||||||
|
this.s = (seed >>> 0) || 0x9e3779b9;
|
||||||
|
}
|
||||||
|
next(): number {
|
||||||
|
// mulberry32
|
||||||
|
let t = (this.s = (this.s + 0x6d2b79f5) >>> 0);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
range(a: number, b: number): number {
|
||||||
|
return a + (b - a) * this.next();
|
||||||
|
}
|
||||||
|
int(a: number, b: number): number {
|
||||||
|
return Math.floor(this.range(a, b + 1));
|
||||||
|
}
|
||||||
|
chance(p: number): boolean {
|
||||||
|
return this.next() < p;
|
||||||
|
}
|
||||||
|
pick<T>(arr: readonly T[]): T {
|
||||||
|
return arr[Math.floor(this.next() * arr.length)];
|
||||||
|
}
|
||||||
|
shuffle<T>(arr: T[]): T[] {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.next() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Простая равномерная сетка для поиска по bbox. */
|
||||||
|
export class SpatialGrid<T> {
|
||||||
|
private cells = new Map<number, T[]>();
|
||||||
|
constructor(private size = 50) {}
|
||||||
|
private key(ix: number, iy: number) {
|
||||||
|
return (ix + 32768) * 65536 + (iy + 32768);
|
||||||
|
}
|
||||||
|
insert(item: T, bb: [number, number, number, number]) {
|
||||||
|
const s = this.size;
|
||||||
|
for (let ix = Math.floor(bb[0] / s); ix <= Math.floor(bb[2] / s); ix++)
|
||||||
|
for (let iy = Math.floor(bb[1] / s); iy <= Math.floor(bb[3] / s); iy++) {
|
||||||
|
const k = this.key(ix, iy);
|
||||||
|
let arr = this.cells.get(k);
|
||||||
|
if (!arr) this.cells.set(k, (arr = []));
|
||||||
|
arr.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query(bb: [number, number, number, number]): T[] {
|
||||||
|
const s = this.size;
|
||||||
|
const out = new Set<T>();
|
||||||
|
for (let ix = Math.floor(bb[0] / s); ix <= Math.floor(bb[2] / s); ix++)
|
||||||
|
for (let iy = Math.floor(bb[1] / s); iy <= Math.floor(bb[3] / s); iy++) {
|
||||||
|
const arr = this.cells.get(this.key(ix, iy));
|
||||||
|
if (arr) for (const it of arr) out.add(it);
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// Мини-карта с тайлами OSM для выбора области (без внешних библиотек).
|
||||||
|
|
||||||
|
import type { BBox } from '../osm/overpass';
|
||||||
|
|
||||||
|
const TILE = 256;
|
||||||
|
const TILE_URL = (z: number, x: number, y: number) => `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
|
||||||
|
|
||||||
|
const lon2x = (lon: number, z: number) => ((lon + 180) / 360) * TILE * 2 ** z;
|
||||||
|
const lat2y = (lat: number, z: number) => {
|
||||||
|
const s = Math.sin((lat * Math.PI) / 180);
|
||||||
|
return (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * TILE * 2 ** z;
|
||||||
|
};
|
||||||
|
const x2lon = (x: number, z: number) => (x / (TILE * 2 ** z)) * 360 - 180;
|
||||||
|
const y2lat = (y: number, z: number) => {
|
||||||
|
const n = Math.PI - (2 * Math.PI * y) / (TILE * 2 ** z);
|
||||||
|
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bboxSizeKm(b: BBox): { w: number; h: number; km2: number } {
|
||||||
|
const lat = ((b[0] + b[2]) / 2) * (Math.PI / 180);
|
||||||
|
const w = (b[3] - b[1]) * 111.32 * Math.cos(lat);
|
||||||
|
const h = (b[2] - b[0]) * 110.57;
|
||||||
|
return { w, h, km2: w * h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AreaPicker {
|
||||||
|
readonly canvas = document.createElement('canvas');
|
||||||
|
private ctx = this.canvas.getContext('2d')!;
|
||||||
|
private lat = 55.751;
|
||||||
|
private lon = 37.618;
|
||||||
|
private zoom = 12;
|
||||||
|
private tiles = new Map<string, HTMLImageElement>();
|
||||||
|
private drag: { x: number; y: number; lat: number; lon: number } | null = null;
|
||||||
|
private sel: { a: [number, number]; b: [number, number] } | null = null; // lat, lon
|
||||||
|
private selecting = false;
|
||||||
|
selectMode = false;
|
||||||
|
private raf = 0;
|
||||||
|
private pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
private pinch: { d: number; zoom: number } | null = null;
|
||||||
|
|
||||||
|
constructor(private parent: HTMLElement, private onChange: (b: BBox | null) => void) {
|
||||||
|
this.canvas.className = 'map-canvas';
|
||||||
|
parent.appendChild(this.canvas);
|
||||||
|
new ResizeObserver(() => this.resize()).observe(parent);
|
||||||
|
this.resize();
|
||||||
|
this.canvas.addEventListener('pointerdown', (e) => this.down(e));
|
||||||
|
this.canvas.addEventListener('pointermove', (e) => this.move(e));
|
||||||
|
this.canvas.addEventListener('pointerup', (e) => this.up(e));
|
||||||
|
this.canvas.addEventListener('pointercancel', (e) => this.up(e));
|
||||||
|
this.canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.zoomAt(e.offsetX, e.offsetY, -Math.sign(e.deltaY) * (Math.abs(e.deltaY) > 50 ? 0.5 : 0.25));
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize() {
|
||||||
|
const r = this.parent.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.canvas.width = Math.round(r.width * dpr);
|
||||||
|
this.canvas.height = Math.round(r.height * dpr);
|
||||||
|
this.canvas.style.width = r.width + 'px';
|
||||||
|
this.canvas.style.height = r.height + 'px';
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get W() { return this.canvas.width / (window.devicePixelRatio || 1); }
|
||||||
|
private get H() { return this.canvas.height / (window.devicePixelRatio || 1); }
|
||||||
|
|
||||||
|
private toScreen(lat: number, lon: number): [number, number] {
|
||||||
|
const z = this.zoom;
|
||||||
|
return [lon2x(lon, z) - lon2x(this.lon, z) + this.W / 2, lat2y(lat, z) - lat2y(this.lat, z) + this.H / 2];
|
||||||
|
}
|
||||||
|
private toLatLon(x: number, y: number): [number, number] {
|
||||||
|
const z = this.zoom;
|
||||||
|
return [y2lat(lat2y(this.lat, z) + y - this.H / 2, z), x2lon(lon2x(this.lon, z) + x - this.W / 2, z)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomAt(x: number, y: number, dz: number) {
|
||||||
|
const [lat, lon] = this.toLatLon(x, y);
|
||||||
|
this.zoom = Math.max(2, Math.min(19, this.zoom + dz));
|
||||||
|
// держим точку под курсором на месте
|
||||||
|
const [sx, sy] = this.toScreen(lat, lon);
|
||||||
|
const [clat, clon] = this.toLatLon(this.W / 2 + (sx - x), this.H / 2 + (sy - y));
|
||||||
|
this.lat = clat;
|
||||||
|
this.lon = clon;
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
private down(e: PointerEvent) {
|
||||||
|
this.canvas.setPointerCapture(e.pointerId);
|
||||||
|
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||||
|
if (this.pointers.size === 2) {
|
||||||
|
const [p, q] = [...this.pointers.values()];
|
||||||
|
this.pinch = { d: Math.hypot(p.x - q.x, p.y - q.y), zoom: this.zoom };
|
||||||
|
this.drag = null;
|
||||||
|
this.selecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectMode || e.shiftKey) {
|
||||||
|
const p = this.toLatLon(e.offsetX, e.offsetY);
|
||||||
|
this.sel = { a: p, b: p };
|
||||||
|
this.selecting = true;
|
||||||
|
} else this.drag = { x: e.offsetX, y: e.offsetY, lat: this.lat, lon: this.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
private move(e: PointerEvent) {
|
||||||
|
if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||||
|
if (this.pinch && this.pointers.size === 2) {
|
||||||
|
const [p, q] = [...this.pointers.values()];
|
||||||
|
const d = Math.hypot(p.x - q.x, p.y - q.y);
|
||||||
|
const target = this.pinch.zoom + Math.log2(d / this.pinch.d);
|
||||||
|
this.zoomAt((p.x + q.x) / 2, (p.y + q.y) / 2, target - this.zoom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selecting && this.sel) {
|
||||||
|
this.sel.b = this.toLatLon(e.offsetX, e.offsetY);
|
||||||
|
this.emit();
|
||||||
|
this.request();
|
||||||
|
} else if (this.drag) {
|
||||||
|
const z = this.zoom;
|
||||||
|
const cx = lon2x(this.drag.lon, z) - (e.offsetX - this.drag.x);
|
||||||
|
const cy = lat2y(this.drag.lat, z) - (e.offsetY - this.drag.y);
|
||||||
|
this.lon = x2lon(cx, z);
|
||||||
|
this.lat = y2lat(cy, z);
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private up(e: PointerEvent) {
|
||||||
|
this.pointers.delete(e.pointerId);
|
||||||
|
if (this.pointers.size < 2) this.pinch = null;
|
||||||
|
if (this.selecting) {
|
||||||
|
this.selecting = false;
|
||||||
|
const b = this.bbox();
|
||||||
|
if (b && bboxSizeKm(b).km2 < 0.0004) this.sel = null; // случайный клик
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
this.drag = null;
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
bbox(): BBox | null {
|
||||||
|
if (!this.sel) return null;
|
||||||
|
const { a, b } = this.sel;
|
||||||
|
return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit() {
|
||||||
|
this.onChange(this.bbox());
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(b: BBox | null, fit = true) {
|
||||||
|
this.sel = b ? { a: [b[0], b[1]], b: [b[2], b[3]] } : null;
|
||||||
|
if (b && fit) this.fit(b);
|
||||||
|
this.emit();
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
fit(b: BBox) {
|
||||||
|
this.lat = (b[0] + b[2]) / 2;
|
||||||
|
this.lon = (b[1] + b[3]) / 2;
|
||||||
|
for (let z = 18; z >= 2; z -= 0.25) {
|
||||||
|
const w = lon2x(b[3], z) - lon2x(b[1], z);
|
||||||
|
const h = lat2y(b[0], z) - lat2y(b[2], z);
|
||||||
|
if (w < this.W * 0.7 && h < this.H * 0.7) { this.zoom = z; break; }
|
||||||
|
}
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
center(): [number, number] {
|
||||||
|
return [this.lat, this.lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сделать выделение вокруг центра экрана заданного размера (км). */
|
||||||
|
selectAroundCenter(km: number) {
|
||||||
|
const dLat = km / 2 / 110.57;
|
||||||
|
const dLon = km / 2 / (111.32 * Math.cos((this.lat * Math.PI) / 180));
|
||||||
|
this.setSelection([this.lat - dLat, this.lon - dLon, this.lat + dLat, this.lon + dLon], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private request() {
|
||||||
|
if (this.raf) return;
|
||||||
|
this.raf = requestAnimationFrame(() => {
|
||||||
|
this.raf = 0;
|
||||||
|
this.draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tile(z: number, x: number, y: number): HTMLImageElement | null {
|
||||||
|
const n = 2 ** z;
|
||||||
|
if (y < 0 || y >= n) return null;
|
||||||
|
x = ((x % n) + n) % n;
|
||||||
|
const key = `${z}/${x}/${y}`;
|
||||||
|
let img = this.tiles.get(key);
|
||||||
|
if (!img) {
|
||||||
|
img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => this.request();
|
||||||
|
img.src = TILE_URL(z, x, y);
|
||||||
|
this.tiles.set(key, img);
|
||||||
|
if (this.tiles.size > 600) this.tiles.delete(this.tiles.keys().next().value!);
|
||||||
|
}
|
||||||
|
return img.complete && img.naturalWidth ? img : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = this.W, H = this.H;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.fillStyle = '#e8e4dc';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
const tz = Math.round(this.zoom);
|
||||||
|
const scale = 2 ** (this.zoom - tz);
|
||||||
|
const cx = lon2x(this.lon, tz), cy = lat2y(this.lat, tz);
|
||||||
|
const left = cx - W / 2 / scale, top = cy - H / 2 / scale;
|
||||||
|
const x0 = Math.floor(left / TILE), y0 = Math.floor(top / TILE);
|
||||||
|
const x1 = Math.floor((cx + W / 2 / scale) / TILE), y1 = Math.floor((cy + H / 2 / scale) / TILE);
|
||||||
|
for (let ty = y0; ty <= y1; ty++)
|
||||||
|
for (let tx = x0; tx <= x1; tx++) {
|
||||||
|
const sx = (tx * TILE - left) * scale, sy = (ty * TILE - top) * scale;
|
||||||
|
const img = this.tile(tz, tx, ty);
|
||||||
|
if (img) ctx.drawImage(img, sx, sy, TILE * scale + 0.5, TILE * scale + 0.5);
|
||||||
|
else {
|
||||||
|
// пока тайл грузится — родительский
|
||||||
|
const p = this.tile(tz - 1, tx >> 1, ty >> 1);
|
||||||
|
if (p) ctx.drawImage(p, (tx & 1) * 128, (ty & 1) * 128, 128, 128, sx, sy, TILE * scale + 0.5, TILE * scale + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const b = this.bbox();
|
||||||
|
if (b) {
|
||||||
|
const [ax, ay] = this.toScreen(b[2], b[1]);
|
||||||
|
const [bx, by] = this.toScreen(b[0], b[3]);
|
||||||
|
ctx.fillStyle = 'rgba(20,20,20,0.35)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, 0, W, H);
|
||||||
|
ctx.rect(ax, ay, bx - ax, by - ay);
|
||||||
|
ctx.fill('evenodd');
|
||||||
|
ctx.strokeStyle = '#e8590c';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.strokeRect(ax, ay, bx - ax, by - ay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
// Отрисовка мира на Canvas2D. Все геометрии кэшируются в Path2D в мировых координатах
|
||||||
|
// (метры) и рисуются с трансформацией камеры, поэтому кадр — это десятки заливок, а не тысячи.
|
||||||
|
|
||||||
|
import type { Building, Floor, Prop, Road, Shape, Vec, World } from '../types';
|
||||||
|
import { SpatialGrid } from '../util/spatial';
|
||||||
|
import { bboxOf, bboxHit, pointInRings, polylineLength } from '../util/geom';
|
||||||
|
import * as S from './style';
|
||||||
|
|
||||||
|
export interface Camera {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** пикселей на метр */
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewOpts {
|
||||||
|
/** null — крыши; иначе номер этажа (0 — первый). */
|
||||||
|
floor: number | null;
|
||||||
|
overrides: Map<string, number | null>;
|
||||||
|
selected?: string | null;
|
||||||
|
furniture: boolean;
|
||||||
|
labels: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Layer {
|
||||||
|
order: number;
|
||||||
|
fill: string | null;
|
||||||
|
path: Path2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloorPaths {
|
||||||
|
rooms: Map<string, Path2D>;
|
||||||
|
wallsExt: Path2D;
|
||||||
|
wallsInt: Path2D;
|
||||||
|
windows: Path2D;
|
||||||
|
doors: Path2D;
|
||||||
|
stairs: Path2D;
|
||||||
|
stairLines: Path2D;
|
||||||
|
props: Layer[];
|
||||||
|
labels: { pos: Vec; text: string; area: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chunk {
|
||||||
|
bb: [number, number, number, number];
|
||||||
|
layers: Layer[];
|
||||||
|
trees: Path2D;
|
||||||
|
treeEdges: Path2D;
|
||||||
|
trunks: Path2D;
|
||||||
|
hasTrees: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK = 120;
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
private areas: { kind: string; path: Path2D; bb: number[] }[] = [];
|
||||||
|
private roadsByLayer: Road[][] = [];
|
||||||
|
private chunks = new Map<string, Chunk>();
|
||||||
|
private bGrid = new SpatialGrid<Building>(60);
|
||||||
|
private roofCache = new Map<string, Path2D>();
|
||||||
|
private floorCache = new Map<string, FloorPaths>();
|
||||||
|
private streetLabels: { text: string; pos: Vec; ang: number; len: number; major: boolean }[] = [];
|
||||||
|
private bounds: World['bounds'];
|
||||||
|
|
||||||
|
constructor(public world: World) {
|
||||||
|
this.bounds = world.bounds;
|
||||||
|
for (const a of world.areas) {
|
||||||
|
const p = new Path2D();
|
||||||
|
for (const r of a.rings) addRing(p, r);
|
||||||
|
this.areas.push({ kind: a.kind, path: p, bb: bboxOf(a.rings[0]) });
|
||||||
|
}
|
||||||
|
const layers = new Map<number, Road[]>();
|
||||||
|
for (const r of world.roads) {
|
||||||
|
const l = r.tunnel ? -10 : r.bridge ? 10 + r.layer : Math.min(r.layer, 0);
|
||||||
|
if (!layers.has(l)) layers.set(l, []);
|
||||||
|
layers.get(l)!.push(r);
|
||||||
|
}
|
||||||
|
this.roadsByLayer = [...layers.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1]);
|
||||||
|
for (const b of world.buildings) this.bGrid.insert(b, bboxOf(b.footprint));
|
||||||
|
this.buildChunks();
|
||||||
|
this.buildStreetLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- подготовка
|
||||||
|
|
||||||
|
private buildChunks() {
|
||||||
|
const groups = new Map<string, Prop[]>();
|
||||||
|
for (const p of this.world.props) {
|
||||||
|
const k = `${Math.floor(p.pos[0] / CHUNK)},${Math.floor(p.pos[1] / CHUNK)}`;
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k)!.push(p);
|
||||||
|
}
|
||||||
|
for (const [k, props] of groups) {
|
||||||
|
const [cx, cy] = k.split(',').map(Number);
|
||||||
|
const trees = new Path2D(), treeEdges = new Path2D(), trunks = new Path2D();
|
||||||
|
const rest: Prop[] = [];
|
||||||
|
let hasTrees = false;
|
||||||
|
for (const p of props) {
|
||||||
|
if (p.kind === 'tree' || p.kind === 'bush') {
|
||||||
|
const r = p.w / 2;
|
||||||
|
trees.moveTo(p.pos[0] + r, p.pos[1]);
|
||||||
|
trees.arc(p.pos[0], p.pos[1], r, 0, Math.PI * 2);
|
||||||
|
treeEdges.moveTo(p.pos[0] + r, p.pos[1]);
|
||||||
|
treeEdges.arc(p.pos[0], p.pos[1], r, 0, Math.PI * 2);
|
||||||
|
if (p.kind === 'tree') {
|
||||||
|
trunks.moveTo(p.pos[0] + 0.25, p.pos[1]);
|
||||||
|
trunks.arc(p.pos[0], p.pos[1], 0.25, 0, Math.PI * 2);
|
||||||
|
}
|
||||||
|
hasTrees = true;
|
||||||
|
} else rest.push(p);
|
||||||
|
}
|
||||||
|
this.chunks.set(k, {
|
||||||
|
bb: [cx * CHUNK - 6, cy * CHUNK - 6, (cx + 1) * CHUNK + 6, (cy + 1) * CHUNK + 6],
|
||||||
|
layers: this.propLayers(rest), trees, treeEdges, trunks, hasTrees,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStreetLabels() {
|
||||||
|
const byName = new Map<string, Road[]>();
|
||||||
|
for (const r of this.world.roads) if (r.name && !['rail', 'tram', 'river', 'stream', 'canal', 'ditch', 'footway', 'path', 'cycleway', 'steps'].includes(r.kind)) {
|
||||||
|
if (!byName.has(r.name)) byName.set(r.name, []);
|
||||||
|
byName.get(r.name)!.push(r);
|
||||||
|
}
|
||||||
|
for (const [name, rs] of byName) {
|
||||||
|
// подпись на самом длинном участке каждого пути
|
||||||
|
for (const r of rs) {
|
||||||
|
if (polylineLength(r.pts) < 40) continue;
|
||||||
|
let best = 0, bi = 1;
|
||||||
|
for (let i = 1; i < r.pts.length; i++) {
|
||||||
|
const l = Math.hypot(r.pts[i][0] - r.pts[i - 1][0], r.pts[i][1] - r.pts[i - 1][1]);
|
||||||
|
if (l > best) { best = l; bi = i; }
|
||||||
|
}
|
||||||
|
const a = r.pts[bi - 1], b = r.pts[bi];
|
||||||
|
let ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||||
|
if (ang > Math.PI / 2) ang -= Math.PI;
|
||||||
|
if (ang < -Math.PI / 2) ang += Math.PI;
|
||||||
|
this.streetLabels.push({ text: name, pos: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], ang, len: best, major: ['primary', 'secondary', 'trunk', 'tertiary'].includes(r.kind) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.streetLabels.sort((a, b) => Number(b.major) - Number(a.major) || b.len - a.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shapesOf(p: Prop): Shape[] {
|
||||||
|
return p.shapes ?? (p.model ? this.world.models[p.model] ?? [] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фигуры предметов → слои Path2D: слой i каждого предмета рисуется после слоя i-1 всех предметов. */
|
||||||
|
private propLayers(props: Prop[]): Layer[] {
|
||||||
|
const map = new Map<string, Layer>();
|
||||||
|
for (const p of props) {
|
||||||
|
const shapes = this.shapesOf(p);
|
||||||
|
const c = Math.cos(p.rot), s = Math.sin(p.rot);
|
||||||
|
const T = (x: number, y: number): Vec => [p.pos[0] + x * c - y * s, p.pos[1] + x * s + y * c];
|
||||||
|
shapes.forEach((sh, i) => {
|
||||||
|
const style = sh.s ?? 'base';
|
||||||
|
const fill = sh.t === 'line' ? null : style === 'car' ? p.color ?? '#999' : S.SHAPE_FILL[style] ?? '#eee';
|
||||||
|
const key = `${i}|${fill}|${sh.t === 'line' ? style : ''}`;
|
||||||
|
let L = map.get(key);
|
||||||
|
if (!L) map.set(key, (L = { order: i * 2 + (sh.t === 'line' ? 1 : 0), fill, path: new Path2D() }));
|
||||||
|
addShape(L.path, sh, T, p.rot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private roof(b: Building): Path2D {
|
||||||
|
let p = this.roofCache.get(b.id);
|
||||||
|
if (!p) {
|
||||||
|
p = new Path2D();
|
||||||
|
addRing(p, b.footprint);
|
||||||
|
for (const h of b.holes) addRing(p, h);
|
||||||
|
this.roofCache.set(b.id, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private floorPaths(b: Building, f: Floor): FloorPaths {
|
||||||
|
const key = `${b.id}|${f.level}`;
|
||||||
|
let fp = this.floorCache.get(key);
|
||||||
|
if (fp) return fp;
|
||||||
|
const rooms = new Map<string, Path2D>();
|
||||||
|
const labels: FloorPaths['labels'] = [];
|
||||||
|
for (const r of f.rooms) {
|
||||||
|
const col = S.ROOM_COLOR[r.kind] ?? S.ROOM_COLOR.default;
|
||||||
|
let p = rooms.get(col);
|
||||||
|
if (!p) rooms.set(col, (p = new Path2D()));
|
||||||
|
for (const ring of r.rings) addRing(p, ring);
|
||||||
|
const name = S.ROOM_NAME[r.kind];
|
||||||
|
if (name && r.kind !== 'stairs' && r.kind !== 'lift') labels.push({ pos: r.labelPos, text: name, area: r.area });
|
||||||
|
}
|
||||||
|
const wallsExt = new Path2D(), wallsInt = new Path2D();
|
||||||
|
for (const w of f.walls) {
|
||||||
|
const p = w.exterior ? wallsExt : wallsInt;
|
||||||
|
p.moveTo(w.a[0], w.a[1]);
|
||||||
|
p.lineTo(w.b[0], w.b[1]);
|
||||||
|
}
|
||||||
|
const windows = new Path2D();
|
||||||
|
for (const w of f.windows) { windows.moveTo(w.a[0], w.a[1]); windows.lineTo(w.b[0], w.b[1]); }
|
||||||
|
const doors = new Path2D();
|
||||||
|
for (const d of f.doors) {
|
||||||
|
if (d.kind === 'opening') continue;
|
||||||
|
const vx = d.b[0] - d.a[0], vy = d.b[1] - d.a[1];
|
||||||
|
const L = Math.hypot(vx, vy);
|
||||||
|
if (L < 0.3) continue;
|
||||||
|
const mid: Vec = [(d.a[0] + d.b[0]) / 2, (d.a[1] + d.b[1]) / 2];
|
||||||
|
let nx = -vy / L, ny = vx / L;
|
||||||
|
if ((d.swingTo[0] - mid[0]) * nx + (d.swingTo[1] - mid[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||||
|
if (d.kind === 'garage') {
|
||||||
|
doors.moveTo(d.a[0] + nx * 0.15, d.a[1] + ny * 0.15);
|
||||||
|
doors.lineTo(d.b[0] + nx * 0.15, d.b[1] + ny * 0.15);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// двустворчатая для широких проёмов
|
||||||
|
const leaves: [Vec, Vec, number][] = L > 1.3 ? [[d.a, [vx / 2, vy / 2], L / 2], [d.b, [-vx / 2, -vy / 2], L / 2]] : [[d.a, [vx, vy], L]];
|
||||||
|
for (const [h, v, r] of leaves) {
|
||||||
|
const a0 = Math.atan2(v[1], v[0]);
|
||||||
|
const a1 = Math.atan2(ny, nx);
|
||||||
|
let da = a1 - a0;
|
||||||
|
while (da > Math.PI) da -= 2 * Math.PI;
|
||||||
|
while (da < -Math.PI) da += 2 * Math.PI;
|
||||||
|
doors.moveTo(h[0], h[1]);
|
||||||
|
doors.lineTo(h[0] + nx * r, h[1] + ny * r);
|
||||||
|
doors.arc(h[0], h[1], r, a1, a0, da > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stairs = new Path2D(), stairLines = new Path2D();
|
||||||
|
for (const s of f.stairs) {
|
||||||
|
for (const ring of s.rings) addRing(stairs, ring);
|
||||||
|
if (s.lift) {
|
||||||
|
const r = s.rings[0];
|
||||||
|
stairLines.moveTo(r[0][0], r[0][1]); stairLines.lineTo(r[2][0], r[2][1]);
|
||||||
|
stairLines.moveTo(r[1][0], r[1][1]); stairLines.lineTo(r[3][0], r[3][1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dx = s.to[0] - s.from[0], dy = s.to[1] - s.from[1];
|
||||||
|
const L = Math.hypot(dx, dy);
|
||||||
|
const ux = dx / L, uy = dy / L, px = -uy, py = ux;
|
||||||
|
const hw = s.width / 2 - 0.05;
|
||||||
|
for (let t = 0.28; t < L - 0.05; t += 0.28) {
|
||||||
|
const cx = s.from[0] + ux * t, cy = s.from[1] + uy * t;
|
||||||
|
stairLines.moveTo(cx - px * hw, cy - py * hw);
|
||||||
|
stairLines.lineTo(cx + px * hw, cy + py * hw);
|
||||||
|
}
|
||||||
|
// стрелка «вверх»
|
||||||
|
if (s.up) {
|
||||||
|
const a: Vec = [s.from[0] + ux * 0.2, s.from[1] + uy * 0.2], b: Vec = [s.to[0] - ux * 0.3, s.to[1] - uy * 0.3];
|
||||||
|
stairLines.moveTo(a[0], a[1]);
|
||||||
|
stairLines.lineTo(b[0], b[1]);
|
||||||
|
stairLines.lineTo(b[0] - ux * 0.35 + px * 0.25, b[1] - uy * 0.35 + py * 0.25);
|
||||||
|
stairLines.moveTo(b[0], b[1]);
|
||||||
|
stairLines.lineTo(b[0] - ux * 0.35 - px * 0.25, b[1] - uy * 0.35 - py * 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fp = { rooms, wallsExt, wallsInt, windows, doors, stairs, stairLines, props: this.propLayers(f.props), labels };
|
||||||
|
this.floorCache.set(key, fp);
|
||||||
|
if (this.floorCache.size > 6000) this.floorCache.delete(this.floorCache.keys().next().value!);
|
||||||
|
return fp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- запросы
|
||||||
|
|
||||||
|
buildingAt(p: Vec): Building | null {
|
||||||
|
for (const b of this.bGrid.query([p[0], p[1], p[0], p[1]])) if (pointInRings(p, [b.footprint, ...b.holes])) return b;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
levelFor(b: Building, o: ViewOpts): number | null {
|
||||||
|
const lv = o.overrides.has(b.id) ? o.overrides.get(b.id)! : o.floor;
|
||||||
|
if (lv === null || lv === undefined) return null;
|
||||||
|
return lv < b.levels ? lv : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- кадр
|
||||||
|
|
||||||
|
draw(ctx: CanvasRenderingContext2D, W: number, H: number, cam: Camera, o: ViewOpts, dpr = 1) {
|
||||||
|
const z = cam.z;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.fillStyle = S.BG;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
const view: [number, number, number, number] = [cam.x - W / 2 / z, cam.y - H / 2 / z, cam.x + W / 2 / z, cam.y + H / 2 / z];
|
||||||
|
const px = 1 / z; // один пиксель в метрах
|
||||||
|
ctx.setTransform(z * dpr, 0, 0, z * dpr, (W / 2 - cam.x * z) * dpr, (H / 2 - cam.y * z) * dpr);
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
// зоны
|
||||||
|
for (const a of this.areas) {
|
||||||
|
if (!bboxHit(a.bb, view)) continue;
|
||||||
|
ctx.fillStyle = (S.AREA_COLOR as any)[a.kind] ?? '#ddd';
|
||||||
|
ctx.fill(a.path, 'evenodd');
|
||||||
|
}
|
||||||
|
if (z > 1.5) {
|
||||||
|
for (const a of this.areas) if (a.kind === 'parking' && bboxHit(a.bb, view)) { ctx.strokeStyle = '#c2c0bb'; ctx.lineWidth = Math.max(0.2, px); ctx.stroke(a.path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// дороги по слоям: тоннели, земля, мосты
|
||||||
|
for (const group of this.roadsByLayer) this.drawRoads(ctx, group, view, z, px);
|
||||||
|
|
||||||
|
// уличные объекты
|
||||||
|
const chunks = [...this.chunks.values()].filter((c) => bboxHit(c.bb, view));
|
||||||
|
if (z > 0.8) for (const c of chunks) this.drawLayers(ctx, c.layers, z, px);
|
||||||
|
|
||||||
|
// здания
|
||||||
|
const visible = this.bGrid.query(view);
|
||||||
|
visible.sort((a, b) => a.levels - b.levels);
|
||||||
|
// тени от крыш
|
||||||
|
if (z < 40) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0.8, 1.2);
|
||||||
|
ctx.fillStyle = 'rgba(40,30,20,0.16)';
|
||||||
|
for (const b of visible) if (this.levelFor(b, o) === null) ctx.fill(this.roof(b), 'evenodd');
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
for (const b of visible) {
|
||||||
|
const lv = this.levelFor(b, o);
|
||||||
|
const f = lv !== null ? b.floors[lv] : undefined;
|
||||||
|
if (f && z >= 0.9) this.drawFloor(ctx, b, f, z, px, o);
|
||||||
|
else this.drawRoof(ctx, b, z, px, lv !== null);
|
||||||
|
if (o.selected === b.id) {
|
||||||
|
ctx.strokeStyle = '#e8590c';
|
||||||
|
ctx.lineWidth = Math.max(3 * px, 0.25);
|
||||||
|
ctx.stroke(this.roof(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// кроны деревьев поверх
|
||||||
|
if (z > 0.35) {
|
||||||
|
for (const c of chunks) {
|
||||||
|
if (!c.hasTrees) continue;
|
||||||
|
ctx.fillStyle = S.TREE;
|
||||||
|
ctx.fill(c.trees);
|
||||||
|
if (z > 1.5) {
|
||||||
|
ctx.strokeStyle = S.TREE_EDGE;
|
||||||
|
ctx.lineWidth = Math.max(px, 0.06);
|
||||||
|
ctx.stroke(c.treeEdges);
|
||||||
|
ctx.fillStyle = 'rgba(90,64,40,0.7)';
|
||||||
|
ctx.fill(c.trunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// затемнение за пределами области
|
||||||
|
const B = this.bounds;
|
||||||
|
ctx.fillStyle = S.OUTSIDE;
|
||||||
|
const big = 1e6;
|
||||||
|
const outside = new Path2D();
|
||||||
|
outside.rect(-big, -big, 2 * big, 2 * big);
|
||||||
|
outside.rect(B.minX, B.minY, B.maxX - B.minX, B.maxY - B.minY);
|
||||||
|
ctx.fill(outside, 'evenodd');
|
||||||
|
|
||||||
|
// подписи — в экранных координатах
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
const toS = (p: Vec): Vec => [(p[0] - cam.x) * z + W / 2, (p[1] - cam.y) * z + H / 2];
|
||||||
|
const boxes: number[][] = [];
|
||||||
|
const free = (bx: number[]) => {
|
||||||
|
if (boxes.some((b) => bboxHit(b, bx))) return false;
|
||||||
|
boxes.push(bx);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (o.labels && z >= 9) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `${z > 30 ? 13 : 11}px system-ui, sans-serif`;
|
||||||
|
for (const b of visible) {
|
||||||
|
const lv = this.levelFor(b, o);
|
||||||
|
if (lv === null || !b.floors[lv]) continue;
|
||||||
|
const fp = this.floorPaths(b, b.floors[lv]);
|
||||||
|
for (const l of fp.labels) {
|
||||||
|
const sp = toS(l.pos);
|
||||||
|
if (sp[0] < -50 || sp[1] < -20 || sp[0] > W + 50 || sp[1] > H + 20) continue;
|
||||||
|
const tw = ctx.measureText(l.text).width;
|
||||||
|
if (Math.sqrt(l.area) * z < tw * 0.9) continue;
|
||||||
|
const yOff = z > 30 ? -12 : 0;
|
||||||
|
if (!free([sp[0] - tw / 2, sp[1] + yOff - 7, sp[0] + tw / 2, sp[1] + yOff + 7])) continue;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||||
|
ctx.fillText(l.text, sp[0] + 1, sp[1] + yOff + 1);
|
||||||
|
ctx.fillStyle = '#5a554d';
|
||||||
|
ctx.fillText(l.text, sp[0], sp[1] + yOff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (o.labels && z > 0.6 && z < 14) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (const l of this.streetLabels) {
|
||||||
|
const sp = toS(l.pos);
|
||||||
|
if (sp[0] < -100 || sp[1] < -50 || sp[0] > W + 100 || sp[1] > H + 50) continue;
|
||||||
|
ctx.font = `${l.major ? 600 : 500} ${l.major ? 13 : 12}px system-ui, sans-serif`;
|
||||||
|
const tw = ctx.measureText(l.text).width;
|
||||||
|
if (tw > l.len * z * 0.95) continue;
|
||||||
|
const hw = (Math.abs(Math.cos(l.ang)) * tw + Math.abs(Math.sin(l.ang)) * 14) / 2;
|
||||||
|
const hh = (Math.abs(Math.sin(l.ang)) * tw + Math.abs(Math.cos(l.ang)) * 14) / 2;
|
||||||
|
if (!free([sp[0] - hw, sp[1] - hh, sp[0] + hw, sp[1] + hh])) continue;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(sp[0], sp[1]);
|
||||||
|
ctx.rotate(l.ang);
|
||||||
|
ctx.lineWidth = 3.5;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||||
|
ctx.strokeText(l.text, 0, 0);
|
||||||
|
ctx.fillStyle = '#3e3a35';
|
||||||
|
ctx.fillText(l.text, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawRoads(ctx: CanvasRenderingContext2D, roads: Road[], view: number[], z: number, px: number) {
|
||||||
|
const vis = roads.filter((r) => {
|
||||||
|
const bb = bboxOf(r.pts);
|
||||||
|
const m = r.width / 2 + r.sidewalk + 2;
|
||||||
|
return bboxHit([bb[0] - m, bb[1] - m, bb[2] + m, bb[3] + m], view);
|
||||||
|
});
|
||||||
|
const line = (r: Road, p: Path2D = new Path2D()) => {
|
||||||
|
p.moveTo(r.pts[0][0], r.pts[0][1]);
|
||||||
|
for (let i = 1; i < r.pts.length; i++) p.lineTo(r.pts[i][0], r.pts[i][1]);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const tunnel = vis.length > 0 && vis[0].tunnel;
|
||||||
|
ctx.save();
|
||||||
|
if (tunnel) {
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.setLineDash([3, 2]);
|
||||||
|
}
|
||||||
|
const water = vis.filter((r) => ['river', 'stream', 'canal', 'ditch'].includes(r.kind));
|
||||||
|
for (const r of water) {
|
||||||
|
ctx.strokeStyle = S.ROAD.water;
|
||||||
|
ctx.lineWidth = Math.max(r.width, 1.5 * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
}
|
||||||
|
const isCar = (r: Road) => !['footway', 'path', 'cycleway', 'steps', 'track', 'pedestrian', 'rail', 'tram', 'river', 'stream', 'canal', 'ditch'].includes(r.kind);
|
||||||
|
const car = vis.filter(isCar);
|
||||||
|
// мосты: тёмная обводка
|
||||||
|
for (const r of vis) if (r.bridge && !['rail', 'tram'].includes(r.kind)) {
|
||||||
|
ctx.strokeStyle = S.ROAD.bridgeCasing;
|
||||||
|
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk + 1.2, 3 * px);
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
}
|
||||||
|
// тротуары и бордюр
|
||||||
|
for (const r of car) if (r.sidewalk > 0) {
|
||||||
|
ctx.strokeStyle = S.ROAD.kerb;
|
||||||
|
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk + 0.3, 2 * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
ctx.strokeStyle = S.ROAD.sidewalk;
|
||||||
|
ctx.lineWidth = Math.max(r.width + 2 * r.sidewalk, 2 * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
}
|
||||||
|
// пешеходные: площади/дорожки
|
||||||
|
for (const r of vis) {
|
||||||
|
if (!['pedestrian', 'footway', 'path', 'cycleway', 'steps', 'track'].includes(r.kind)) continue;
|
||||||
|
ctx.strokeStyle = r.kind === 'track' ? S.ROAD.track : r.kind === 'path' ? S.ROAD.path : r.kind === 'pedestrian' ? S.ROAD.sidewalk : S.ROAD.footway;
|
||||||
|
ctx.lineWidth = Math.max(r.width, 1.2 * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
if (r.kind === 'steps' && z > 4) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([0.12, 0.3]);
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.strokeStyle = '#a89c88';
|
||||||
|
ctx.lineWidth = r.width;
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// проезжая часть
|
||||||
|
for (const r of car) {
|
||||||
|
const major = ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'].includes(r.kind);
|
||||||
|
ctx.strokeStyle = r.kind === 'service' ? S.ROAD.service : major ? S.ROAD.major : S.ROAD.minor;
|
||||||
|
ctx.lineWidth = Math.max(r.width, (major ? 2.5 : 1.6) * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
}
|
||||||
|
// разметка
|
||||||
|
if (z > 1.5) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = S.ROAD.centre;
|
||||||
|
ctx.lineWidth = 0.15;
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.setLineDash([3, 4]);
|
||||||
|
for (const r of car) if (r.width >= 6 && !r.oneway && r.kind !== 'service') ctx.stroke(line(r));
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
// рельсы
|
||||||
|
for (const r of vis) if (r.kind === 'rail' || r.kind === 'tram') {
|
||||||
|
if (z > 2) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.strokeStyle = S.ROAD.sleeper;
|
||||||
|
ctx.lineWidth = 2.6;
|
||||||
|
ctx.setLineDash([0.25, 0.4]);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = S.ROAD.rail;
|
||||||
|
ctx.lineWidth = Math.max(z > 2 ? 1.5 : 1.2, 1.5 * px);
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
if (z > 2) {
|
||||||
|
ctx.strokeStyle = S.ROAD.sleeper;
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.stroke(line(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], z: number, px: number) {
|
||||||
|
const outline = z > 6;
|
||||||
|
ctx.lineWidth = Math.max(0.025, px * 0.8);
|
||||||
|
for (const L of layers) {
|
||||||
|
if (L.fill) {
|
||||||
|
ctx.fillStyle = L.fill;
|
||||||
|
ctx.fill(L.path);
|
||||||
|
if (outline) {
|
||||||
|
ctx.strokeStyle = S.SHAPE_STROKE;
|
||||||
|
ctx.stroke(L.path);
|
||||||
|
}
|
||||||
|
} else if (z > 3) {
|
||||||
|
ctx.strokeStyle = S.SHAPE_STROKE;
|
||||||
|
ctx.stroke(L.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawRoof(ctx: CanvasRenderingContext2D, b: Building, z: number, px: number, lower: boolean) {
|
||||||
|
const p = this.roof(b);
|
||||||
|
ctx.fillStyle = S.ROOF[b.kind] ?? '#c8bba9';
|
||||||
|
ctx.fill(p, 'evenodd');
|
||||||
|
if (lower) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
||||||
|
ctx.fill(p, 'evenodd');
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(70,58,46,0.75)';
|
||||||
|
ctx.lineWidth = Math.max(px, 0.15);
|
||||||
|
ctx.stroke(p);
|
||||||
|
// конёк для небольших домов при приближении
|
||||||
|
if (z > 3 && (b.kind === 'house' || b.kind === 'garage' || b.kind === 'shed' || b.kind === 'farm') && b.footprint.length <= 6) {
|
||||||
|
const r = b.footprint;
|
||||||
|
const c = Math.cos(b.frame.angle), s = Math.sin(b.frame.angle);
|
||||||
|
const loc = r.map((q) => [q[0] * c + q[1] * s, -q[0] * s + q[1] * c]);
|
||||||
|
const xs = loc.map((q) => q[0]), ys = loc.map((q) => q[1]);
|
||||||
|
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||||
|
const ym = (y0 + y1) / 2, inset = (y1 - y0) / 2;
|
||||||
|
const a: Vec = [x0 + inset * 0.6, ym], bb: Vec = [x1 - inset * 0.6, ym];
|
||||||
|
const W = (q: Vec): Vec => [q[0] * c - q[1] * s, q[0] * s + q[1] * c];
|
||||||
|
const pa = W(a), pb = W(bb);
|
||||||
|
ctx.save();
|
||||||
|
ctx.clip(p, 'evenodd');
|
||||||
|
ctx.strokeStyle = 'rgba(90,70,55,0.45)';
|
||||||
|
ctx.lineWidth = Math.max(px, 0.08);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pa[0], pa[1]);
|
||||||
|
ctx.lineTo(pb[0], pb[1]);
|
||||||
|
for (const corner of r) {
|
||||||
|
const cl: Vec = [corner[0] * c + corner[1] * s, -corner[0] * s + corner[1] * c];
|
||||||
|
const tgt = W(cl[0] < (x0 + x1) / 2 ? a : bb);
|
||||||
|
ctx.moveTo(corner[0], corner[1]);
|
||||||
|
ctx.lineTo(tgt[0], tgt[1]);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFloor(ctx: CanvasRenderingContext2D, b: Building, f: Floor, z: number, px: number, o: ViewOpts) {
|
||||||
|
const roof = this.roof(b);
|
||||||
|
if (z < 1.6) {
|
||||||
|
ctx.fillStyle = S.ROOM_COLOR.default;
|
||||||
|
ctx.fill(roof, 'evenodd');
|
||||||
|
ctx.strokeStyle = S.WALL_EXT;
|
||||||
|
ctx.lineWidth = Math.max(px * 1.2, 0.3);
|
||||||
|
ctx.stroke(roof);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fp = this.floorPaths(b, f);
|
||||||
|
ctx.save();
|
||||||
|
ctx.clip(roof, 'evenodd');
|
||||||
|
ctx.fillStyle = S.ROOM_COLOR.default;
|
||||||
|
ctx.fill(roof, 'evenodd');
|
||||||
|
for (const [col, p] of fp.rooms) {
|
||||||
|
ctx.fillStyle = col;
|
||||||
|
ctx.fill(p, 'evenodd');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = '#dcd5ca';
|
||||||
|
ctx.fill(fp.stairs);
|
||||||
|
ctx.strokeStyle = '#8f877b';
|
||||||
|
ctx.lineWidth = Math.max(px * 0.8, 0.03);
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.stroke(fp.stairLines);
|
||||||
|
if (o.furniture && z >= 2.5) this.drawLayers(ctx, fp.props, z, px);
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
ctx.strokeStyle = S.WALL_INT;
|
||||||
|
ctx.lineWidth = Math.max(0.12, px * 1.0);
|
||||||
|
ctx.stroke(fp.wallsInt);
|
||||||
|
ctx.restore();
|
||||||
|
// внешние стены (не клипуем — толщина наружу)
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
ctx.strokeStyle = S.WALL_EXT;
|
||||||
|
ctx.lineWidth = Math.max(0.3, px * 1.6);
|
||||||
|
ctx.stroke(fp.wallsExt);
|
||||||
|
if (z > 2) {
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.strokeStyle = '#eef5f9';
|
||||||
|
ctx.lineWidth = 0.22;
|
||||||
|
ctx.stroke(fp.windows);
|
||||||
|
ctx.strokeStyle = S.WINDOW;
|
||||||
|
ctx.lineWidth = Math.max(0.05, px * 0.8);
|
||||||
|
ctx.stroke(fp.windows);
|
||||||
|
}
|
||||||
|
if (z > 5) {
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
ctx.strokeStyle = S.DOOR;
|
||||||
|
ctx.lineWidth = Math.max(0.03, px * 0.8);
|
||||||
|
ctx.stroke(fp.doors);
|
||||||
|
}
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- геометрия Path2D
|
||||||
|
|
||||||
|
function addRing(p: Path2D, r: Vec[]) {
|
||||||
|
if (r.length < 2) return;
|
||||||
|
p.moveTo(r[0][0], r[0][1]);
|
||||||
|
for (let i = 1; i < r.length; i++) p.lineTo(r[i][0], r[i][1]);
|
||||||
|
p.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShape(p: Path2D, sh: Shape, T: (x: number, y: number) => Vec, rot: number) {
|
||||||
|
if (sh.t === 'circle') {
|
||||||
|
const c = T(sh.x, sh.y);
|
||||||
|
p.moveTo(c[0] + sh.r, c[1]);
|
||||||
|
p.arc(c[0], c[1], sh.r, 0, Math.PI * 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sh.t === 'line') {
|
||||||
|
const a = T(sh.x1, sh.y1), b = T(sh.x2, sh.y2);
|
||||||
|
p.moveTo(a[0], a[1]);
|
||||||
|
p.lineTo(b[0], b[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hw = sh.w / 2, hh = sh.h / 2;
|
||||||
|
const r = Math.min(sh.r ?? 0, hw, hh);
|
||||||
|
const corners: Vec[] = [T(sh.x - hw, sh.y - hh), T(sh.x + hw, sh.y - hh), T(sh.x + hw, sh.y + hh), T(sh.x - hw, sh.y + hh)];
|
||||||
|
if (r <= 0.01) {
|
||||||
|
p.moveTo(corners[0][0], corners[0][1]);
|
||||||
|
for (let i = 1; i < 4; i++) p.lineTo(corners[i][0], corners[i][1]);
|
||||||
|
p.closePath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = (a: Vec, b: Vec): Vec => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||||
|
const start = m(corners[3], corners[0]);
|
||||||
|
p.moveTo(start[0], start[1]);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const c = corners[i], n = corners[(i + 1) % 4];
|
||||||
|
p.arcTo(c[0], c[1], n[0], n[1], r);
|
||||||
|
}
|
||||||
|
p.closePath();
|
||||||
|
void rot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { AreaKind, BuildingKind, RoomKind, ShapeStyle } from '../types';
|
||||||
|
|
||||||
|
export const BG = '#ebe7de';
|
||||||
|
export const OUTSIDE = 'rgba(40,38,34,0.38)';
|
||||||
|
|
||||||
|
export const AREA_COLOR: Record<AreaKind, string> = {
|
||||||
|
residential: '#e5e0d5', commercial: '#ead9d3', retail: '#eed6d2', industrial: '#e0d9e2', farmland: '#ecedd2',
|
||||||
|
farmyard: '#e7d9c2', grass: '#cfe3b0', park: '#c6e2a6', forest: '#a9cc8f', scrub: '#c4d7a1', meadow: '#d7e9b8',
|
||||||
|
orchard: '#c9e0a6', vineyard: '#d4e3ae', water: '#a8cde6', wetland: '#c3dcd6', beach: '#f0e3bd', sand: '#ebdfbf',
|
||||||
|
parking: '#d7d6d3', pitch: '#b3d79e', playground: '#d6ecc9', cemetery: '#c3d7b4', construction: '#ddd5c4',
|
||||||
|
railway: '#dad5cc', school: '#efe5cc', hospital: '#efdcdc', pedestrian: '#e0dbd2', garden: '#d3e8b7',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROAD = {
|
||||||
|
major: '#8e9196',
|
||||||
|
minor: '#a3a6ab',
|
||||||
|
service: '#b4b6b9',
|
||||||
|
sidewalk: '#d4d0c8',
|
||||||
|
kerb: '#b9b4ab',
|
||||||
|
centre: '#f4f1e8',
|
||||||
|
footway: '#e3d6bd',
|
||||||
|
path: '#d4c29f',
|
||||||
|
track: '#c7b28c',
|
||||||
|
rail: '#6f6a64',
|
||||||
|
sleeper: '#a59b8d',
|
||||||
|
water: '#8fc0e0',
|
||||||
|
bridgeCasing: '#5e5a54',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROOF: Record<BuildingKind, string> = {
|
||||||
|
house: '#d2b9a3', apartments: '#c4b3a6', mixed: '#c8b2ad', retail: '#c9b5c3', restaurant: '#cdb3b0',
|
||||||
|
office: '#b9bfc9', industrial: '#bdbcc2', warehouse: '#b6b8bd', school: '#d6c4a0', hospital: '#d6bcbc',
|
||||||
|
hotel: '#c9b9b0', church: '#c4a98a', civic: '#cfc19f', garage: '#bab6b0', shed: '#c7bba8', farm: '#c9b394',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROOM_COLOR: Partial<Record<RoomKind, string>> & { default: string } = {
|
||||||
|
default: '#f6f3ed',
|
||||||
|
living: '#f4ead8', bedroom: '#ece7f2', kitchen: '#f3eed4', bath: '#dbeaf0', wc: '#dbeaf0', hall: '#efebe5',
|
||||||
|
corridor: '#ece8e1', lobby: '#ece8e1', landing: '#e9e5de', stairs: '#e2ddd4', lift: '#d6d2cb', storage: '#ebe5d9', utility: '#e5e8e4',
|
||||||
|
sales: '#f6ecd9', backroom: '#ebe5d9', office: '#e8edf1', openoffice: '#e8edf1', meeting: '#e3e9f0',
|
||||||
|
dining: '#f5e6d8', prokitchen: '#e6ebec', workshop: '#e6e5e1', hall_ind: '#e4e4e2', classroom: '#f2ecd9',
|
||||||
|
staff: '#ecebe2', ward: '#e6f0ee', guestroom: '#f0e8f0', nave: '#f1e9da', garage: '#e1e1df', void: '#d9d6d0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROOM_NAME: Record<RoomKind, string> = {
|
||||||
|
hall: 'прихожая', landing: 'площадка', living: 'гостиная', kitchen: 'кухня', bedroom: 'спальня', bath: 'ванная', wc: 'туалет',
|
||||||
|
storage: 'кладовая', corridor: 'коридор', stairs: 'лестница', lift: 'лифт', lobby: 'вестибюль', sales: 'торговый зал',
|
||||||
|
backroom: 'подсобка', office: 'кабинет', openoffice: 'опенспейс', meeting: 'переговорная', dining: 'зал',
|
||||||
|
prokitchen: 'кухня', workshop: 'мастерская', hall_ind: 'цех', classroom: 'класс', staff: 'учительская', ward: 'палата',
|
||||||
|
guestroom: 'номер', nave: 'зал', garage: 'гараж', utility: 'техпомещение', void: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KIND_NAME: Record<BuildingKind, string> = {
|
||||||
|
house: 'Жилой дом', apartments: 'Многоквартирный дом', mixed: 'Жилой дом с магазином', retail: 'Магазин',
|
||||||
|
restaurant: 'Кафе / ресторан', office: 'Офис', industrial: 'Производство', warehouse: 'Склад / ангар', school: 'Школа',
|
||||||
|
hospital: 'Больница', hotel: 'Гостиница', church: 'Храм', civic: 'Общественное здание', garage: 'Гараж',
|
||||||
|
shed: 'Сарай / хозпостройка', farm: 'Хозяйственная постройка',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHAPE_FILL: Record<ShapeStyle, string | null> = {
|
||||||
|
base: '#fbfaf6', soft: '#ffffff', dark: '#5f5b55', green: '#86b26e', glass: '#bcd9e8', metal: '#bfc4c8',
|
||||||
|
wood: '#c9a57f', water: '#cfe6f2', line: null, car: '#999', fabric: '#b8a48f', accent: '#c88e64',
|
||||||
|
};
|
||||||
|
export const SHAPE_STROKE = '#5c564e';
|
||||||
|
|
||||||
|
export const WALL_EXT = '#33312e';
|
||||||
|
export const WALL_INT = '#4d4a45';
|
||||||
|
export const WINDOW = '#5d93b8';
|
||||||
|
export const DOOR = '#7b766f';
|
||||||
|
export const TREE = 'rgba(96,148,72,0.78)';
|
||||||
|
export const TREE_EDGE = 'rgba(64,104,48,0.9)';
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// Просмотрщик мира: камера (перетаскивание, колесо, щипок), этажи, выбор здания.
|
||||||
|
|
||||||
|
import type { Building, Vec, World } from '../types';
|
||||||
|
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
||||||
|
import { KIND_NAME, ROOM_NAME } from './style';
|
||||||
|
import { centroid } from '../util/geom';
|
||||||
|
|
||||||
|
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (html !== undefined) e.innerHTML = html;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||||
|
|
||||||
|
export class Viewer {
|
||||||
|
readonly root = el('div', 'viewer');
|
||||||
|
private canvas = el('canvas', 'world-canvas');
|
||||||
|
private ctx = this.canvas.getContext('2d')!;
|
||||||
|
private renderer: Renderer;
|
||||||
|
cam: Camera;
|
||||||
|
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
||||||
|
private raf = 0;
|
||||||
|
private floorBar = el('div', 'floor-bar card');
|
||||||
|
private info = el('div', 'info card');
|
||||||
|
private scale = el('div', 'scale');
|
||||||
|
private maxLevel: number;
|
||||||
|
private pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
|
||||||
|
|
||||||
|
constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) {
|
||||||
|
this.renderer = new Renderer(world);
|
||||||
|
const B = world.bounds;
|
||||||
|
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
|
||||||
|
this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels)));
|
||||||
|
this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'));
|
||||||
|
parent.appendChild(this.root);
|
||||||
|
this.info.hidden = true;
|
||||||
|
this.buildFloorBar();
|
||||||
|
new ResizeObserver(() => this.resize()).observe(this.root);
|
||||||
|
this.bindInput();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.resize();
|
||||||
|
this.fitBounds();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.root.remove();
|
||||||
|
window.removeEventListener('keydown', this.onKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTop(): HTMLElement {
|
||||||
|
const top = el('div', 'viewer-top card');
|
||||||
|
const w = this.world;
|
||||||
|
let floors = 0, rooms = 0, furn = 0, beds = 0, homes = 0;
|
||||||
|
for (const b of w.buildings) {
|
||||||
|
floors += b.floors.length;
|
||||||
|
beds += b.beds;
|
||||||
|
homes += b.units.filter((u) => u.kind === 'home').length;
|
||||||
|
for (const f of b.floors) { rooms += f.rooms.length; furn += f.props.length; }
|
||||||
|
}
|
||||||
|
top.innerHTML = `
|
||||||
|
<div class="title">${esc(w.name || 'Без названия')}</div>
|
||||||
|
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" data-a="new">Новая карта</button>
|
||||||
|
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
|
||||||
|
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
|
||||||
|
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
||||||
|
</div>`;
|
||||||
|
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
|
||||||
|
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
|
||||||
|
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
|
||||||
|
inp.addEventListener('change', () => {
|
||||||
|
(this.opts as any)[inp.dataset.t!] = inp.checked;
|
||||||
|
this.request();
|
||||||
|
}));
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFloorBar() {
|
||||||
|
const bar = this.floorBar;
|
||||||
|
bar.innerHTML = '<div class="floor-title">Этаж</div>';
|
||||||
|
const list = el('div', 'floor-list');
|
||||||
|
const mk = (label: string, value: number | null, title: string) => {
|
||||||
|
const b = el('button', 'floor-btn', label);
|
||||||
|
b.title = title;
|
||||||
|
b.dataset.v = value === null ? 'roof' : String(value);
|
||||||
|
b.addEventListener('click', () => this.setFloor(value));
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
list.appendChild(mk('⌂', null, 'Крыши'));
|
||||||
|
for (let l = this.maxLevel - 1; l >= 0; l--) list.appendChild(mk(String(l + 1), l, `${l + 1}-й этаж`));
|
||||||
|
bar.appendChild(list);
|
||||||
|
bar.appendChild(el('div', 'floor-hint', 'PgUp / PgDn'));
|
||||||
|
this.syncFloorBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncFloorBar() {
|
||||||
|
const v = this.opts.floor === null ? 'roof' : String(this.opts.floor);
|
||||||
|
this.floorBar.querySelectorAll<HTMLButtonElement>('.floor-btn').forEach((b) => b.classList.toggle('on', b.dataset.v === v));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFloor(v: number | null) {
|
||||||
|
this.opts.floor = v;
|
||||||
|
this.opts.overrides.clear();
|
||||||
|
this.syncFloorBar();
|
||||||
|
this.updateInfo();
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize() {
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.canvas.width = Math.round(r.width * dpr);
|
||||||
|
this.canvas.height = Math.round(r.height * dpr);
|
||||||
|
this.canvas.style.width = r.width + 'px';
|
||||||
|
this.canvas.style.height = r.height + 'px';
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
fitBounds() {
|
||||||
|
const B = this.world.bounds;
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
this.cam.x = (B.minX + B.maxX) / 2;
|
||||||
|
this.cam.y = (B.minY + B.maxY) / 2;
|
||||||
|
this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92;
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перелёт к точке (для отладки и будущих событий). */
|
||||||
|
lookAt(p: Vec, z: number) {
|
||||||
|
this.cam = { x: p[0], y: p[1], z };
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
request() {
|
||||||
|
if (this.raf) return;
|
||||||
|
this.raf = requestAnimationFrame(() => {
|
||||||
|
this.raf = 0;
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
|
||||||
|
this.updateScale();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateScale() {
|
||||||
|
const z = this.cam.z;
|
||||||
|
const target = 120 / z;
|
||||||
|
const nice = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000].find((n) => n >= target * 0.6) ?? 5000;
|
||||||
|
this.scale.style.width = nice * z + 'px';
|
||||||
|
this.scale.textContent = nice >= 1000 ? `${nice / 1000} км` : `${nice} м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenToWorld(x: number, y: number): Vec {
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
return [(x - r.width / 2) / this.cam.z + this.cam.x, (y - r.height / 2) / this.cam.z + this.cam.y];
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomAt(x: number, y: number, factor: number) {
|
||||||
|
const before = this.screenToWorld(x, y);
|
||||||
|
this.cam.z = Math.max(0.05, Math.min(120, this.cam.z * factor));
|
||||||
|
const after = this.screenToWorld(x, y);
|
||||||
|
this.cam.x += before[0] - after[0];
|
||||||
|
this.cam.y += before[1] - after[1];
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
|
||||||
|
const f = this.opts.floor;
|
||||||
|
if (e.key === 'PageUp' || e.key === ']') this.setFloor(f === null ? 0 : Math.min(this.maxLevel - 1, f + 1));
|
||||||
|
else if (e.key === 'PageDown' || e.key === '[') this.setFloor(f === null || f === 0 ? null : f - 1);
|
||||||
|
else if (e.key === 'Escape') this.select(null);
|
||||||
|
else if (e.key === '+' || e.key === '=') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1.3);
|
||||||
|
else if (e.key === '-') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1 / 1.3);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
private bindInput() {
|
||||||
|
const c = this.canvas;
|
||||||
|
c.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||||
|
this.zoomAt(e.offsetX, e.offsetY, Math.exp(-e.deltaY * k));
|
||||||
|
}, { passive: false });
|
||||||
|
c.addEventListener('pointerdown', (e) => {
|
||||||
|
c.setPointerCapture(e.pointerId);
|
||||||
|
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||||
|
const ps = [...this.pointers.values()];
|
||||||
|
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
|
||||||
|
this.gesture = { x: mx, y: my, cam: { ...this.cam }, d: ps.length === 2 ? Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y) : undefined, moved: false };
|
||||||
|
});
|
||||||
|
c.addEventListener('pointermove', (e) => {
|
||||||
|
if (!this.pointers.has(e.pointerId) || !this.gesture) return;
|
||||||
|
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
|
||||||
|
const ps = [...this.pointers.values()];
|
||||||
|
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
|
||||||
|
const g = this.gesture;
|
||||||
|
if (Math.hypot(mx - g.x, my - g.y) > 4) g.moved = true;
|
||||||
|
if (ps.length === 2 && g.d) {
|
||||||
|
const d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y);
|
||||||
|
this.cam.z = Math.max(0.05, Math.min(120, g.cam.z * (d / g.d)));
|
||||||
|
g.moved = true;
|
||||||
|
}
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
const anchor: Vec = [(g.x - r.width / 2) / g.cam.z + g.cam.x, (g.y - r.height / 2) / g.cam.z + g.cam.y];
|
||||||
|
this.cam.x = anchor[0] - (mx - r.width / 2) / this.cam.z;
|
||||||
|
this.cam.y = anchor[1] - (my - r.height / 2) / this.cam.z;
|
||||||
|
this.request();
|
||||||
|
});
|
||||||
|
const end = (e: PointerEvent) => {
|
||||||
|
const g = this.gesture;
|
||||||
|
this.pointers.delete(e.pointerId);
|
||||||
|
if (g && !g.moved && this.pointers.size === 0) {
|
||||||
|
const b = this.renderer.buildingAt(this.screenToWorld(e.offsetX, e.offsetY));
|
||||||
|
this.select(b?.id ?? null);
|
||||||
|
}
|
||||||
|
if (this.pointers.size) {
|
||||||
|
const ps = [...this.pointers.values()];
|
||||||
|
this.gesture = { x: ps[0].x, y: ps[0].y, cam: { ...this.cam }, moved: true };
|
||||||
|
} else this.gesture = null;
|
||||||
|
};
|
||||||
|
c.addEventListener('pointerup', end);
|
||||||
|
c.addEventListener('pointercancel', end);
|
||||||
|
c.addEventListener('dblclick', (e) => this.zoomAt(e.offsetX, e.offsetY, 2));
|
||||||
|
window.addEventListener('keydown', this.onKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(id: string | null) {
|
||||||
|
this.opts.selected = id;
|
||||||
|
this.updateInfo();
|
||||||
|
this.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateInfo() {
|
||||||
|
const id = this.opts.selected;
|
||||||
|
const b = id ? this.world.buildings.find((x) => x.id === id) : null;
|
||||||
|
if (!b) {
|
||||||
|
this.info.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.info.hidden = false;
|
||||||
|
const cur = this.opts.overrides.has(b.id) ? this.opts.overrides.get(b.id)! : this.opts.floor;
|
||||||
|
const lvl = cur !== null && cur < b.levels ? cur : null;
|
||||||
|
const f = lvl !== null ? b.floors[lvl] : null;
|
||||||
|
const src = b.levelsSource === 'osm' ? 'из OSM' : b.levelsSource === 'height' ? 'по высоте' : 'оценка';
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
if (f) for (const r of f.rooms) if (ROOM_NAME[r.kind]) counts.set(ROOM_NAME[r.kind], (counts.get(ROOM_NAME[r.kind]) ?? 0) + 1);
|
||||||
|
const roomList = [...counts.entries()].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(', ');
|
||||||
|
const homes = b.units.filter((u) => u.kind === 'home').length;
|
||||||
|
const osm = b.osmId ? `<a href="https://www.openstreetmap.org/${b.osmId}" target="_blank" rel="noopener">OSM</a>` : '';
|
||||||
|
this.info.innerHTML = `
|
||||||
|
<button class="close" title="Закрыть">×</button>
|
||||||
|
<div class="title">${esc(b.name || KIND_NAME[b.kind])}</div>
|
||||||
|
<div class="sub">${b.name ? esc(KIND_NAME[b.kind]) + ' · ' : ''}${b.use ? esc(b.use) + ' · ' : ''}${b.address ? esc(b.address) : ''}</div>
|
||||||
|
<div class="kv"><span>Этажей</span><b>${b.levels}</b> <i>${src}</i></div>
|
||||||
|
<div class="kv"><span>Площадь застройки</span><b>${Math.round(b.area)} м²</b></div>
|
||||||
|
${homes ? `<div class="kv"><span>Жилых помещений</span><b>${homes}</b></div>` : ''}
|
||||||
|
${b.beds ? `<div class="kv"><span>Спальных мест</span><b>${b.beds}</b></div>` : ''}
|
||||||
|
<div class="floor-pick">${['⌂', ...Array.from({ length: Math.min(b.levels, 40) }, (_, i) => String(i + 1))].map((t, i) => `<button class="floor-btn sm${(i === 0 && lvl === null) || lvl === i - 1 ? ' on' : ''}" data-l="${i - 1}">${t}</button>`).join('')}</div>
|
||||||
|
${f ? `<div class="rooms">${f.rooms.length} помещений: ${esc(roomList)}</div>` : ''}
|
||||||
|
<div class="links">${osm}<a href="#" data-a="zoom">Приблизить</a></div>`;
|
||||||
|
this.info.querySelector('.close')!.addEventListener('click', () => this.select(null));
|
||||||
|
this.info.querySelectorAll<HTMLButtonElement>('[data-l]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const l = +btn.dataset.l!;
|
||||||
|
this.opts.overrides.set(b.id, l < 0 ? null : l);
|
||||||
|
this.updateInfo();
|
||||||
|
this.request();
|
||||||
|
}));
|
||||||
|
this.info.querySelector('[data-a=zoom]')!.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const c = centroid(b.footprint);
|
||||||
|
const span = Math.sqrt(b.area) * 1.8 + 10;
|
||||||
|
const r = this.root.getBoundingClientRect();
|
||||||
|
this.lookAt(c, Math.min(60, Math.min(r.width, r.height) / span));
|
||||||
|
if (this.opts.overrides.get(b.id) == null && this.opts.floor === null) this.opts.overrides.set(b.id, 0);
|
||||||
|
this.updateInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectBuilding(b: Building) {
|
||||||
|
this.select(b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user