gods-eye-view/tools/cesium-render.mjs

378 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* cesium-render.mjs
*
* Renders a CesiumJS 3D view to a JPEG image using headless Chromium
* (Puppeteer) with Google 3D Photorealistic tiles.
*
* Two modes:
*
* Direct position (camera placed at lat/lon):
* node tools/cesium-render.mjs --lat 30.266476 --lon -97.73719 --heading 270 --pitch -15 --height 8
*
* Look-at mode (camera computed to look at a target point):
* node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --heading 270 --pitch -30 --height 20
*
* In look-at mode the camera is positioned behind the heading direction at
* the distance needed so the target point is centered in view. The --height
* is meters above ground at the *target* point, and --pitch must be negative
* (looking down).
*
* Options:
* --lat Camera latitude (direct mode)
* --lon Camera longitude (direct mode)
* --lookat-lat Target latitude to look at (lookat mode)
* --lookat-lon Target longitude to look at (lookat mode)
* --heading Compass heading in degrees, 0=N 90=E 180=S 270=W (default: 0)
* --pitch Camera pitch in degrees, 0=horizon -90=down (default: -10)
* --height Height above ground in meters (default: 8)
* --fov Vertical field of view in degrees (default: 60)
* --width Image width in pixels (default: 1280)
* --height-px Image height in pixels (default: 720)
* --sse Max screen-space error, lower = sharper (default: 2)
* --timeout Max total wait in seconds (default: 30)
* --outdir Output directory (default: output/)
* --key Google Maps API key (default: reads from .env)
*
* Strategy:
* 1. Overhead pass (500m up, looking down) to sample ground elevation.
* 2. Position camera at target level (ground + --height meters).
* 3. Progressive SSE refinement: step from SSE=16 down to target SSE,
* waiting for each LOD level to fully load before requesting more detail.
* 4. 3-second settle pass for final texture uploads.
* 5. Screenshot.
*
* Dependencies: puppeteer, cesium (for serving assets)
*/
import { readFileSync, mkdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:http';
import puppeteer from 'puppeteer';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
heading: 0, pitch: -10, height: 8, fov: 60,
width: 1280, heightPx: 720, sse: 2, timeout: 30, outdir: 'output',
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--lat': opts.lat = parseFloat(args[++i]); break;
case '--lon': opts.lon = parseFloat(args[++i]); break;
case '--lookat-lat': opts.lookatLat = parseFloat(args[++i]); break;
case '--lookat-lon': opts.lookatLon = parseFloat(args[++i]); break;
case '--heading': opts.heading = parseFloat(args[++i]); break;
case '--pitch': opts.pitch = parseFloat(args[++i]); break;
case '--height': opts.height = parseFloat(args[++i]); break;
case '--fov': opts.fov = parseFloat(args[++i]); break;
case '--width': opts.width = parseInt(args[++i], 10); break;
case '--height-px': opts.heightPx = parseInt(args[++i], 10); break;
case '--sse': opts.sse = parseFloat(args[++i]); break;
case '--timeout': opts.timeout = parseInt(args[++i], 10); break;
case '--outdir': opts.outdir = args[++i]; break;
case '--key': opts.key = args[++i]; break;
case '--help':
console.log([
'Usage:',
' Direct: node tools/cesium-render.mjs --lat <lat> --lon <lon> [options]',
' LookAt: node tools/cesium-render.mjs --lookat-lat <lat> --lookat-lon <lon> [options]',
'',
'Position (pick one pair):',
' --lat Camera latitude (direct mode)',
' --lon Camera longitude (direct mode)',
' --lookat-lat Target latitude to look at (computes camera position)',
' --lookat-lon Target longitude to look at (computes camera position)',
'',
'Camera:',
' --heading Compass heading (default: 0)',
' --pitch Camera pitch (default: -10, must be <0 for lookat mode)',
' --height Meters above ground (default: 8)',
' --fov Vertical FOV degrees (default: 60)',
'',
'Output:',
' --width Image width (default: 1280)',
' --height-px Image height (default: 720)',
' --sse Screen space error, lower=sharper (default: 2)',
' --timeout Timeout seconds (default: 30)',
' --outdir Output dir (default: output/)',
' --key Google Maps API key',
].join('\n'));
process.exit(0);
}
}
// Validate: need either --lat/--lon or --lookat-lat/--lookat-lon, not both
const hasDirect = opts.lat !== undefined && opts.lon !== undefined;
const hasLookat = opts.lookatLat !== undefined && opts.lookatLon !== undefined;
const hasPartialDirect = (opts.lat !== undefined) !== (opts.lon !== undefined);
const hasPartialLookat = (opts.lookatLat !== undefined) !== (opts.lookatLon !== undefined);
if (hasPartialDirect) {
console.error('Error: --lat and --lon must both be provided.');
process.exit(1);
}
if (hasPartialLookat) {
console.error('Error: --lookat-lat and --lookat-lon must both be provided.');
process.exit(1);
}
if (hasDirect && hasLookat) {
console.error('Error: Use --lat/--lon OR --lookat-lat/--lookat-lon, not both.');
process.exit(1);
}
if (!hasDirect && !hasLookat) {
console.error('Error: Provide --lat/--lon (direct) or --lookat-lat/--lookat-lon (look-at).');
process.exit(1);
}
if (hasLookat) {
opts.mode = 'lookat';
if (opts.pitch >= 0) {
console.error('Error: --pitch must be negative in lookat mode (camera looks down at target).');
process.exit(1);
}
} else {
opts.mode = 'direct';
}
return opts;
}
// ---------------------------------------------------------------------------
// Load API key
// ---------------------------------------------------------------------------
function loadApiKey(overrideKey) {
if (overrideKey) return overrideKey;
if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY;
try {
const envPath = join(PROJECT_ROOT, '.env');
const envContent = readFileSync(envPath, 'utf8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
const eqIdx = trimmed.indexOf('=');
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (key === 'GOOGLE_MAPS_API_KEY' && val) return val;
}
} catch { /* ignore */ }
console.error('Error: No API key found. Set GOOGLE_MAPS_API_KEY in .env or pass --key.');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Temporary HTTP server to serve Cesium assets + render page
// ---------------------------------------------------------------------------
function startServer() {
const CESIUM_ROOT = join(PROJECT_ROOT, 'node_modules/cesium/Build/Cesium');
const RENDER_HTML = join(__dirname, 'cesium-render.html');
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.glb': 'model/gltf-binary',
};
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
let filePath;
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
} else if (url.pathname === '/' || url.pathname === '/index.html') {
filePath = RENDER_HTML;
} else if (url.pathname.startsWith('/cesium/')) {
filePath = join(CESIUM_ROOT, url.pathname.slice('/cesium/'.length));
} else {
res.writeHead(404);
res.end('Not found');
return;
}
try {
const data = readFileSync(filePath);
const ext = filePath.slice(filePath.lastIndexOf('.'));
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const apiKey = loadApiKey(opts.key);
console.log(`\nCesium 3D Renderer`);
if (opts.mode === 'lookat') {
console.log(` Mode : lookat`);
console.log(` Target : ${opts.lookatLat}, ${opts.lookatLon}`);
} else {
console.log(` Mode : direct`);
console.log(` Camera : ${opts.lat}, ${opts.lon}`);
}
console.log(` Heading : ${opts.heading}° Pitch: ${opts.pitch}° Height: ${opts.height}m`);
console.log(` FOV : ${opts.fov}° Size: ${opts.width}x${opts.heightPx} SSE: ${opts.sse}`);
console.log(` Timeout : ${opts.timeout}s\n`);
// Step 1: Start temp HTTP server
console.log('Starting asset server...');
const { server, port } = await startServer();
console.log(` http://127.0.0.1:${port}\n`);
let browser;
try {
// Step 2: Launch headless Chromium with ANGLE/SwiftShader
console.log('Launching browser...');
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const page = await browser.newPage();
// Load tiles directly at target resolution.
// The HTML page does progressive SSE refinement (16→12→8→6→4→target)
// to avoid overwhelming SwiftShader with tile requests all at once.
const LOAD_W = opts.width, LOAD_H = opts.heightPx;
await page.setViewport({ width: LOAD_W, height: LOAD_H });
// Log browser console
page.on('console', msg => {
const type = msg.type();
if (type === 'error' || type === 'warning' || type === 'log') {
console.log(` [${type}] ${msg.text()}`);
}
});
// Step 3: Navigate to render page with params
const urlParams = new URLSearchParams({
heading: opts.heading,
pitch: opts.pitch,
height: opts.height,
fov: opts.fov,
sse: opts.sse,
key: apiKey,
mode: opts.mode,
});
if (opts.mode === 'lookat') {
// Lookat mode: HTML page computes camera position from target + heading/pitch/height
urlParams.set('targetLat', opts.lookatLat);
urlParams.set('targetLon', opts.lookatLon);
} else {
// Direct mode: camera placed at lat/lon
urlParams.set('lat', opts.lat);
urlParams.set('lon', opts.lon);
}
const url = `http://127.0.0.1:${port}/?${urlParams}`;
console.log(`Loading Cesium scene (${LOAD_W}x${LOAD_H})...`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Step 4: Wait for tiles to load
console.log('Waiting for tiles...');
const timeoutMs = opts.timeout * 1000;
try {
await page.waitForFunction(
'window.__tilesReady === true || window.__error !== null',
{ timeout: timeoutMs, polling: 500 }
);
} catch {
console.log(` Timed out after ${opts.timeout}s — capturing what we have.`);
}
// Check for errors
const error = await page.evaluate('window.__error');
if (error) throw new Error(`Cesium error: ${error}`);
const ready = await page.evaluate('window.__tilesReady');
if (ready) console.log(' Tiles loaded.');
// Step 5: Screenshot
const outdir = resolve(PROJECT_ROOT, opts.outdir);
mkdirSync(outdir, { recursive: true });
const fileLat = opts.mode === 'lookat' ? opts.lookatLat : opts.lat;
const fileLon = opts.mode === 'lookat' ? opts.lookatLon : opts.lon;
const prefix = opts.mode === 'lookat' ? 'cesium_lookat' : 'cesium';
const outPath = join(outdir,
`${prefix}_${fileLat}_${fileLon}_h${opts.heading}_p${opts.pitch}_${opts.height}m_${opts.width}x${opts.heightPx}.jpg`);
await page.screenshot({
path: outPath,
type: 'jpeg',
quality: 92,
fullPage: false,
});
console.log(`\nSaved: ${outPath}`);
// Report ground height and camera info
const gh = await page.evaluate('window.__groundHeight');
if (gh !== null) {
console.log(` Ground height: ${gh.toFixed(1)}m (WGS84 ellipsoid)`);
}
const cam = await page.evaluate('window.__cameraInfo');
if (cam) {
console.log(` Camera pos : ${cam.lat.toFixed(6)}, ${cam.lon.toFixed(6)}`);
console.log(` Camera alt : ${cam.height.toFixed(1)}m (WGS84)`);
console.log(` True heading : ${cam.heading.toFixed(1)}°`);
console.log(` True pitch : ${cam.pitch.toFixed(1)}°`);
console.log(` Distance : ${cam.dist.toFixed(1)}m to target`);
}
} finally {
if (browser) await browser.close();
server.close();
}
}
main().catch((err) => {
console.error('\nError:', err.message);
process.exit(1);
});