// Уровень города: зоны, дороги, контуры зданий, уличные объекты. // Интерьеры генерируются отдельно (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 = { 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 = { water: 'water', wood: 'forest', scrub: 'scrub', heath: 'scrub', grassland: 'meadow', wetland: 'wetland', beach: 'beach', sand: 'sand', bare_rock: 'sand', scree: 'sand', }; const LEISURE: Record = { 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 = { parking: 'parking', school: 'school', kindergarten: 'school', college: 'school', university: 'school', hospital: 'hospital', grave_yard: 'cemetery', marketplace: 'pedestrian', fuel: 'pedestrian', }; const AREA_TIER: Record = { 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 = { 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 = { primary: 2.2, secondary: 2, tertiary: 1.8, residential: 1.5, living_street: 0, trunk: 0 }; function areaKindOf(t: Record): 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 }[] = []; 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(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(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 }>(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(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; 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(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; }