@maplat/tin
Version:
JavaScript library which performs homeomorphic conversion mutually between the coordinate systems of two planes based on the control points.
558 lines (488 loc) • 19.6 kB
text/typescript
import { lineString, lineIntersect } from "@turf/turf";
import type { Position } from "geojson";
import type { VertexPosition } from "./types/tin.d.ts";
interface ConvexEntry {
forw: Position;
bakw: Position;
}
/**
* Unified parameters for all boundary vertex computations (V2 and V3).
*
* `allGcps` must include **all** interior points: GCPs from `this.points` **and**
* edge intermediate nodes from `this.edgeNodes`. This is necessary so that
* `checkAndAdjustVerticesN` can guarantee that every constrained-edge vertex in
* bakw space lies inside the boundary polygon.
*/
export interface BoundaryVerticesParams {
convexBuf: Record<string, ConvexEntry>;
centroid: { forw: Position; bakw: Position };
/** GCPs + edge intermediate nodes */
allGcps: Array<{ forw: Position; bakw: Position }>;
minx: number;
maxx: number;
miny: number;
maxy: number;
}
// ─── Internal types ──────────────────────────────────────────────────────────
type SamplePair = {
forw: [number, number];
bakw: [number, number];
};
// ─── Ratio helpers ───────────────────────────────────────────────────────────
function collectSamples(
convexBuf: Record<string, ConvexEntry>,
centroid: { forw: Position; bakw: Position },
): { perQuad: SamplePair[][]; aggregate: SamplePair[] } {
const perQuad: SamplePair[][] = [[], [], [], []];
const aggregate: SamplePair[] = [];
Object.keys(convexBuf).forEach((key) => {
const item = convexBuf[key];
const forw = item.forw;
const bakw = item.bakw;
const forwDelta: [number, number] = [
forw[0] - centroid.forw[0],
forw[1] - centroid.forw[1],
];
const bakwDelta: [number, number] = [
bakw[0] - centroid.bakw[0],
centroid.bakw[1] - bakw[1],
];
const sample: SamplePair = { forw: forwDelta, bakw: bakwDelta };
aggregate.push(sample);
if (forwDelta[0] === 0 || forwDelta[1] === 0) {
return;
}
let quad = 0;
if (forwDelta[0] > 0) quad += 1;
if (forwDelta[1] > 0) quad += 2;
perQuad[quad].push(sample);
});
return { perQuad, aggregate };
}
function reduceSamples(samples: SamplePair[]): [number, number] {
let minRatio = Infinity;
let sumCos = 0;
let sumSin = 0;
samples.forEach((sample) => {
const { forw, bakw } = sample;
const forwNorm = Math.hypot(forw[0], forw[1]);
const bakwNorm = Math.hypot(bakw[0], bakw[1]);
if (bakwNorm === 0) return;
const ratio = forwNorm / bakwNorm;
const theta = Math.atan2(forw[0], forw[1]) - Math.atan2(bakw[0], bakw[1]);
minRatio = Math.min(minRatio, ratio);
sumCos += Math.cos(theta);
sumSin += Math.sin(theta);
});
if (!isFinite(minRatio)) {
return [1, 0];
}
return [minRatio, Math.atan2(sumSin, sumCos)];
}
function buildVertexRatio(
convexBuf: Record<string, ConvexEntry>,
centroid: { forw: Position; bakw: Position },
mode: "plain" | "birdeye",
): Array<[number, number]> {
const { perQuad, aggregate } = collectSamples(convexBuf, centroid);
const hasAllQuadrants = perQuad.every((samples) => samples.length > 0);
const groups: SamplePair[][] = mode === "birdeye"
? (hasAllQuadrants ? perQuad : [aggregate])
: [aggregate];
const vertexRatio = groups.map((samples) => reduceSamples(samples));
if (vertexRatio.length === 1) {
return [vertexRatio[0], vertexRatio[0], vertexRatio[0], vertexRatio[0]];
}
return vertexRatio;
}
// ─── Geometry helpers ────────────────────────────────────────────────────────
/**
* Return quadrant index (0–3) of `forw` relative to `centroidForw`.
*
* Quadrant encoding:
* q0: Δx≤0, Δy≤0 q1: Δx>0, Δy≤0
* q2: Δx≤0, Δy>0 q3: Δx>0, Δy>0
*/
function forwQuadrant(forw: Position, centroidForw: Position): number {
let q = 0;
if (forw[0] > centroidForw[0]) q += 1;
if (forw[1] > centroidForw[1]) q += 2;
return q;
}
/**
* Compute the bakw position for a given forw position using the centroid and
* an aggregate [scale, rotation] ratio.
*/
function computeVertexBakwFromRatio(
forwPos: Position,
centroid: { forw: Position; bakw: Position },
ratio: [number, number],
): Position {
const forDelta = [
forwPos[0] - centroid.forw[0],
forwPos[1] - centroid.forw[1],
];
const forDistance = Math.sqrt(forDelta[0] ** 2 + forDelta[1] ** 2);
const bakDistance = forDistance / ratio[0];
const bakTheta = Math.atan2(forDelta[0], forDelta[1]) - ratio[1];
return [
centroid.bakw[0] + bakDistance * Math.sin(bakTheta),
centroid.bakw[1] - bakDistance * Math.cos(bakTheta),
];
}
/**
* Find the intersection of the ray from `centroid` through `rayDir` with the
* segment [segStart, segEnd]. Returns {t, point} where t > 0 is the parameter
* along the ray, or null if no valid intersection.
*/
function findRaySegmentIntersection(
centroid: Position,
rayDir: Position,
segStart: Position,
segEnd: Position,
): { t: number; point: Position } | null {
const dx = rayDir[0] - centroid[0];
const dy = rayDir[1] - centroid[1];
if (Math.abs(dx) < 1e-12 && Math.abs(dy) < 1e-12) return null;
const ex = segEnd[0] - segStart[0];
const ey = segEnd[1] - segStart[1];
const fx = segStart[0] - centroid[0];
const fy = segStart[1] - centroid[1];
const denom = dx * ey - dy * ex;
if (Math.abs(denom) < 1e-12) return null;
const t = (fx * ey - fy * ex) / denom;
const s = (fx * dy - fy * dx) / denom;
if (t <= 1e-10) return null;
if (s < -1e-10 || s > 1 + 1e-10) return null;
return { t, point: [centroid[0] + t * dx, centroid[1] + t * dy] };
}
/**
* Find the exit intersection of the ray from `centroid` through `rayDir` with
* the convex polygon formed by `corners` (angularly-sorted VertexPosition[]).
*/
function findRayQuadIntersection(
centroid: Position,
rayDir: Position,
corners: VertexPosition[],
): Position | null {
const N = corners.length;
let bestT = -Infinity;
let bestPoint: Position | null = null;
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const result = findRaySegmentIntersection(
centroid, rayDir, corners[i].bakw, corners[j].bakw,
);
if (result && result.t > bestT) {
bestT = result.t;
bestPoint = result.point;
}
}
return bestPoint;
}
/** Convert a forw position to [0, 360) degrees using the atan2(Δx, Δy) convention. */
function toAngleDeg360(pos: Position, centroid: Position): number {
const rawRad = Math.atan2(pos[0] - centroid[0], pos[1] - centroid[1]);
const deg = rawRad * (180 / Math.PI);
return deg < 0 ? deg + 360 : deg;
}
/**
* Find the first intersection of the ray from `centroid` THROUGH `vertex` with
* the axis-aligned bbox [minx,maxx] × [miny,maxy].
*/
function findRayBboxIntersection(
centroid: Position,
vertex: Position,
minx: number,
maxx: number,
miny: number,
maxy: number,
): Position | null {
const dx = vertex[0] - centroid[0];
const dy = vertex[1] - centroid[1];
if (dx === 0 && dy === 0) return null;
const candidates: { t: number; x: number; y: number }[] = [];
if (dx !== 0) {
for (const bx of [minx, maxx]) {
const t = (bx - centroid[0]) / dx;
if (t > 0) {
const y = centroid[1] + t * dy;
if (y >= miny && y <= maxy) candidates.push({ t, x: bx, y });
}
}
}
if (dy !== 0) {
for (const by of [miny, maxy]) {
const t = (by - centroid[1]) / dy;
if (t > 0) {
const x = centroid[0] + t * dx;
if (x >= minx && x <= maxx) candidates.push({ t, x, y: by });
}
}
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => a.t - b.t);
const best = candidates[0];
return [best.x, best.y];
}
/**
* Expand N boundary vertices radially from `centroid.bakw` so that every point
* in `allPoints` has its bakw position enclosed inside the polygon formed by
* `vertices`.
*
* For each point, tests all N bakw polygon sides to find where the ray
* [centroid.bakw → point.bakw] exits. If the point is outside that side, both
* endpoints of the side are scaled outward until the point is enclosed.
*/
function checkAndAdjustVerticesN(
vertices: VertexPosition[],
allPoints: Array<{ forw: Position; bakw: Position }>,
centroid: { forw: Position; bakw: Position },
): void {
const N = vertices.length;
const expandRatio = new Array<number>(N).fill(1);
for (const node of allPoints) {
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const side = lineString([vertices[i].bakw, vertices[j].bakw]);
const line = lineString([centroid.bakw, node.bakw]);
const intersect = lineIntersect(side, line);
if (intersect.features.length > 0 && intersect.features[0].geometry) {
const intersectPt = intersect.features[0];
const distance = Math.sqrt(
Math.pow(node.bakw[0] - centroid.bakw[0], 2) +
Math.pow(node.bakw[1] - centroid.bakw[1], 2),
);
const intDistance = Math.sqrt(
Math.pow(intersectPt.geometry.coordinates[0] - centroid.bakw[0], 2) +
Math.pow(intersectPt.geometry.coordinates[1] - centroid.bakw[1], 2),
);
const ratio = distance / intDistance;
if (ratio > expandRatio[i]) expandRatio[i] = ratio;
if (ratio > expandRatio[j]) expandRatio[j] = ratio;
}
}
}
vertices.forEach((vertex, i) => {
const ratio = expandRatio[i];
vertex.bakw = [
(vertex.bakw[0] - centroid.bakw[0]) * ratio + centroid.bakw[0],
(vertex.bakw[1] - centroid.bakw[1]) * ratio + centroid.bakw[1],
];
});
}
// ─── Unified core ────────────────────────────────────────────────────────────
/**
* Unified boundary vertex computation shared by V2 (4 corners) and V3 (4 corners
* + up to 32 edge vertices).
*
* ## Algorithm
*
* ### Phase 1 – Ratios
* Compute per-quadrant (birdeye) or aggregate (plain) [minScale, avgRotation]
* ratios from the convex hull of all GCPs.
*
* ### Phase 2 – 4 bbox corners
* Each bbox corner's bakw position is extrapolated using the ratio for its
* quadrant. `checkAndAdjustVerticesN` then expands corners radially so that
* **every point in `allGcps`** (GCPs + edge intermediate nodes) is enclosed.
*
* If `withEdgeVertices` is false the function returns here (V2 path).
*
* ### Phase 3 – 36 × 10° bins (V3 only)
* For each non-corner 10° bin the most-extreme interior point (GCP or edge
* node) in that angular range is used as a guide:
*
* - **bakw direction**: linearly interpolated rotation between the two
* bracketing corner bakw rotations → smooth, no discontinuity.
* - **bakw distance**: the guide point's actual bakw distance from centroid,
* scaled up by `forwEdge_dist / forwGuide_dist` (extrapolation to the bbox
* boundary). This gives non-collinear positions independent of the
* corner-quad sides and avoids degenerate (zero-area) triangles.
*
* Bins with no interior points fall back to the interpolated corner scale.
*
* ### Phase 4 – Sort + final adjust
* All vertices are sorted by forw angle. `checkAndAdjustVerticesN` is called
* again on the full set so that the final polygon correctly encloses every
* interior point.
*/
function calculateVerticesCore(
params: BoundaryVerticesParams,
mode: "plain" | "birdeye",
withEdgeVertices: boolean,
): VertexPosition[] {
const { convexBuf, centroid, allGcps, minx, maxx, miny, maxy } = params;
// ── Phase 1 ───────────────────────────────────────────────────────────────
const cornerRatios = buildVertexRatio(convexBuf, centroid, mode);
// ── Phase 2: 4 bbox corner vertices ───────────────────────────────────────
const bboxCornerForws: Position[] = [
[minx, miny],
[maxx, miny],
[maxx, maxy],
[minx, maxy],
];
const cornerVertices: VertexPosition[] = bboxCornerForws.map((forw) => ({
forw,
bakw: computeVertexBakwFromRatio(
forw,
centroid,
cornerRatios[forwQuadrant(forw, centroid.forw)],
),
}));
// Sort corners CCW by forw angle (atan2(Δx, Δy) convention)
cornerVertices.sort((a, b) =>
Math.atan2(a.forw[0] - centroid.forw[0], a.forw[1] - centroid.forw[1]) -
Math.atan2(b.forw[0] - centroid.forw[0], b.forw[1] - centroid.forw[1])
);
// Expand corners to enclose all GCPs + edge nodes in bakw space
checkAndAdjustVerticesN(cornerVertices, allGcps, centroid);
if (!withEdgeVertices) return cornerVertices;
// ── Phase 3 prep: interpolation data from adjusted corners ────────────────
//
// For each edge vertex we interpolate the forw→bakw rotation from the two
// bracketing corner bakw rotations. At a corner's exact forw direction the
// interpolated rotation equals that corner's actual bakw rotation, so the
// boundary is continuous everywhere.
//
// The bakw distance is determined by projecting the interpolated direction
// onto the **corner polygon** sides. This places every Phase 3 edge vertex
// exactly on the adjusted corner-polygon boundary, so the 36-vertex bakw
// polygon is a refinement of (not larger than) the 4-corner polygon.
const N = 4; // always 4 corners
const cornerForwAngles = cornerVertices.map((cv) =>
Math.atan2(cv.forw[0] - centroid.forw[0], cv.forw[1] - centroid.forw[1])
);
const cornerBakwThetas = cornerVertices.map((cv) =>
Math.atan2(
cv.bakw[0] - centroid.bakw[0],
-(cv.bakw[1] - centroid.bakw[1]),
)
);
/** Find the angular sector [corners[i], corners[j]) containing theta. */
function findSector(theta: number): { i: number; j: number; frac: number } {
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const ai = cornerForwAngles[i];
const aj = i < N - 1 ? cornerForwAngles[j] : cornerForwAngles[j] + 2 * Math.PI;
let t = theta;
while (t < ai) t += 2 * Math.PI;
while (t >= ai + 2 * Math.PI) t -= 2 * Math.PI;
if (t >= ai && t < aj) {
return { i, j, frac: (t - ai) / (aj - ai) };
}
}
return { i: 0, j: 1, frac: 0 };
}
/**
* Interpolate the bakw angle directly between adjacent corner bakw angles.
* This avoids the large rotation-delta problem that occurs when the forw→bakw
* rotation difference between adjacent corners exceeds 180°.
*/
function interpolateBakwAngle(theta: number): number {
const { i, j, frac } = findSector(theta);
const bi = cornerBakwThetas[i];
const bj = cornerBakwThetas[j];
let delta = bj - bi;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
return bi + frac * delta;
}
// ── Phase 3: 36-bin edge vertices ─────────────────────────────────────────
const cornerBins = new Set<number>(
cornerVertices.map(
(cv) => Math.floor(toAngleDeg360(cv.forw, centroid.forw) / 10) % 36,
),
);
type GcpInfo = {
forw: Position;
bakw: Position;
angleDeg: number;
forwDist: number;
};
const gcpInfos: GcpInfo[] = allGcps.map((gcp) => ({
forw: gcp.forw,
bakw: gcp.bakw,
angleDeg: toAngleDeg360(gcp.forw, centroid.forw),
forwDist: Math.hypot(gcp.forw[0] - centroid.forw[0], gcp.forw[1] - centroid.forw[1]),
}));
const edgeVertices: VertexPosition[] = [];
for (let bin = 0; bin < 36; bin++) {
if (cornerBins.has(bin)) continue;
const binStart = bin * 10;
const inBin = gcpInfos.filter(
(g) => g.angleDeg >= binStart && g.angleDeg < binStart + 10,
);
let forwEdge: Position | null = null;
if (inBin.length > 0) {
// Most extreme interior point in this angular bin
const sel = inBin.reduce((best, g) => g.forwDist > best.forwDist ? g : best);
forwEdge = findRayBboxIntersection(centroid.forw, sel.forw, minx, maxx, miny, maxy);
}
if (!forwEdge) {
const binCenterRad = ((binStart + 5) % 360) * (Math.PI / 180);
const fallbackDir: Position = [
centroid.forw[0] + Math.sin(binCenterRad),
centroid.forw[1] + Math.cos(binCenterRad),
];
forwEdge = findRayBboxIntersection(centroid.forw, fallbackDir, minx, maxx, miny, maxy);
}
if (!forwEdge) continue;
const forDelta = [forwEdge[0] - centroid.forw[0], forwEdge[1] - centroid.forw[1]];
const forwAngle = Math.atan2(forDelta[0], forDelta[1]);
const bakwTheta = interpolateBakwAngle(forwAngle);
const bakwDirPt: Position = [
centroid.bakw[0] + Math.sin(bakwTheta),
centroid.bakw[1] - Math.cos(bakwTheta),
];
const bakwEdge = findRayQuadIntersection(centroid.bakw, bakwDirPt, cornerVertices);
if (bakwEdge) {
edgeVertices.push({ forw: forwEdge, bakw: bakwEdge });
}
}
// ── Phase 4: Combine, sort, final adjust ──────────────────────────────────
const allVertices: VertexPosition[] = [...cornerVertices, ...edgeVertices];
allVertices.sort((a, b) =>
Math.atan2(a.forw[0] - centroid.forw[0], a.forw[1] - centroid.forw[1]) -
Math.atan2(b.forw[0] - centroid.forw[0], b.forw[1] - centroid.forw[1])
);
// Final expansion: ensure the full polygon encloses all GCPs + edge nodes
// (some edge vertices may have been placed conservatively via extrapolation;
// this step corrects any remaining gaps).
checkAndAdjustVerticesN(allVertices, allGcps, centroid);
return allVertices;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Calculate boundary vertices in plain mode.
*
* @param params - Input parameters including GCPs, centroid, and bounding box.
* @param v3 - When true (V3 format), runs the full 36-bin edge vertex pass
* (Phase 3) in addition to the 4 bbox corners, producing up to 36 vertices.
* When false (V2 format), returns only the 4 bbox corners.
*
* Plain mode uses a single aggregate [scale, rotation] ratio from all GCPs.
*/
export function calculatePlainVertices(
params: BoundaryVerticesParams,
v3 = false,
): VertexPosition[] {
return calculateVerticesCore(params, "plain", v3);
}
/**
* Calculate boundary vertices in bird's-eye mode.
*
* @param params - Input parameters including GCPs, centroid, and bounding box.
* @param v3 - When true (V3 format), runs the full 36-bin edge vertex pass
* (Phase 3) in addition to the 4 bbox corners, producing up to 36 vertices.
* When false (V2 format), returns only the 4 bbox corners.
*
* Birdeye mode uses per-quadrant [scale, rotation] ratios to capture
* perspective distortion in the 4 corner positions.
*/
export function calculateBirdeyeVertices(
params: BoundaryVerticesParams,
v3 = false,
): VertexPosition[] {
return calculateVerticesCore(params, "birdeye", v3);
}