← Files RemotionARCHIVED FILE

skills/remotion-markup/remotion-maps/techniques/cesium/scripts/prep-cesium-path.mjs

5.43 KB · Oct 5, 2026 · 18:37 UTC

↓ Download file

// Camera-path generator for the Cesium flythrough. Turns a river/route centerline GeoJSON into a LONG,
// CONTINUOUSLY-curving camera path so the camera banks through smooth flowing curves (no
// straight-then-corner). Method: clip → resample to even spacing → moving-average smooth (inherently
// continuous curvature) → dampen lateral deviation toward the straight chord (dials swerve amplitude).
// No Douglas-Peucker (that concentrates curvature at sparse control points → corners).
//
// RUN (out of the box, against the shipped sample):
//   node prep-cesium-path.mjs
//     → reads  assets/sample-river.geojson   (override: node prep-cesium-path.mjs <input.geojson> <output.json>)
//     → writes assets/cesium-path.json        (then import that JSON in CesiumFlythrough.tsx, or copy it
//                                               into your Remotion project's src/geo/ and adjust the import)
//
// ADAPT for a new location: change START (a point ON your centerline where the corridor opens),
// WINDOW_KM, and DAMP/SMOOTH below. Input must be a single LineString feature (features[0].geometry).

import {readFileSync, writeFileSync, mkdirSync} from 'fs';
import {dirname, resolve} from 'path';
import {fileURLToPath} from 'url';

const __dir = dirname(fileURLToPath(import.meta.url));
const IN = process.argv[2] || resolve(__dir, '../assets/sample-river.geojson');
const OUT = process.argv[3] || resolve(__dir, '../assets/cesium-path.json');
const havKm = (a, b) => {
	const R = 6371,
		r = Math.PI / 180,
		dLat = (b[1] - a[1]) * r,
		dLng = (b[0] - a[0]) * r;
	const h =
		Math.sin(dLat / 2) ** 2 +
		Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
	return 2 * R * Math.asin(Math.sqrt(h));
};

const gorge = JSON.parse(readFileSync(IN, 'utf8')).features[0].geometry
	.coordinates;

// ADAPT: clip ~24 km of river from the reach where the flythrough opens. START must be a point ON the
// centerline (the script snaps to the nearest vertex). The sample's opening is the Yarlung gorge:
const START = [94.968, 29.757];
let s0 = 0,
	best = Infinity;
gorge.forEach((p, i) => {
	const d = havKm(p, START);
	if (d < best) {
		best = d;
		s0 = i;
	}
});
const WINDOW_KM = 30; // clip to ~end of gorge data; smoothing+dampening shrink it to the usable corridor
const clip = [];
for (let i = s0, acc = 0; i < gorge.length; i++) {
	if (i > s0) acc += havKm(gorge[i - 1], gorge[i]);
	if (acc > WINDOW_KM) break;
	clip.push(gorge[i]);
}

// Resample to even arc-length spacing so curvature is distributed evenly along the path.
const STEP_KM = 0.1;
const resample = (coords) => {
	const out = [coords[0].slice()];
	let carry = 0,
		from = coords[0];
	for (let i = 1; i < coords.length; i++) {
		let segLen = havKm(from, coords[i]);
		while (carry + segLen >= STEP_KM) {
			const t = (STEP_KM - carry) / segLen;
			const np = [
				from[0] + (coords[i][0] - from[0]) * t,
				from[1] + (coords[i][1] - from[1]) * t,
			];
			out.push(np);
			from = np;
			segLen = havKm(from, coords[i]);
			carry = 0;
		}
		carry += segLen;
		from = coords[i];
	}
	return out;
};

// Moving-average smoothing — inherently continuous (no kinks). Window in points; repeat for extra glass.
const smoothMA = (coords, w, passes) => {
	let c = coords;
	for (let p = 0; p < passes; p++) {
		c = c.map((_, i) => {
			let sx = 0,
				sy = 0,
				n = 0;
			for (
				let j = Math.max(0, i - w);
				j <= Math.min(c.length - 1, i + w);
				j++
			) {
				sx += c[j][0];
				sy += c[j][1];
				n++;
			}
			return [sx / n, sy / n];
		});
	}
	return c;
};

const SMOOTH_W = 28; // ±2.8 km window — turns meanders into smooth flowing curves
const SMOOTH_PASSES = 2;
const DAMP = 0.45; // keep 45% of the (already-smooth) deviation → gentle, continuous swerve

const even = resample(clip);
const sm = smoothMA(even, SMOOTH_W, SMOOTH_PASSES);

const lat0 = (sm[0][1] * Math.PI) / 180;
const kx = 111.32 * Math.cos(lat0),
	ky = 110.57;
const toXY = (p) => [(p[0] - sm[0][0]) * kx, (p[1] - sm[0][1]) * ky];
const toLL = (xy) => [sm[0][0] + xy[0] / kx, sm[0][1] + xy[1] / ky];
const A = toXY(sm[0]),
	B = toXY(sm[sm.length - 1]);
const AB = [B[0] - A[0], B[1] - A[1]],
	len2 = AB[0] ** 2 + AB[1] ** 2;
const path = sm.map((p) => {
	const P = toXY(p);
	const t = ((P[0] - A[0]) * AB[0] + (P[1] - A[1]) * AB[1]) / len2;
	const proj = [A[0] + t * AB[0], A[1] + t * AB[1]];
	return toLL([
		proj[0] + (P[0] - proj[0]) * DAMP,
		proj[1] + (P[1] - proj[1]) * DAMP,
	]);
});

mkdirSync(dirname(OUT), {recursive: true});
writeFileSync(OUT, JSON.stringify(path));

let len = 0;
for (let i = 1; i < path.length; i++) len += havKm(path[i - 1], path[i]);
console.log(
	`cesium-path: clip ${clip.length} → resample ${even.length} → smooth → ${path.length} pts · ${len.toFixed(1)} km`,
);
const bear = (a, b) => {
	const r = Math.PI / 180;
	const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
	const x =
		Math.cos(a[1] * r) * Math.sin(b[1] * r) -
		Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
	return (Math.atan2(y, x) * 180) / Math.PI;
};
// heading sampled every ~1.5 km — should change gradually & continuously (no big jumps = no corners)
const stepPts = Math.round(1.5 / STEP_KM);
let prev = null,
	hs = [];
for (let i = 0; i + stepPts < path.length; i += stepPts) {
	const h = bear(path[i], path[i + stepPts]);
	if (prev !== null) {
		let d = h - prev;
		while (d > 180) d -= 360;
		while (d < -180) d += 360;
		hs.push(d.toFixed(0));
	}
	prev = h;
}
console.log(`  heading deltas every 1.5km (deg): ${hs.join(', ')}`);

SHA-256: 6dbb4d1ae3f5fef9f3f6eb23e2a6d58622750714bb847b088ff58a3533839463