import type { OsmElement } from '../osm/overpass'; import type { Prop, Vec, World } from '../types'; import { bboxOf, centroid, closestOnSegment, distToSegment, pointInRings } from '../util/geom'; import { SpatialGrid } from '../util/spatial'; import { Projector } from './project'; import { streetObject } from './objects'; export function populateStreets(world: World, elements: OsmElement[]) { const buildings = new SpatialGrid(50); for (const b of world.buildings) buildings.insert(b, bboxOf(b.footprint)); const water = new SpatialGrid(100); for (const a of world.areas) if (['water', 'wetland'].includes(a.kind)) water.insert(a, bboxOf(a.rings[0])); const occupied = new SpatialGrid(10); const box = (p: Prop): [number, number, number, number] => { const x = (Math.abs(Math.cos(p.rot)) * p.w + Math.abs(Math.sin(p.rot)) * p.d) / 2 + 0.3; const y = (Math.abs(Math.sin(p.rot)) * p.w + Math.abs(Math.cos(p.rot)) * p.d) / 2 + 0.3; return [p.pos[0] - x, p.pos[1] - y, p.pos[0] + x, p.pos[1] + y]; }; for (const p of world.props) occupied.insert(p, box(p)); const roadSegments = new SpatialGrid<{ a: Vec; b: Vec; width: number }>(40); for (const road of world.roads) if (!road.tunnel && !road.bridge) for (let i = 1; i < road.pts.length; i++) { const a = road.pts[i - 1], b = road.pts[i]; roadSegments.insert({ a, b, width: road.width }, [Math.min(a[0], b[0]) - road.width, Math.min(a[1], b[1]) - road.width, Math.max(a[0], b[0]) + road.width, Math.max(a[1], b[1]) + road.width]); } const ids = new Set(world.props.map(p => p.id)); let budget = 20000; const add = (p: Prop, onRoad = false) => { if (budget <= 0 || ids.has(p.id)) return; const bb = box(p), B = world.bounds; if (bb[0] < B.minX || bb[1] < B.minY || bb[2] > B.maxX || bb[3] > B.maxY) return; const samples: Vec[] = [p.pos, [bb[0], bb[1]], [bb[2], bb[1]], [bb[2], bb[3]], [bb[0], bb[3]]]; if (buildings.query(bb).some(b => samples.some(q => pointInRings(q, [b.footprint, ...b.holes]))) || water.query(bb).some(a => samples.some(q => pointInRings(q, a.rings)))) return; if (!onRoad && roadSegments.query(bb).some(s => samples.some(q => distToSegment(q, s.a, s.b) < s.width / 2 + 0.15))) return; if (occupied.query(bb).some(other => { const b = box(other); return bb[0] < b[2] && bb[2] > b[0] && bb[1] < b[3] && bb[3] > b[1]; })) return; world.props.push(p); occupied.insert(p, bb); ids.add(p.id); budget--; }; const projector = new Projector(world.origin.lat, world.origin.lon); for (const element of elements) { if (element.type !== 'node' || element.lat == null || element.lon == null) continue; const t = element.tags ?? {}, pos = projector.toXY(element.lat, element.lon); const kind = t.highway === 'bus_stop' || t.public_transport === 'platform' ? 'bus_stop' : t.highway === 'crossing' ? 'crossing' : t.amenity === 'waste_basket' ? 'bin' : t.amenity === 'marketplace' ? 'market_stall' : null; if (kind) { const p = streetObject(kind, `osm-${kind}-${element.id}`, pos); if (kind === 'crossing') { const near = roadSegments.query([pos[0] - 10, pos[1] - 10, pos[0] + 10, pos[1] + 10]) .sort((a, b) => distToSegment(pos, a.a, a.b) - distToSegment(pos, b.a, b.b))[0]; if (near) { const q = closestOnSegment(pos, near.a, near.b); p.pos = q; p.rot = Math.atan2(near.b[1] - near.a[1], near.b[0] - near.a[0]); p.d = near.width; p.shapes = [{ t: 'rect', x: 0, y: 0, w: p.w, h: p.d, s: 'line' }]; } } add(p, kind === 'crossing'); } } // Regular furniture along walkable streets; stable IDs and per-road seeds. for (const road of world.roads) { if (road.bridge || road.tunnel || road.layer !== 0 || !['residential', 'secondary', 'tertiary', 'pedestrian', 'living_street', 'footway'].includes(road.kind)) continue; let distance = 15; let serial = 0; for (let i = 1; i < road.pts.length; i++) { const a = road.pts[i - 1], b = road.pts[i], length = Math.hypot(b[0] - a[0], b[1] - a[1]); if (!length) continue; const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); for (; distance < length && budget > 0; distance += 35) { const side = serial % 2 ? 1 : -1; const offset = road.width / 2 + road.sidewalk + 1; const p: Vec = [a[0] + (b[0] - a[0]) * distance / length - Math.sin(angle) * offset * side, a[1] + (b[1] - a[1]) * distance / length + Math.cos(angle) * offset * side]; const kind = serial % 4 === 0 ? 'bench' : serial % 4 === 2 ? 'bin' : 'lamp'; add(streetObject(kind, `street-${road.id}-${serial++}`, p, angle + (side < 0 ? Math.PI : 0))); } distance -= length; } } for (const area of world.areas) { if (!['park', 'garden', 'playground', 'pedestrian'].includes(area.kind)) continue; const pos = centroid(area.rings[0]); if (!pointInRings(pos, area.rings)) continue; add(streetObject('courtyard', `courtyard-${area.id}`, pos)); add(streetObject(area.kind === 'pedestrian' ? 'market_stall' : 'bench', `amenity-${area.id}`, [pos[0] + 4, pos[1]])); } } /** Entrances are derived from the actual finished floor plans, never guessed. */ export function addStreetEntrances(world: World) { const ids = new Set(world.props.map(p => p.id)); for (const building of world.buildings) for (const floor of building.floors) { if (floor.level !== 0) continue; for (const door of floor.doors) { if (!door.rooms.includes('outside')) continue; const id = `entrance-${building.id}-${door.id}`; if (ids.has(id)) continue; const pos: Vec = [(door.a[0] + door.b[0]) / 2, (door.a[1] + door.b[1]) / 2]; const prop = streetObject('entrance', id, pos, Math.atan2(door.b[1] - door.a[1], door.b[0] - door.a[0])); prop.target = { buildingId: building.id, doorId: door.id, level: floor.level }; world.props.push(prop); ids.add(id); } } }