maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
173 lines (143 loc) • 6.29 kB
text/typescript
import {EXTENT} from '../extent.ts';
import {isBoundaryEdge} from '../extent_bounds.ts';
import {MercatorCoordinate} from '../../geo/mercator_coordinate.ts';
import {tileCoordinatesToLocation} from '../../geo/projection/mercator_utils.ts';
import type Point from '@mapbox/point-geometry';
import type {CanonicalTileID} from '../../tile/tile_id.ts';
/**
* Rounds polygon corners by calculating arc points at each corner vertex.
* @param polygon - Collection of polygon rings (outer ring and hole rings)
* @param distanceInMeters - Desired corner rounding distance in meters
* @param canonical - Canonical tile ID used for meter to tile unit conversion
*/
export function roundPolygonCorners(
polygon: Point[][],
distanceInMeters: number,
canonical: CanonicalTileID
): Point[][] {
if (distanceInMeters <= 0 || !polygon || polygon.length === 0) {
return polygon;
}
const distanceInTileUnits = getTileUnitsForMeters(distanceInMeters, canonical);
return polygon.map(ring => roundRing(ring, distanceInTileUnits));
}
function getTileUnitsForMeters(distanceInMeters: number, canonical: CanonicalTileID): number {
const centerLocation = tileCoordinatesToLocation(EXTENT / 2, EXTENT / 2, canonical);
const mercatorCoord = MercatorCoordinate.fromLngLat(centerLocation);
const meterInMercator = mercatorCoord.meterInMercatorCoordinateUnits();
const tileUnitsPerMercator = (1 << canonical.z) * EXTENT;
return distanceInMeters * meterInMercator * tileUnitsPerMercator;
}
/**
* Rounds the corners of a single ring.
*
* Corners that tile clipping created are left sharp: they belong to the cut rather than to the
* feature, and the neighbouring tile cuts the same feature elsewhere, so rounding them would leave
* the two halves out of step. Every vertex ends up on the integer tile grid, because triangulation,
* subdivision and the vertex buffers snap and deduplicate vertices there - arcs finer than a tile
* unit would otherwise be merged only after they were triangulated, turning the mesh into spikes.
*
* A ring that collapses into fewer than three distinct vertices is returned unchanged.
* @param ring - Ring to round, closed or open
* @param distanceInTileUnits - Corner rounding distance, already converted to tile units
*/
function roundRing(ring: Point[], distanceInTileUnits: number): Point[] {
if (!ring || ring.length < 3) {
return ring;
}
const isClosed = ring[0].x === ring[ring.length - 1].x && ring[0].y === ring[ring.length - 1].y;
const vertexCount = isClosed ? ring.length - 1 : ring.length;
if (vertexCount < 3) {
return ring;
}
const newRing: Point[] = [];
for (let i = 0; i < vertexCount; i++) {
const previous = ring[(i - 1 + vertexCount) % vertexCount];
const current = ring[i];
const next = ring[(i + 1) % vertexCount];
if (isBoundaryEdge(previous, current) || isBoundaryEdge(current, next)) {
newRing.push(current.clone());
continue;
}
appendRoundCorner(newRing, previous, current, next, distanceInTileUnits);
}
const snapped = snapToIntegerGrid(newRing);
if (snapped.length < 3) {
return ring;
}
if (isClosed) {
snapped.push(snapped[0].clone());
}
return snapped;
}
/**
* Rounds every vertex to the integer tile grid, dropping vertices that collapse onto their neighbour.
* The ring is treated as closed, so the wrap-around duplicate is dropped as well.
* @param ring - Ring to snap
*/
function snapToIntegerGrid(ring: Point[]): Point[] {
const snapped: Point[] = [];
for (const p of ring) {
const point = p.round();
const previous = snapped[snapped.length - 1];
if (previous?.x === point.x && previous?.y === point.y) {
continue;
}
snapped.push(point);
}
while (snapped.length > 1 && snapped[0].x === snapped[snapped.length - 1].x && snapped[0].y === snapped[snapped.length - 1].y) {
snapped.pop();
}
return snapped;
}
/**
* Appends the arc that replaces one corner, or the corner itself when it is too shallow or too sharp
* to round.
* @param newRing - Ring being built, the arc points are appended to it
* @param prev - Vertex before the corner
* @param current - The corner
* @param next - Vertex after the corner
* @param distanceInTileUnits - Corner rounding distance, already converted to tile units
*/
function appendRoundCorner(
newRing: Point[],
prev: Point,
current: Point,
next: Point,
distanceInTileUnits: number
): void {
// Unit edge vectors from the current vertex towards its neighbours
const ua = prev.sub(current);
const ub = next.sub(current);
const lenA = ua.mag();
const lenB = ub.mag();
if (lenA < 1e-6 || lenB < 1e-6) {
newRing.push(current.clone());
return;
}
ua._div(lenA);
ub._div(lenB);
// Straight lines or zero-degree turns
const dot = ua.x * ub.x + ua.y * ub.y;
if (Math.abs(dot) > Math.cos(5 * Math.PI / 180)) {
newRing.push(current.clone());
return;
}
// we clamp to not have circles in the extremes
const maxEdgeLenPercent = 0.2;
const r = Math.min(distanceInTileUnits, lenA * maxEdgeLenPercent, lenB * maxEdgeLenPercent);
// Tangent points on edges to prevPoint and nextPoint
const tangentA = current.add(ua.mult(r));
const tangentB = current.add(ub.mult(r));
// Center of the rounding arc, at r / cos(theta/2) along the bisector
const cosHalfTheta = Math.sqrt((1 + dot) / 2);
const center = current.add(ua.add(ub)._unit()._mult(r / cosHalfTheta));
// Both tangent points lie on the arc circle, so rotating tangent A around the center by the angle
// between the two radii traces the fillet onto tangent B along the shortest arc.
const sweepAngle = tangentA.sub(center).angleWith(tangentB.sub(center));
// ~30 deg per segment; epsilon keeps fp noise from adding one at exact multiples.
const numSegments = Math.max(2, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 6) - 1e-6));
for (let s = 0; s <= numSegments; s++) {
newRing.push(tangentA.rotateAround(sweepAngle * (s / numSegments), center));
}
}