feat(globe): implement the engine on Navara

src/globe/navara builds the GlobeEngine on a Navara 0.2.1 ThreeView. The
engine keeps the camera pose in src/geo values and pushes it to Navara's
setCamera in degrees (Cesium's heading/pitch/roll convention, S4);
whatever Navara moves itself (user input, its flyTo arc) is read back
from the drawn camera each frame. Projection, picks against the
ellipsoid and the view rectangle are geo math on that pose, with
Navara's fov kept on Cesium's 60° across the wider side.

- flights: Navara flyTo in ms with mapped easings, cancelFlight via an
  empty setCamera, user input cancels a flight as in Cesium
- events: moveStart/moveEnd (500 ms still)/changed from the pose
- render: forceUpdate and animation (render on demand, S5)
- surface: S2 CPU ray over 3D-tile meshes, Navara terrain fallback
- maps: OSM/Esri raster, Re:Earth or ion quantized-mesh terrain,
  Google 3D tiles with a browser key and Google's logo
- input: DOM pointer events with ScreenSpaceEventHandler semantics
- the three private Navara members used live in internals.js, pinned
  to 0.2.1 by a test

index.js builds the product view (MSAA 4, native pixel ratio, preserved
drawing buffer, effects=minimal) for a dynamic import. The contract
QA script gains --engine=navara on a dev-only harness page: 46/46
contract and 4/4 Navara checks; Cesium stays 53/53.
This commit is contained in:
German Khokhlov committed 2026-10-15 19:03:58 +04:00
1 parent b6ac0e9c99
commit 90c8e7763a
19 files changed
+3311

No files matched your search

+444
View File
@@ -0,0 +1,444 @@
// Camera math for the Navara engine, on src/geo only: Cesium's heading/pitch/
// roll convention, the perspective projection and picks of a camera given as
// { position, direction, up, fovy, width, height } (Earth-fixed metres, unit
// vectors, vertical fov in radians, CSS pixels), Cesium's view rectangle and
// horizon test, and the Navara setCamera pose (degrees). Ported from the S4
// spike (src/bench/cameraConvention.js) and the contract's fake engine; the
// projection matches what Navara draws because the engine keeps Navara's
// camera on the same pose and fov.
import * as geo from '../../geo/index.js';
const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo;
const WGS84 = geo.ellipsoid.WGS84;
const RADII = WGS84.radii;
const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z);
// The near/far planes of viewProjection (only its z row depends on them).
const NEAR = 1;
const FAR = 1e9;
// Cesium's MINIMUM_ZOOM (range for a radius-0 sphere) and the camera
// controller's default zoom distances.
const ZERO_RADIUS_SPHERE_RANGE = 100;
const MINIMUM_ZOOM_DISTANCE = 1;
const MAXIMUM_ZOOM_DISTANCE = Infinity;
// Cesium's default PerspectiveFrustum fov, across the wider canvas side.
const CESIUM_FOV = Math.PI / 3;
const copy3 = (value) => cartesian3.create(value.x, value.y, value.z);
const scaled = (vector, scalar) =>
cartesian3.multiplyByScalar(vector, scalar, cartesian3.create());
const sum = (...vectors) =>
vectors.reduce(
(total, vector) => cartesian3.add(total, vector, total),
cartesian3.create(),
);
const unit = (vector) => cartesian3.normalize(vector, cartesian3.create());
const cross = (left, right) =>
cartesian3.cross(left, right, cartesian3.create());
/** An angle in [0, 2π), as Cesium's camera reports heading and roll. */
export function wrapAngle(angle) {
const value = math.zeroToTwoPi(angle);
return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value;
}
function localAxes(position) {
const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
return {
east: cartesian3.create(frame[0], frame[1], frame[2]),
north: cartesian3.create(frame[4], frame[5], frame[6]),
up: cartesian3.create(frame[8], frame[9], frame[10]),
};
}
const combine = (axes, e, n, u) =>
sum(scaled(axes.east, e), scaled(axes.north, n), scaled(axes.up, u));
const toLocal = (axes, vector) => ({
e: cartesian3.dot(vector, axes.east),
n: cartesian3.dot(vector, axes.north),
u: cartesian3.dot(vector, axes.up),
});
/**
* Direction and up for heading/pitch/roll (radians) in the local frame at
* `position`, as Cesium's setView builds them: heading 0 looks north and turns
* clockwise, positive pitch looks up, positive roll tilts up to the right.
*/
export function orientationVectors(position, heading, pitch, roll) {
const axes = localAxes(position);
const cosPitch = Math.cos(pitch);
const direction = combine(
axes,
Math.sin(heading) * cosPitch,
Math.cos(heading) * cosPitch,
Math.sin(pitch),
);
const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0);
const levelUp = cross(levelRight, direction);
const up = sum(
scaled(levelUp, Math.cos(roll)),
scaled(levelRight, Math.sin(roll)),
);
return { direction: unit(direction), up: unit(up) };
}
/**
* Cesium's camera heading/pitch/roll getters: heading and roll in [0, 2π);
* within 1e-3 of straight up or down roll is 0 and heading follows `up`.
*/
export function headingPitchRoll(position, direction, up) {
const axes = localAxes(position);
const d = toLocal(axes, direction);
const u = toLocal(axes, up);
const r = toLocal(axes, cross(direction, up));
const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3;
const raw = vertical
? Math.atan2(u.n, u.e) - math.PI_OVER_TWO
: Math.atan2(d.n, d.e) - math.PI_OVER_TWO;
return {
heading: wrapAngle(math.TWO_PI - math.zeroToTwoPi(raw)),
pitch: math.PI_OVER_TWO - Math.acos(math.clamp(d.u, -1, 1)),
roll: vertical ? 0 : wrapAngle(Math.atan2(-r.u, u.u)),
};
}
/**
* The Navara setCamera/flyTo pose for a camera pose: the eye's longitude,
* latitude and ellipsoid height, and the same heading/pitch/roll in degrees
* (Navara 0.2.1 uses Cesium's convention, S4), heading in [0, 360) and roll
* in (−180, 180].
*/
export function navaraPose(position, direction, up) {
const place = cartographic.fromCartesian(position, WGS84);
const angles = headingPitchRoll(position, direction, up);
let roll = math.toDegrees(angles.roll);
if (roll > 180) roll -= 360;
return {
lng: math.toDegrees(place.longitude),
lat: math.toDegrees(place.latitude),
height: place.height,
heading: math.toDegrees(angles.heading),
pitch: math.toDegrees(angles.pitch),
roll,
};
}
/** Cesium's vertical fov for a canvas: 60° across the wider side. */
export function cesiumFovy(width, height) {
return width > height
? 2 * Math.atan(Math.tan(CESIUM_FOV / 2) * (height / width))
: CESIUM_FOV;
}
const aspectOf = (camera) => camera.width / camera.height;
const rightOf = (camera) => unit(cross(camera.direction, camera.up));
/** Window position (CSS px) of a world point; undefined behind the camera. */
export function projectToWindow(camera, position, result) {
const offset = cartesian3.subtract(
position,
camera.position,
cartesian3.create(),
);
const depth = cartesian3.dot(offset, camera.direction);
if (!(depth > 0)) return undefined;
const tangent = Math.tan(camera.fovy / 2);
const ndcX =
cartesian3.dot(offset, rightOf(camera)) /
(depth * tangent * aspectOf(camera));
const ndcY = cartesian3.dot(offset, camera.up) / (depth * tangent);
const x = ((ndcX + 1) / 2) * camera.width;
const y = ((1 - ndcY) / 2) * camera.height;
if (!result) return cartesian2.create(x, y);
result.x = x;
result.y = y;
return result;
}
/** World → clip matrix consistent with projectToWindow (column-major). */
export function viewProjection(camera, result = matrix4.create()) {
const f = 1 / Math.tan(camera.fovy / 2);
const rows = [
[rightOf(camera), f / aspectOf(camera)],
[camera.up, f],
];
const D = camera.direction;
const P = camera.position;
for (const [row, [axis, scale]] of rows.entries()) {
result[row] = axis.x * scale;
result[4 + row] = axis.y * scale;
result[8 + row] = axis.z * scale;
result[12 + row] = -cartesian3.dot(axis, P) * scale;
}
// Eye z is −direction·(p − position); clip.w = −z_eye.
const A = (FAR + NEAR) / (NEAR - FAR);
const B = (2 * FAR * NEAR) / (NEAR - FAR);
result[2] = -D.x * A;
result[6] = -D.y * A;
result[10] = -D.z * A;
result[14] = cartesian3.dot(D, P) * A + B;
result[3] = D.x;
result[7] = D.y;
result[11] = D.z;
result[15] = -cartesian3.dot(D, P);
return result;
}
/** The unit ray from the eye through a window position (CSS px). */
export function pickRay(camera, screen) {
const tangent = Math.tan(camera.fovy / 2);
const ndcX = (2 * screen.x) / camera.width - 1;
const ndcY = 1 - (2 * screen.y) / camera.height;
const direction = sum(
camera.direction,
scaled(rightOf(camera), ndcX * tangent * aspectOf(camera)),
scaled(camera.up, ndcY * tangent),
);
return { origin: copy3(camera.position), direction: unit(direction) };
}
/** Nearest intersection of a ray with the WGS84 ellipsoid, if any. */
export function intersectEllipsoid(origin, direction) {
const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z];
const d = [
direction.x / RADII.x,
direction.y / RADII.y,
direction.z / RADII.z,
];
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]);
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return undefined;
const root = Math.sqrt(discriminant);
const nearest = (-b - root) / (2 * a);
const t = nearest > 0 ? nearest : (-b + root) / (2 * a);
if (!(t > 0)) return undefined;
return sum(origin, scaled(direction, t));
}
/** Cesium's camera.pickEllipsoid for a window position. */
export function pickEllipsoid(camera, screen) {
return intersectEllipsoid(camera.position, pickRay(camera, screen).direction);
}
/** Cesium's EllipsoidalOccluder.isPointVisible from `eye`. */
export function horizonVisible(eye, position) {
const camera = cartesian3.create(
eye.x / RADII.x,
eye.y / RADII.y,
eye.z / RADII.z,
);
const limbSquared = cartesian3.magnitudeSquared(camera) - 1;
const target = cartesian3.create(
position.x / RADII.x,
position.y / RADII.y,
position.z / RADII.z,
);
const vt = cartesian3.subtract(target, camera, cartesian3.create());
const vtDotVc = -cartesian3.dot(vt, camera);
const occluded =
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / cartesian3.magnitudeSquared(vt) > limbSquared;
return !occluded;
}
// Cesium's computeHorizonQuad: four limb points around the eye's nadir, in the
// corner order upper-left, lower-left, lower-right, upper-right.
function horizonQuad(position) {
const q = cartesian3.create(
position.x / RADII.x,
position.y / RADII.y,
position.z / RADII.z,
);
const qMagnitude = cartesian3.magnitude(q);
const qUnit = unit(q);
let east;
let north;
if (cartesian3.equalsEpsilon(qUnit, cartesian3.UNIT_Z, math.EPSILON10)) {
east = cartesian3.create(0, 1, 0);
north = cartesian3.create(0, 0, 1);
} else {
east = unit(cross(cartesian3.UNIT_Z, qUnit));
north = unit(cross(qUnit, east));
}
const center = scaled(qUnit, 1 / qMagnitude);
const offset =
Math.sqrt(Math.max(0, cartesian3.magnitudeSquared(q) - 1)) / qMagnitude;
const point = (n, e) => {
const p = sum(center, scaled(north, n * offset), scaled(east, e * offset));
return cartesian3.create(p.x * RADII.x, p.y * RADII.y, p.z * RADII.z);
};
return [point(1, -1), point(-1, -1), point(-1, 1), point(1, 1)];
}
// Cesium's Rectangle.fromCartographicArray, including the antimeridian case.
function rectangleFromCartographics(points) {
let west = Infinity;
let east = -Infinity;
let westOverIDL = Infinity;
let eastOverIDL = -Infinity;
let south = Infinity;
let north = -Infinity;
for (const { longitude, latitude } of points) {
west = Math.min(west, longitude);
east = Math.max(east, longitude);
south = Math.min(south, latitude);
north = Math.max(north, latitude);
const adjusted = longitude >= 0 ? longitude : longitude + math.TWO_PI;
westOverIDL = Math.min(westOverIDL, adjusted);
eastOverIDL = Math.max(eastOverIDL, adjusted);
}
if (east - west > eastOverIDL - westOverIDL) {
west = westOverIDL > Math.PI ? westOverIDL - math.TWO_PI : westOverIDL;
east = eastOverIDL > Math.PI ? eastOverIDL - math.TWO_PI : eastOverIDL;
}
return { west, south, east, north };
}
// Whether the sphere around the Earth is wholly outside the view frustum.
function globeOutsideFrustum(camera) {
const toCentre = cartesian3.negate(camera.position, cartesian3.create());
const D = camera.direction;
const tanY = Math.tan(camera.fovy / 2);
const tanX = tanY * aspectOf(camera);
const R = rightOf(camera);
const U = camera.up;
const planes = [
[D, NEAR],
[unit(sum(scaled(R, -1), scaled(D, tanX))), 0],
[unit(sum(R, scaled(D, tanX))), 0],
[unit(sum(scaled(U, -1), scaled(D, tanY))), 0],
[unit(sum(U, scaled(D, tanY))), 0],
];
return planes.some(
([normal, offset]) =>
cartesian3.dot(normal, toCentre) - offset < -MAXIMUM_RADIUS,
);
}
/**
* Cesium's Camera.computeViewRectangle (radians): undefined when the globe is
* outside the frustum, the whole globe when fewer than two canvas corners
* hit it, west > east across the antimeridian, opened to a pole the corners
* walk around.
*/
export function viewRectangle(camera) {
if (globeOutsideFrustum(camera)) return undefined;
const horizon = horizonQuad(camera.position);
let hits = 0;
const points = [
[0, 0],
[0, camera.height],
[camera.width, camera.height],
[camera.width, 0],
].map(([x, y], index) => {
const hit = pickEllipsoid(camera, cartesian2.create(x, y));
if (hit) hits++;
return cartographic.fromCartesian(hit ?? horizon[index], WGS84);
});
if (hits < 2)
return {
west: -Math.PI,
south: -math.PI_OVER_TWO,
east: Math.PI,
north: math.PI_OVER_TWO,
};
const rectangle = rectangleFromCartographics(points);
let travelled = 0;
let last = points[3].longitude;
for (const { longitude } of points) {
const step = Math.abs(longitude - last);
travelled += step > Math.PI ? math.TWO_PI - step : step;
last = longitude;
}
if (math.equalsEpsilon(Math.abs(travelled), math.TWO_PI, 1e-9)) {
rectangle.west = -Math.PI;
rectangle.east = Math.PI;
if (points[0].latitude >= 0) rectangle.north = math.PI_OVER_TWO;
else rectangle.south = -math.PI_OVER_TWO;
}
return rectangle;
}
/**
* Where a {west, south, east, north} destination is seen from, looking
* straight down: above the rectangle's centre, high enough for its wider
* side to fill the view (with a margin for curvature). Cesium fits the
* corners exactly; this framing is within a few percent of it.
*/
export function rectangleViewPosition(
{ west, south, east, north },
fovy,
aspect,
) {
const lonSpan = east >= west ? east - west : east + math.TWO_PI - west;
const longitude = math.negativePiToPi(west + lonSpan / 2);
const latitude = (south + north) / 2;
const tanY = Math.tan(fovy / 2);
const radius = WGS84.maximumRadius;
const widest =
south * north <= 0
? 1
: Math.cos(Math.min(Math.abs(south), Math.abs(north)));
const across =
1.05 *
Math.max(
(radius * widest * lonSpan) / 2 / (tanY * aspect),
(radius * (north - south)) / 2 / tanY,
);
return cartesian3.fromRadians(longitude, latitude, across, WGS84);
}
// The camera offset from a target for a HeadingPitchRange, in the target frame.
function offsetFromHeadingPitchRange(heading, pitch, range) {
const p = math.clamp(pitch, -math.PI_OVER_TWO, math.PI_OVER_TWO);
const cosPitch = Math.cos(p);
return cartesian3.create(
-Math.sin(heading) * cosPitch * range,
-Math.cos(heading) * cosPitch * range,
-Math.sin(p) * range,
);
}
/**
* Cesium's lookAtTransform pose: the camera at `offset` in `frame`
* (a HeadingPitchRange or a Cartesian position), looking at the frame origin
* with the frame's +Z as up.
*/
export function lookAtFrame(frame, offset) {
const local =
'heading' in offset
? offsetFromHeadingPitchRange(offset.heading, offset.pitch, offset.range)
: copy3(offset);
const localDirection = unit(cartesian3.negate(local, cartesian3.create()));
let localRight = cross(localDirection, cartesian3.UNIT_Z);
if (cartesian3.magnitudeSquared(localRight) < math.EPSILON10)
localRight = copy3(cartesian3.UNIT_X);
localRight = unit(localRight);
const localUp = unit(cross(localRight, localDirection));
const vector = (value) =>
unit(matrix4.multiplyByPointAsVector(frame, value, cartesian3.create()));
return {
position: matrix4.multiplyByPoint(frame, local, cartesian3.create()),
direction: vector(localDirection),
up: vector(localUp),
};
}
/**
* Cesium's flyToBoundingSphere default range: 100 m for a radius-0 sphere,
* otherwise the distance that fits the sphere in view, clamped to the
* camera controller's zoom distances.
*/
export function sphereRange(radius, fovy, aspect) {
if (radius === 0) return ZERO_RADIUS_SPHERE_RANGE;
const tanPhi = Math.tan(fovy / 2);
const tanTheta = aspect * tanPhi;
return math.clamp(
Math.max(radius / tanTheta, radius / tanPhi),
MINIMUM_ZOOM_DISTANCE,
MAXIMUM_ZOOM_DISTANCE,
);
}
+308
View File
@@ -0,0 +1,308 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import {
cesiumFovy,
headingPitchRoll,
horizonVisible,
lookAtFrame,
navaraPose,
orientationVectors,
pickRay,
projectToWindow,
rectangleViewPosition,
sphereRange,
viewProjection,
viewRectangle,
} from './cameraMath.js';
const { cartesian2, cartesian3, cartographic, math } = geo;
const WGS84 = geo.ellipsoid.WGS84;
const near = (actual, expected, tolerance, label) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
const axesAt = (position) => {
const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
return {
frame,
east: cartesian3.create(frame[0], frame[1], frame[2]),
north: cartesian3.create(frame[4], frame[5], frame[6]),
up: cartesian3.create(frame[8], frame[9], frame[10]),
};
};
const dot = cartesian3.dot;
const unit = (v) => cartesian3.normalize(v, cartesian3.create());
const along = (origin, direction, distance) =>
cartesian3.add(
origin,
cartesian3.multiplyByScalar(direction, distance, cartesian3.create()),
cartesian3.create(),
);
// A camera as the math takes it: pose vectors, vertical fov and CSS size.
function cameraAt(lon, lat, height, heading, pitch, roll, size = [1280, 800]) {
const position = cartesian3.fromDegrees(lon, lat, height);
const [width, height_] = size;
return {
position,
...orientationVectors(position, heading, pitch, roll),
fovy: cesiumFovy(width, height_),
width,
height: height_,
};
}
test('heading π/2 at pitch 0 looks east with up along the local up', () => {
const position = cartesian3.fromDegrees(-120, 35, 3000);
const { direction, up } = orientationVectors(position, math.PI_OVER_TWO, 0, 0);
const axes = axesAt(position);
near(dot(direction, axes.east), 1, 1e-12, 'direction·east');
near(dot(up, axes.up), 1, 1e-12, 'up·up');
});
test('pitch −π/2 looks down the local up with up to the north at heading 0', () => {
const position = cartesian3.fromDegrees(-120, 35, 3000);
const { direction, up } = orientationVectors(
position,
0,
-math.PI_OVER_TWO,
0,
);
const axes = axesAt(position);
near(dot(direction, axes.up), -1, 1e-12, 'direction·up');
near(dot(up, axes.north), 1, 1e-12, 'up·north');
});
test('positive roll tilts up toward the camera right', () => {
const position = cartesian3.fromDegrees(10, 45, 3000);
const level = orientationVectors(position, 0.4, -0.2, 0);
const rolled = orientationVectors(position, 0.4, -0.2, 0.3);
const right = cartesian3.cross(
level.direction,
level.up,
cartesian3.create(),
);
near(dot(rolled.up, right), Math.sin(0.3), 1e-12, 'up·levelRight');
near(dot(rolled.direction, level.direction), 1, 1e-12, 'direction kept');
});
test('headingPitchRoll inverts orientationVectors with Cesium ranges', () => {
const position = cartesian3.fromDegrees(-70, 45, 25000);
const { direction, up } = orientationVectors(position, 1.2, -0.6, 0.05);
const angles = headingPitchRoll(position, direction, up);
near(angles.heading, 1.2, 1e-12, 'heading');
near(angles.pitch, -0.6, 1e-12, 'pitch');
near(angles.roll, 0.05, 1e-12, 'roll');
const negative = orientationVectors(position, -0.5, -0.4, -0.2);
const wrapped = headingPitchRoll(position, negative.direction, negative.up);
near(wrapped.heading, math.TWO_PI - 0.5, 1e-12, 'heading of −0.5');
near(wrapped.roll, math.TWO_PI - 0.2, 1e-12, 'roll of −0.2');
const nadir = orientationVectors(position, 0.7, -math.PI_OVER_TWO, 0.3);
const down = headingPitchRoll(position, nadir.direction, nadir.up);
assert.equal(down.roll, 0);
assert.ok(down.heading >= 0 && down.heading < math.TWO_PI);
});
test('navaraPose gives Navara setCamera degrees in the same convention', () => {
const camera = cameraAt(-70, 45, 25000, 1.2, -0.6, -0.05);
const pose = navaraPose(camera.position, camera.direction, camera.up);
near(pose.lng, -70, 1e-9, 'lng');
near(pose.lat, 45, 1e-9, 'lat');
near(pose.height, 25000, 1e-6, 'height');
near(pose.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)');
near(pose.pitch, math.toDegrees(-0.6), 1e-9, 'pitch (deg)');
near(pose.roll, math.toDegrees(-0.05), 1e-9, 'roll (deg) in (−180, 180]');
// Back through the same convention, the basis is unchanged.
const back = orientationVectors(
camera.position,
math.toRadians(pose.heading),
math.toRadians(pose.pitch),
math.toRadians(pose.roll),
);
near(dot(back.direction, camera.direction), 1, 1e-12, 'direction');
near(dot(back.up, camera.up), 1, 1e-12, 'up');
});
test('cesiumFovy spans 60° across the wider canvas side', () => {
near(
cesiumFovy(1280, 800),
2 * Math.atan(Math.tan(Math.PI / 6) / 1.6),
1e-15,
'landscape',
);
near(cesiumFovy(600, 900), Math.PI / 3, 0, 'portrait');
});
test('projection: ahead is the centre, behind is undefined, result is filled', () => {
const camera = cameraAt(20, 10, 1e6, 0, -math.PI_OVER_TWO, 0);
const ahead = along(camera.position, camera.direction, 1000);
const result = cartesian2.create();
assert.equal(projectToWindow(camera, ahead, result), result);
near(result.x, 640, 1e-9, 'x');
near(result.y, 400, 1e-9, 'y');
assert.equal(
projectToWindow(camera, along(camera.position, camera.direction, -1)),
undefined,
);
const upper = projectToWindow(camera, along(ahead, camera.up, 100));
assert.ok(upper.y < 400);
});
test('pickRay and projection round-trip through a screen point', () => {
const camera = cameraAt(20, 10, 30000, 0.6, -0.5, 0.1);
const ray = pickRay(camera, cartesian2.create(200, 150));
near(cartesian3.magnitude(ray.direction), 1, 1e-12, '|direction|');
const back = projectToWindow(camera, along(ray.origin, ray.direction, 5000));
near(back.x, 200, 1e-6, 'x');
near(back.y, 150, 1e-6, 'y');
});
test('viewProjection agrees with projectToWindow', () => {
const camera = cameraAt(20, 10, 30000, 0.6, -0.5, 0.1);
const matrix = viewProjection(camera, new Array(16).fill(0));
const point = along(
camera.position,
pickRay(camera, cartesian2.create(300, 500)).direction,
12000,
);
const window = projectToWindow(camera, point);
const clip = [0, 1, 2, 3].map(
(row) =>
matrix[row] * point.x +
matrix[4 + row] * point.y +
matrix[8 + row] * point.z +
matrix[12 + row],
);
assert.ok(clip[3] > 0);
near(((clip[0] / clip[3] + 1) / 2) * 1280, window.x, 1e-6, 'x');
near(((1 - clip[1] / clip[3]) / 2) * 800, window.y, 1e-6, 'y');
});
test('horizonVisible: the nadir is visible and the antipode hidden', () => {
const eye = cartesian3.fromDegrees(20, 10, 1e6);
assert.equal(horizonVisible(eye, cartesian3.fromDegrees(20, 10, 0)), true);
assert.equal(horizonVisible(eye, cartesian3.fromDegrees(-160, -10, 0)), false);
assert.equal(
horizonVisible(eye, cartesian3.fromDegrees(-70, 10, 2e7)),
true,
);
});
test("viewRectangle matches Cesium's computeViewRectangle (S4 reference poses)", () => {
// S4 measured these on Cesium at 1280×720 with Cesium's fov.
const deg = (rectangle) =>
['west', 'south', 'east', 'north'].map((side) =>
math.toDegrees(rectangle[side]),
);
const nadir = viewRectangle(
cameraAt(-74, 40.7, 1500, 0, -math.PI_OVER_TWO, 0, [1280, 720]),
);
const expected = [-74.0102, 40.6956, -73.9898, 40.7044];
deg(nadir).forEach((value, index) =>
near(value, expected[index], 1e-4, `nadir ${index}`),
);
const pole = viewRectangle(
cameraAt(0, 89, 3e6, 0, -math.PI_OVER_TWO, 0, [1280, 720]),
);
const poleExpected = [-180, 68.7968, 180, 90];
deg(pole).forEach((value, index) =>
near(value, poleExpected[index], 2e-3, `pole ${index}`),
);
});
test('viewRectangle: antimeridian wrap, undefined away from the Earth, whole globe from afar', () => {
const idl = viewRectangle(cameraAt(180, 0, 1e6, 0, -math.PI_OVER_TWO, 0));
assert.ok(idl.west > 0 && idl.east < 0 && idl.west > idl.east);
assert.equal(
viewRectangle(cameraAt(20, 10, 1e6, 0, math.PI_OVER_TWO, 0)),
undefined,
);
const whole = viewRectangle(cameraAt(20, 10, 2e7, 0, -math.PI_OVER_TWO, 0));
assert.deepEqual(whole, {
west: -Math.PI,
south: -math.PI_OVER_TWO,
east: Math.PI,
north: math.PI_OVER_TWO,
});
});
test('rectangleViewPosition frames a rectangle at the centre of a nadir view', () => {
const rectangle = {
west: math.toRadians(2.2),
south: math.toRadians(48.8),
east: math.toRadians(2.5),
north: math.toRadians(48.9),
};
const fovy = cesiumFovy(1280, 800);
const position = rectangleViewPosition(rectangle, fovy, 1280 / 800);
const camera = {
position,
...orientationVectors(position, 0, -math.PI_OVER_TWO, 0),
fovy,
width: 1280,
height: 800,
};
const middle = projectToWindow(
camera,
cartesian3.fromRadians(math.toRadians(2.35), math.toRadians(48.85)),
);
near(middle.x, 640, 64, 'centre x');
near(middle.y, 400, 40, 'centre y');
for (const [lon, lat] of [
[rectangle.west, rectangle.south],
[rectangle.east, rectangle.north],
]) {
const corner = projectToWindow(camera, cartesian3.fromRadians(lon, lat));
assert.ok(corner.x >= 0 && corner.x <= 1280 && corner.y >= 0 && corner.y <= 800);
}
});
test('lookAtFrame: heading/pitch/range and Cartesian offsets aim at the origin', () => {
const target = cartesian3.fromDegrees(-0.12, 51.5, 0);
const { frame, up } = axesAt(target);
const pose = lookAtFrame(frame, { heading: 0.4, pitch: -0.7, range: 2000 });
near(cartesian3.distance(pose.position, target), 2000, 1e-6, 'range');
const aim = (p) =>
dot(
unit(cartesian3.subtract(target, p.position, cartesian3.create())),
p.direction,
);
near(aim(pose), 1, 1e-12, 'aim');
const angles = headingPitchRoll(pose.position, pose.direction, pose.up);
near(angles.pitch, -0.7, 1e-3, 'pitch about the target');
const offset = lookAtFrame(frame, cartesian3.create(0, -1000, 1000));
near(
cartesian3.distance(offset.position, target),
Math.SQRT2 * 1000,
1e-6,
'Cartesian range',
);
near(aim(offset), 1, 1e-12, 'Cartesian aim');
assert.ok(dot(offset.up, up) > 0);
});
test("sphereRange follows Cesium's flyToBoundingSphere defaults", () => {
const fovy = cesiumFovy(1280, 800);
const tanPhi = Math.tan(fovy / 2);
const tanTheta = 1.6 * tanPhi;
assert.equal(sphereRange(0, fovy, 1.6), 100);
near(
sphereRange(1000, fovy, 1.6),
Math.max(1000 / tanTheta, 1000 / tanPhi),
1e-9,
'range',
);
assert.equal(sphereRange(1e-6, fovy, 1.6), 1);
});
test('cartographic heights of the math agree with src/geo', () => {
const camera = cameraAt(139.7, 35.6, 8000, 4, -0.3, 0);
near(
cartographic.fromCartesian(camera.position, WGS84).height,
8000,
1e-6,
'height',
);
});
+388
View File
@@ -0,0 +1,388 @@
// The GlobeCamera of the Navara engine. The engine keeps the camera pose
// itself, in src/geo values, and Navara's camera follows it:
//
// - programmatic moves (setView, lookAt, zoom, a landed flight) change the
// pose at once, as Cesium does, and push it to Navara's setCamera in
// degrees; Navara draws it from its next update. Navara clamps pitch to
// ±89.99°, so its redraw of a pushed pose is not read back;
// - whatever Navara moves by itself (user input, its own flyTo arc) is read
// back from the drawn camera every frame (sync) and becomes the pose;
// - camera events come from frame-to-frame changes of the pose: moveStart on
// the first moving frame, moveEnd once it has been still for 500 ms of
// wall time (Cesium's cameraEventWaitTime), changed past percentageChanged.
//
// The port is the engine's thin layer over the Navara view (apply, read, fly,
// stop, size, requestFrame); see createNavaraEngine.js.
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
import {
cesiumFovy,
headingPitchRoll,
horizonVisible,
lookAtFrame,
navaraPose,
orientationVectors,
pickEllipsoid,
pickRay,
projectToWindow,
rectangleViewPosition,
sphereRange,
viewProjection,
viewRectangle,
} from './cameraMath.js';
const { cartesian3, cartographic, math, matrix4 } = geo;
const WGS84 = geo.ellipsoid.WGS84;
const CESIUM_DEFAULTS = Object.freeze({
heading: 0,
pitch: -math.PI_OVER_TWO,
roll: 0,
});
const DEFAULT_ZOOM_AMOUNT = 100000; // Cesium's camera.defaultZoomAmount
const MOVE_END_WAIT_MS = 500; // Cesium's scene.cameraEventWaitTime
const DEFAULT_PERCENTAGE_CHANGED = 0.5; // Cesium's camera.percentageChanged
// How far Navara's drawing of a pushed pose may be from it and still count as
// that pose: its ±89.99° pitch clamp is 1.7e-4 rad; positions land exactly.
const PUSHED_ANGLE_TOLERANCE = 1e-3;
const PUSHED_POSITION_TOLERANCE = 0.05;
// Navara 0.2.1 flyTo presets for the contract easings. Navara has no sine
// preset; quadInOut is the closest curve.
const NAVARA_EASINGS = Object.freeze({
linear: 'linear',
cubicInOut: 'cubicInOut',
sinusoidalInOut: 'quadInOut',
});
const copy3 = (value) => cartesian3.create(value.x, value.y, value.z);
const copyPose = (pose) => ({
position: copy3(pose.position),
direction: copy3(pose.direction),
up: copy3(pose.up),
});
const angleBetween = (a, b) =>
Math.acos(math.clamp(cartesian3.dot(a, b), -1, 1));
// Unit vectors are compared by chord length (≈ the angle when small), which,
// unlike acos of the dot product, does not turn last-bit noise into 1e-7 rad.
function posesNear(a, b, distance, angle) {
return (
cartesian3.distance(a.position, b.position) <= distance &&
cartesian3.distance(a.direction, b.direction) <= angle &&
cartesian3.distance(a.up, b.up) <= angle
);
}
/**
* @param {{
* port: {
* apply: (pose: object) => void,
* read: () => ({ position: object, direction: object, up: object } | null | undefined),
* fly: (pose: object, options: { duration?: number, easing?: string }) => Promise<boolean>,
* stop: () => void,
* size: () => { width: number, height: number },
* requestFrame: () => void,
* },
* clock?: { now: () => number, setTimeout: Function, clearTimeout: Function },
* }} options
*/
export function createCameraModel({
port,
clock = {
now: () => performance.now(),
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (timer) => clearTimeout(timer),
},
}) {
const events = {
changed: createEngineEvent(),
moveStart: createEngineEvent(),
moveEnd: createEngineEvent(),
};
const initial = port.read();
let state = initial
? copyPose(initial)
: (() => {
const position = cartesian3.fromDegrees(0, 0, 1e7);
return {
position,
...orientationVectors(position, 0, -math.PI_OVER_TWO, 0),
};
})();
let transform = [...matrix4.IDENTITY];
let percentageChanged = DEFAULT_PERCENTAGE_CHANGED;
let flight = null;
let pushed = null;
let lastSeen = initial ? copyPose(initial) : null;
let lastFramePose = copyPose(state);
let lastChangedPose = copyPose(state);
let moving = false;
let lastMoveAt = 0;
let moveEndTimer = null;
let destroyed = false;
const view = () => {
const { width, height } = port.size();
return { ...state, fovy: cesiumFovy(width, height), width, height };
};
// A programmatic pose: exact here, pushed to Navara for drawing.
function place(pose) {
state = copyPose(pose);
pushed = copyPose(pose);
port.apply(navaraPose(state.position, state.direction, state.up));
port.requestFrame();
}
function viewFor(destination, orientation) {
const { width, height } = port.size();
const position =
'west' in destination
? rectangleViewPosition(
destination,
cesiumFovy(width, height),
width / height,
)
: copy3(destination);
if (orientation && 'direction' in orientation)
return {
position,
direction: cartesian3.normalize(
orientation.direction,
cartesian3.create(),
),
up: cartesian3.normalize(orientation.up, cartesian3.create()),
};
return {
position,
...orientationVectors(
position,
orientation?.heading ?? CESIUM_DEFAULTS.heading,
orientation?.pitch ?? CESIUM_DEFAULTS.pitch,
orientation?.roll ?? CESIUM_DEFAULTS.roll,
),
};
}
function endFlight(current, outcome) {
if (flight !== current) return;
flight = null;
if (outcome === 'cancel') current.options.cancel?.();
}
function cancelCurrentFlight() {
const current = flight;
if (!current) return;
endFlight(current, 'cancel');
port.stop();
}
function land(options, target) {
if (options.endTransform) transform = [...options.endTransform];
place(target);
}
function startFlight(options, target) {
cancelCurrentFlight();
if (options.duration !== undefined && options.duration <= 0) {
land(options, target);
options.complete?.();
return;
}
const current = { options };
flight = current;
// Navara moves the camera from here on; read it back every frame.
pushed = null;
const flyOptions = {};
if (options.duration !== undefined)
flyOptions.duration = options.duration * 1000;
if (options.easing !== undefined)
flyOptions.easing = NAVARA_EASINGS[options.easing];
port
.fly(navaraPose(target.position, target.direction, target.up), flyOptions)
.then(
(arrived) => {
if (flight !== current || destroyed) return;
if (!arrived) return endFlight(current, 'cancel');
flight = null;
land(options, target);
options.complete?.();
},
() => endFlight(current, 'cancel'),
);
}
/** Reads Navara's drawn camera; called once per frame before preRender. */
function sync() {
const seen = port.read();
if (!seen) return;
const moved = !lastSeen || !posesNear(seen, lastSeen, 1e-6, 1e-10);
lastSeen = copyPose(seen);
if (!moved) return;
if (
pushed &&
posesNear(seen, pushed, PUSHED_POSITION_TOLERANCE, PUSHED_ANGLE_TOLERANCE)
)
return;
pushed = null;
state = copyPose(seen);
}
function finishMove() {
moveEndTimer = null;
const still = clock.now() - lastMoveAt;
if (still < MOVE_END_WAIT_MS) {
moveEndTimer = clock.setTimeout(finishMove, MOVE_END_WAIT_MS - still);
return;
}
moving = false;
events.moveEnd.raiseEvent();
}
/** Raises camera events for this frame's pose; called after rendering. */
function afterFrame() {
const pose = copyPose(state);
if (!posesNear(pose, lastFramePose, 1e-6, 1e-10)) {
lastMoveAt = clock.now();
if (!moving) {
moving = true;
events.moveStart.raiseEvent();
}
if (moveEndTimer) clock.clearTimeout(moveEndTimer);
moveEndTimer = clock.setTimeout(finishMove, MOVE_END_WAIT_MS);
}
lastFramePose = pose;
const turn =
angleBetween(pose.direction, lastChangedPose.direction) / Math.PI;
const altitude = Math.max(
1,
cartographic.fromCartesian(pose.position, WGS84).height,
);
const shift =
cartesian3.distance(pose.position, lastChangedPose.position) / altitude;
const amount = Math.max(turn, shift);
if (amount > percentageChanged) {
lastChangedPose = pose;
events.changed.raiseEvent(amount);
}
}
const camera = {
pose() {
const current = view();
const angles = headingPitchRoll(
current.position,
current.direction,
current.up,
);
return {
position: copy3(current.position),
cartographic: cartographic.fromCartesian(current.position, WGS84),
heading: angles.heading,
pitch: angles.pitch,
roll: angles.roll,
direction: copy3(current.direction),
up: copy3(current.up),
right: cartesian3.normalize(
cartesian3.cross(current.direction, current.up, cartesian3.create()),
cartesian3.create(),
),
fovy: current.fovy,
};
},
setView(options) {
if (!options?.destination)
throw new TypeError('setView needs a destination');
place(viewFor(options.destination, options.orientation));
},
flyTo(options) {
if (!options?.destination)
throw new TypeError('flyTo needs a destination');
startFlight(options, viewFor(options.destination, options.orientation));
},
flyToSphere(sphere, options = {}) {
transform = [...matrix4.IDENTITY];
const { width, height } = port.size();
const range =
options.range ||
sphereRange(sphere.radius, cesiumFovy(width, height), width / height);
const frame = geo.transforms.eastNorthUpToFixedFrame(
sphere.center,
WGS84,
);
startFlight(
options,
lookAtFrame(frame, {
heading: options.heading ?? 0,
pitch: options.pitch ?? -Math.PI / 4,
range,
}),
);
},
flightActive: () => flight != null,
lookAt(target, offset) {
camera.lookAtTransform(
geo.transforms.eastNorthUpToFixedFrame(target, WGS84),
offset,
);
},
lookAtTransform(frame, offset) {
transform = [...frame];
if (offset) place(lookAtFrame(frame, offset));
},
transform: () => [...transform],
release() {
transform = [...matrix4.IDENTITY];
},
cancelFlight() {
cancelCurrentFlight();
},
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
place({
...state,
position: cartesian3.add(
state.position,
cartesian3.multiplyByScalar(
state.direction,
amount,
cartesian3.create(),
),
cartesian3.create(),
),
});
},
zoomOut(amount = DEFAULT_ZOOM_AMOUNT) {
camera.zoomIn(-amount);
},
viewRectangle: () => viewRectangle(view()),
pickEllipsoid: (screen) => pickEllipsoid(view(), screen),
pickRay: (screen) => pickRay(view(), screen),
changed: events.changed,
moveStart: events.moveStart,
moveEnd: events.moveEnd,
get percentageChanged() {
return percentageChanged;
},
set percentageChanged(value) {
percentageChanged = value;
},
};
return {
camera,
sync,
afterFrame,
/** The current camera for projections: pose, vertical fov and CSS size. */
view,
worldToWindow: (position, result) =>
projectToWindow(view(), position, result),
viewProjection: (result) => viewProjection(view(), result),
horizonVisible: (position) => horizonVisible(state.position, position),
destroy() {
if (destroyed) return;
cancelCurrentFlight();
destroyed = true;
if (moveEndTimer) clock.clearTimeout(moveEndTimer);
moveEndTimer = null;
},
};
}
+441
View File
@@ -0,0 +1,441 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { isEngineEvent } from '../contract.js';
import { orientationVectors } from './cameraMath.js';
import { createCameraModel } from './cameraModel.js';
const { cartesian2, cartesian3, math, matrix4 } = geo;
const near = (actual, expected, tolerance, label) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
// A stand-in for Navara's camera: setCamera lands on the next update with
// Navara's ±89.99° pitch clamp; flights resolve when the test says so.
function createFakePort() {
const port = {
applied: [],
flights: [],
stops: 0,
frames: 0,
drawn: null,
width: 1280,
height: 800,
apply(pose) {
port.applied.push(pose);
},
read: () => port.drawn,
fly(pose, options) {
let settle;
const promise = new Promise((resolve) => (settle = resolve));
port.flights.push({ pose, options, settle });
return promise;
},
stop() {
port.stops++;
},
size: () => ({ width: port.width, height: port.height }),
requestFrame() {
port.frames++;
},
/** Navara's update: draw a newly applied pose (pitch clamped). */
drawnCount: 0,
update() {
if (port.applied.length === port.drawnCount) return;
port.drawnCount = port.applied.length;
const pose = port.applied.at(-1);
const pitch = Math.max(-89.99, Math.min(89.99, pose.pitch));
const position = cartesian3.fromDegrees(pose.lng, pose.lat, pose.height);
port.drawn = {
position,
...orientationVectors(
position,
math.toRadians(pose.heading),
math.toRadians(pitch),
math.toRadians(pose.roll),
),
};
},
};
return port;
}
function createClock() {
const clock = {
time: 0,
timers: [],
now: () => clock.time,
setTimeout(fn, ms) {
const timer = { fn, at: clock.time + ms };
clock.timers.push(timer);
return timer;
},
clearTimeout(timer) {
clock.timers = clock.timers.filter((entry) => entry !== timer);
},
advance(ms) {
clock.time += ms;
for (const timer of [...clock.timers])
if (timer.at <= clock.time) {
clock.clearTimeout(timer);
timer.fn();
}
},
};
return clock;
}
function setup() {
const port = createFakePort();
const clock = createClock();
const model = createCameraModel({ port, clock });
// One Navara frame: update, preRender sync, postRender events.
const frame = (ms = 16) => {
clock.advance(ms);
port.update();
model.sync();
model.afterFrame();
};
return { port, clock, model, camera: model.camera, frame };
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
const nadir = { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 };
test('events are contract events and percentageChanged defaults to Cesium 0.5', () => {
const { camera } = setup();
for (const name of ['changed', 'moveStart', 'moveEnd'])
assert.ok(isEngineEvent(camera[name]), name);
assert.equal(camera.percentageChanged, 0.5);
camera.percentageChanged = 0.1;
assert.equal(camera.percentageChanged, 0.1);
});
test('setView updates the pose at once and pushes the Navara pose in degrees', () => {
const { camera, port } = setup();
const destination = cartesian3.fromDegrees(-70, 45, 25000);
camera.setView({
destination,
orientation: { heading: 1.2, pitch: -0.6, roll: 0.05 },
});
const pose = camera.pose();
near(cartesian3.distance(pose.position, destination), 0, 1e-9, 'position');
near(pose.heading, 1.2, 1e-12, 'heading');
near(pose.pitch, -0.6, 1e-12, 'pitch');
near(pose.roll, 0.05, 1e-12, 'roll');
const pushed = port.applied.at(-1);
near(pushed.lng, -70, 1e-9, 'lng');
near(pushed.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)');
near(pushed.roll, math.toDegrees(0.05), 1e-9, 'roll (deg)');
assert.ok(port.frames > 0, 'no frame was requested');
});
test("Navara's clamped redraw of a pushed pose does not overwrite the exact pose", () => {
const { camera, frame } = setup();
camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
frame();
frame();
near(camera.pose().pitch, -math.PI_OVER_TWO, 1e-12, 'pitch');
});
test('a camera Navara moves by itself (user input) becomes the pose', () => {
const { camera, port, frame } = setup();
camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
frame();
const moved = cartesian3.fromDegrees(21, 11, 9e5);
port.drawn = { position: moved, ...orientationVectors(moved, 0.3, -1, 0) };
frame();
near(cartesian3.distance(camera.pose().position, moved), 0, 1e-9, 'adopted');
near(camera.pose().heading, 0.3, 1e-9, 'heading');
});
test('missing angles default to heading 0, pitch −π/2 and roll 0', () => {
const { camera } = setup();
const destination = cartesian3.fromDegrees(40, 30, 20000);
camera.setView({
destination,
orientation: { heading: 1, pitch: -0.3, roll: 0.2 },
});
camera.setView({ destination, orientation: { heading: 1 } });
const pose = camera.pose();
near(pose.heading, 1, 1e-12, 'heading');
near(pose.pitch, -math.PI_OVER_TWO, 1e-12, 'pitch');
assert.equal(pose.roll, 0);
});
test('setView takes direction/up and rectangle destinations', () => {
const { camera } = setup();
const destination = cartesian3.fromDegrees(5, 50, 3e5);
const direction = cartesian3.normalize(
cartesian3.negate(destination, cartesian3.create()),
cartesian3.create(),
);
const up = cartesian3.normalize(
cartesian3.cross(
cartesian3.cross(direction, cartesian3.UNIT_Z, cartesian3.create()),
direction,
cartesian3.create(),
),
cartesian3.create(),
);
camera.setView({ destination, orientation: { direction, up } });
near(cartesian3.dot(camera.pose().direction, direction), 1, 1e-12, 'dir');
camera.setView({
destination: { west: 0.03, south: 0.85, east: 0.04, north: 0.86 },
});
const place = camera.pose().cartographic;
near(place.longitude, 0.035, 1e-9, 'rectangle centre longitude');
near(camera.pose().pitch, -math.PI_OVER_TWO, 1e-12, 'looks down');
});
test('flyTo with duration 0 lands and completes before it returns', () => {
const { camera, port } = setup();
const outcome = [];
const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000);
camera.flyTo({
destination,
orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
duration: 0,
complete: () => outcome.push('complete'),
cancel: () => outcome.push('cancel'),
});
assert.deepEqual(outcome, ['complete']);
assert.equal(camera.flightActive(), false);
assert.equal(port.flights.length, 0);
near(cartesian3.distance(camera.pose().position, destination), 0, 1e-9, 'p');
});
test('a timed flyTo flies with Navara in ms with the mapped easing and completes on arrival', async () => {
const { camera, port, frame } = setup();
const destination = cartesian3.fromDegrees(100, 0, 5e6);
const outcome = [];
camera.flyTo({
destination,
duration: 2.5,
easing: 'sinusoidalInOut',
endTransform: matrix4.IDENTITY,
complete: () => outcome.push('complete'),
});
assert.equal(camera.flightActive(), true);
const [flight] = port.flights;
assert.equal(flight.options.duration, 2500);
assert.equal(flight.options.easing, 'quadInOut');
near(flight.pose.lng, 100, 1e-9, 'lng');
near(flight.pose.pitch, -90, 1e-9, 'pitch');
// Navara moves the camera during the flight; the pose follows it.
const midway = cartesian3.fromDegrees(60, 5, 6e6);
port.drawn = { position: midway, ...orientationVectors(midway, 0, -1.2, 0) };
frame();
near(cartesian3.distance(camera.pose().position, midway), 0, 1e-9, 'mid');
flight.settle(true);
await flush();
assert.deepEqual(outcome, ['complete']);
assert.equal(camera.flightActive(), false);
near(cartesian3.distance(camera.pose().position, destination), 0, 1e-9, 'p');
});
test('easings map onto Navara presets and an omitted one stays Navara’s own', () => {
const { camera, port } = setup();
for (const easing of ['linear', 'cubicInOut', undefined])
camera.flyTo({
destination: cartesian3.fromDegrees(1, 2, 3e5),
duration: 1,
easing,
});
assert.deepEqual(
port.flights.map((flight) => flight.options.easing),
['linear', 'cubicInOut', undefined],
);
camera.flyTo({ destination: cartesian3.fromDegrees(1, 2, 3e5) });
assert.equal(port.flights.at(-1).options.duration, undefined);
});
test('cancelFlight stops Navara in place and calls cancel, never complete', async () => {
const { camera, port } = setup();
const outcome = [];
camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
complete: () => outcome.push('complete'),
cancel: () => outcome.push('cancel'),
});
camera.cancelFlight();
assert.equal(camera.flightActive(), false);
assert.equal(port.stops, 1);
port.flights[0].settle(false);
await flush();
assert.deepEqual(outcome, ['cancel']);
});
test('a newer flight cancels the previous one', async () => {
const { camera, port } = setup();
const outcome = [];
camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
complete: () => outcome.push('first complete'),
cancel: () => outcome.push('first cancel'),
});
camera.flyTo({
destination: cartesian3.fromDegrees(20, 10, 2e6),
duration: 0,
complete: () => outcome.push('second complete'),
});
port.flights[0].settle(false);
await flush();
assert.deepEqual(outcome, ['first cancel', 'second complete']);
});
test('a flight Navara interrupts on its own counts as cancelled', async () => {
const { camera, port } = setup();
const outcome = [];
camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
cancel: () => outcome.push('cancel'),
});
port.flights[0].settle(false);
await flush();
assert.deepEqual(outcome, ['cancel']);
assert.equal(camera.flightActive(), false);
});
test('flyToSphere uses Cesium defaults and ends unlocked', () => {
const { camera } = setup();
const center = cartesian3.fromDegrees(2.35, 48.85, 100);
camera.lookAt(center, { heading: 0, pitch: -1, range: 500 });
let done = false;
camera.flyToSphere(
{ center, radius: 0 },
{ duration: 0, complete: () => (done = true) },
);
assert.equal(done, true);
near(cartesian3.distance(camera.pose().position, center), 100, 1e-6, 'r');
assert.deepEqual(camera.transform(), [...matrix4.IDENTITY]);
});
test('lookAt locks the transform, aims at the target and release keeps the pose', () => {
const { camera } = setup();
const target = cartesian3.fromDegrees(-0.12, 51.5, 0);
camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 });
const pose = camera.pose();
near(cartesian3.distance(pose.position, target), 2000, 1e-6, 'range');
const frame = geo.transforms.eastNorthUpToFixedFrame(
target,
geo.ellipsoid.WGS84,
);
camera.transform().forEach((value, index) =>
near(value, frame[index], 1e-9, `transform[${index}]`),
);
camera.release();
assert.deepEqual(camera.transform(), [...matrix4.IDENTITY]);
near(
cartesian3.distance(camera.pose().position, pose.position),
0,
0,
'release moved the camera',
);
camera.lookAtTransform(frame);
near(
cartesian3.distance(camera.pose().position, pose.position),
0,
0,
'lookAtTransform without offset moved the camera',
);
});
test('zoomIn and zoomOut move along the view direction', () => {
const { camera } = setup();
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 50000),
orientation: { heading: 0.3, pitch: -0.9, roll: 0 },
});
const start = camera.pose();
camera.zoomIn(2000);
camera.zoomOut(500);
const moved = cartesian3.subtract(
camera.pose().position,
start.position,
cartesian3.create(),
);
near(cartesian3.dot(moved, start.direction), 1500, 1e-6, 'along');
near(cartesian3.magnitude(moved), 1500, 1e-6, 'on the axis');
});
test('moveStart on the first moving frame, moveEnd after 500 ms still, changed past the threshold', () => {
const { camera, frame, clock } = setup();
const seen = [];
for (const name of ['moveStart', 'moveEnd', 'changed'])
camera[name].addEventListener(() => seen.push(name));
frame();
assert.deepEqual(seen, []);
camera.setView({ destination: cartesian3.fromDegrees(60, -30, 2e6) });
frame();
assert.deepEqual(seen, ['moveStart', 'changed']);
camera.setView({ destination: cartesian3.fromDegrees(60, -30, 2.01e6) });
frame(300);
assert.deepEqual(seen, ['moveStart', 'changed'], 'one movement');
clock.advance(499);
assert.ok(!seen.includes('moveEnd'));
clock.advance(1);
assert.deepEqual(seen, ['moveStart', 'changed', 'moveEnd']);
});
test('picks, projection and view rectangle come from the pose and canvas size', () => {
const { camera, port, model } = setup();
camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
const centre = cartesian2.create(port.width / 2, port.height / 2);
const hit = camera.pickEllipsoid(centre);
const place = geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
near(math.toDegrees(place.latitude), 10, 0.01, 'latitude');
const ray = camera.pickRay(centre);
near(cartesian3.dot(ray.direction, camera.pose().direction), 1, 1e-12, 'ray');
const back = model.worldToWindow(hit);
near(back.x, centre.x, 1e-6, 'x');
const rectangle = camera.viewRectangle();
assert.ok(rectangle.west < math.toRadians(20) && math.toRadians(20) < rectangle.east);
near(camera.pose().fovy, 2 * Math.atan(Math.tan(Math.PI / 6) / 1.6), 1e-15, 'fovy');
});
test('destroy cancels a running flight and stops the moveEnd timer', () => {
const { camera, model, clock, frame } = setup();
const outcome = [];
camera.setView({ destination: cartesian3.fromDegrees(1, 1, 1e6) });
frame();
camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
cancel: () => outcome.push('cancel'),
});
model.destroy();
assert.deepEqual(outcome, ['cancel']);
assert.equal(clock.timers.length, 0);
});
test('rounding noise in the drawn camera is no movement, so moveEnd still fires', () => {
const { camera, port, frame, clock } = setup();
const moved = cartesian3.fromDegrees(21, 11, 9e5);
const drawn = { position: moved, ...orientationVectors(moved, 0.3, -1, 0) };
let ends = 0;
camera.moveEnd.addEventListener(() => ends++);
port.drawn = drawn;
frame();
for (let index = 0; index < 60; index++) {
// Navara re-derives the vectors every frame: same pose, last-bit noise.
const jitter = index % 2 ? 1e-13 : -1e-13;
port.drawn = {
position: moved,
direction: cartesian3.create(
drawn.direction.x + jitter,
drawn.direction.y,
drawn.direction.z - jitter,
),
up: cartesian3.create(drawn.up.x, drawn.up.y + jitter, drawn.up.z),
};
frame(16);
}
assert.equal(clock.time >= 960, true);
assert.equal(ends, 1, 'moveEnd did not fire while Navara redrew the pose');
});
+155
View File
@@ -0,0 +1,155 @@
// The GlobeEngine on an initialised Navara ThreeView (index.js builds one).
// The engine keeps the camera pose and Navara draws it (cameraModel.js);
// projection and picks against the ellipsoid are src/geo math on that pose,
// and Navara's own fov is kept on Cesium's (60° across the wider side) so
// both agree with what is drawn. Navara internals reach this module only
// through `hooks` (internals.js), so it runs in Node tests on a fake view.
// destroy() removes everything the engine added; the view stays the caller's.
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
import { cesiumFovy } from './cameraMath.js';
import { createCameraModel } from './cameraModel.js';
import { createNavaraCredits } from './credits.js';
import { createNavaraInput } from './input.js';
import { createNavaraMaps } from './maps.js';
import { createNavaraSurface } from './surface.js';
const { cartesian3, math } = geo;
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
function readCamera(raw) {
raw.updateMatrixWorld();
const e = raw.matrixWorld.elements;
const unit = (x, y, z) =>
cartesian3.normalize(cartesian3.create(x, y, z), cartesian3.create());
return {
position: cartesian3.create(e[12], e[13], e[14]),
direction: unit(-e[8], -e[9], -e[10]),
up: unit(e[4], e[5], e[6]),
};
}
/**
* @param {object} view An initialised Navara ThreeView.
* @param {{
* hooks: { tileRoots: Function, isIdle: Function, retainTileGeometry: Function },
* container?: HTMLElement,
* fetch?: typeof fetch,
* clock?: object,
* }} options `hooks` are internals.js; `container` defaults to the
* canvas's parent; `fetch` and `clock` are test seams.
* @returns {import('../contract.js').GlobeEngine}
*/
export function createNavaraEngine(
view,
{ hooks, container, fetch, clock } = {},
) {
if (!view?.camera?.raw)
throw new TypeError('createNavaraEngine needs a view');
if (!hooks) throw new TypeError('createNavaraEngine needs Navara hooks');
const { canvas } = view;
const size = () => ({
width: canvas.clientWidth,
height: canvas.clientHeight,
});
const matchFov = () => {
const { width, height } = size();
if (width > 0 && height > 0)
view.camera.fov = math.toDegrees(cesiumFovy(width, height));
};
matchFov();
const model = createCameraModel({
port: {
apply: (pose) => view.setCamera(pose),
read: () => readCamera(view.camera.raw),
fly: (pose, options) => view.flyTo(pose, options),
// Navara 0.2.1 has no cancel call: any camera change interrupts a
// flight, and an empty setCamera changes nothing else (S4).
stop: () => view.setCamera({}),
size,
requestFrame: () => view.forceUpdate(),
},
...(clock ? { clock } : {}),
});
const frames = {
preUpdate: createEngineEvent(),
preRender: createEngineEvent(),
postRender: createEngineEvent(),
};
const handlers = {
preUpdate: (time) => frames.preUpdate.raiseEvent(time),
preRender: (time) => {
model.sync();
frames.preRender.raiseEvent(time);
},
postRender: (time) => {
model.afterFrame();
frames.postRender.raiseEvent(time);
},
resize: () => matchFov(),
};
for (const [name, handler] of Object.entries(handlers))
view.on(name, handler);
const input = createNavaraInput(view, {
onCameraInput: () => model.camera.cancelFlight(),
});
const credits = createNavaraCredits(view);
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
const { surface } = createNavaraSurface(view, hooks);
const picker = {
// Navara picks its own meshes only on click gestures (featureClick); there
// is no synchronous pick, and 3D tiles carry no ids. No layers yet.
pick: () => undefined,
drillPick: () => [],
pickPosition(screen) {
const rect = canvas.getBoundingClientRect();
const hit = view.pickDepthPosition(
rect.left + screen.x,
rect.top + screen.y,
);
return hit ? cartesian3.create(hit.x, hit.y, hit.z) : undefined;
},
pickPositionSupported: true,
};
let destroyed = false;
return {
kind: 'navara',
host: { container: container ?? canvas.parentElement, canvas, size },
camera: model.camera,
picker,
projector: {
worldToWindow: (position, result) =>
model.worldToWindow(position, result),
viewProjection: (result) => model.viewProjection(result),
horizonVisible: (position) => model.horizonVisible(position),
},
frames,
render: {
request: () => view.forceUpdate(),
setContinuous(enabled) {
view.animation = Boolean(enabled);
},
isContinuous: () => view.animation === true,
},
surface,
input: input.input,
credits: credits.credits,
maps: maps.maps,
destroy() {
if (destroyed) return;
destroyed = true;
for (const [name, handler] of Object.entries(handlers))
view.off(name, handler);
model.destroy();
input.destroy();
credits.destroy();
maps.destroy();
},
};
}
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { assertGlobeEngine } from '../contract.js';
import { cesiumFovy, orientationVectors } from './cameraMath.js';
import { createNavaraEngine } from './createNavaraEngine.js';
const { cartesian2, cartesian3, math } = geo;
// The parts of a Navara ThreeView the engine uses.
function fakeView() {
const handlers = new Map();
const canvas = new EventTarget();
Object.assign(canvas, {
clientWidth: 1280,
clientHeight: 800,
getBoundingClientRect: () => ({ left: 10, top: 20 }),
parentElement: { id: 'container' },
});
const position = cartesian3.fromDegrees(0, 0, 1e7);
const { direction, up } = orientationVectors(position, 0, -math.PI_OVER_TWO, 0);
// three's camera.matrixWorld: columns right, up, back (−direction), position.
const right = cartesian3.cross(direction, up, cartesian3.create());
const elements = [
right.x, right.y, right.z, 0,
up.x, up.y, up.z, 0,
-direction.x, -direction.y, -direction.z, 0,
position.x, position.y, position.z, 1,
];
const view = {
canvas,
animation: false,
calls: [],
flights: [],
on(name, fn) {
if (!handlers.has(name)) handlers.set(name, new Set());
handlers.get(name).add(fn);
},
off(name, fn) {
handlers.get(name)?.delete(fn);
},
emit(name, ...args) {
for (const fn of [...(handlers.get(name) ?? [])]) fn(...args);
},
listenerCount: () =>
[...handlers.values()].reduce((count, set) => count + set.size, 0),
camera: {
raw: { matrixWorld: { elements }, updateMatrixWorld() {} },
fovs: [],
set fov(value) {
view.camera.fovs.push(value);
},
set options(value) {
view.calls.push(['options', value]);
},
},
setCamera(pose) {
view.calls.push(['setCamera', pose]);
},
flyTo(pose, options) {
return new Promise((resolve) => view.flights.push({ pose, options, resolve }));
},
forceUpdate() {
view.calls.push(['forceUpdate']);
},
pickDepthPosition(x, y) {
view.calls.push(['pickDepthPosition', x, y]);
return x > 100 ? { x: 1, y: 2, z: 3, isVector3: true } : null;
},
sampleTerrainHeight: () => undefined,
addSource: (desc) => ({ id: 's', desc, delete: () => true }),
addLayer: (desc) => ({ id: 'l', desc, delete() {} }),
attribution: { add() {}, remove() {} },
};
return view;
}
const hooks = {
tileRoots: () => [],
isIdle: () => true,
retainTileGeometry() {},
};
const setup = () => {
const view = fakeView();
const engine = createNavaraEngine(view, { hooks });
return { view, engine };
};
test('the engine has every contract capability and reports kind navara', () => {
const { engine, view } = setup();
assertGlobeEngine(engine);
assert.equal(engine.kind, 'navara');
assert.equal(engine.host.canvas, view.canvas);
assert.deepEqual(engine.host.size(), { width: 1280, height: 800 });
});
test("Navara's frame events drive the contract frames in order with timestamps", () => {
const { engine, view } = setup();
const order = [];
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener((time) => order.push([name, time]));
view.emit('preUpdate', 1);
view.emit('postUpdate', 1);
view.emit('preRender', 2);
view.emit('postRender', 3);
assert.deepEqual(order, [
['preUpdate', 1],
['preRender', 2],
['postRender', 3],
]);
});
test('render requests are forceUpdate and continuous mode is Navara animation', () => {
const { engine, view } = setup();
engine.render.request();
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
engine.render.setContinuous(true);
assert.equal(view.animation, true);
assert.equal(engine.render.isContinuous(), true);
engine.render.setContinuous(false);
assert.equal(view.animation, false);
});
test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
const { engine, view } = setup();
near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height');
engine.camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
const [, pose] = view.calls.findLast(([name]) => name === 'setCamera');
near(pose.lng, 20, 1e-9, 'lng');
near(pose.pitch, -90, 1e-9, 'pitch');
});
test("Navara's fov follows Cesium's vertical fov, also after a resize", () => {
const { view, engine } = setup();
near(view.camera.fovs.at(-1), math.toDegrees(cesiumFovy(1280, 800)), 1e-12, 'fov');
view.canvas.clientWidth = 600;
view.canvas.clientHeight = 900;
view.emit('resize', 600, 900);
near(view.camera.fovs.at(-1), 60, 1e-12, 'portrait fov');
near(engine.camera.pose().fovy, Math.PI / 3, 1e-15, 'pose fovy');
});
test('cancelFlight stops Navara with an empty setCamera; user input cancels a flight', () => {
const { engine, view } = setup();
const outcome = [];
engine.camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
cancel: () => outcome.push('cancel'),
});
assert.equal(view.flights[0].options.duration, 5000);
engine.camera.cancelFlight();
assert.deepEqual(view.calls.at(-1), ['setCamera', {}]);
engine.camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
cancel: () => outcome.push('input'),
});
view.canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(outcome, ['cancel', 'input']);
assert.equal(engine.camera.flightActive(), false);
});
test('pickPosition reads Navara depth at client coordinates; pick finds no tiles', () => {
const { engine, view } = setup();
const hit = engine.picker.pickPosition(cartesian2.create(200, 30));
assert.deepEqual(view.calls.at(-1), ['pickDepthPosition', 210, 50]);
assert.deepEqual([hit.x, hit.y, hit.z], [1, 2, 3]);
assert.equal(hit.isVector3, undefined, 'a three Vector3 leaked');
assert.equal(engine.picker.pickPosition(cartesian2.create(1, 1)), undefined);
assert.equal(engine.picker.pickPositionSupported, true);
assert.equal(engine.picker.pick(cartesian2.create(1, 1)), undefined);
assert.deepEqual(engine.picker.drillPick(cartesian2.create(1, 1), 3), []);
});
test('the projector agrees with the camera', () => {
const { engine } = setup();
const pose = engine.camera.pose();
const ahead = cartesian3.add(
pose.position,
cartesian3.multiplyByScalar(pose.direction, 1000, cartesian3.create()),
cartesian3.create(),
);
const window = engine.projector.worldToWindow(ahead);
near(window.x, 640, 1e-6, 'x');
near(window.y, 400, 1e-6, 'y');
assert.equal(engine.projector.horizonVisible(cartesian3.fromDegrees(0, 0)), true);
});
test('destroy removes its Navara listeners, keeps the view and is idempotent', () => {
const { engine, view } = setup();
engine.frames.postRender.addEventListener(() => {});
engine.input.on('click', () => {});
engine.destroy();
engine.destroy();
assert.equal(view.listenerCount(), 0);
});
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
}
+44
View File
@@ -0,0 +1,44 @@
// Static credits on Navara's attribution UI (view.attribution, the
// AttributionPlugin). Navara matches entries by content and drops duplicates,
// so one HTML string is shown once and counted here, staying until its last
// holder removes it. Navara has no separate on-screen strip: `onScreen` credits
// go into the same list; logos that must stay visible (Google) are sources
// with a `logo`, added by maps.js.
/**
* @param {{ attribution?: { add: Function, remove: Function } }} view
* @returns {{ credits: object, destroy: () => void }}
*/
export function createNavaraCredits(view) {
const counts = new Map();
const release = (html) => {
if (!counts.has(html)) return;
const count = counts.get(html);
if (count > 1) return counts.set(html, count - 1);
counts.delete(html);
view.attribution?.remove([{ attributionHtml: html }]);
};
const credits = {
addStatic(html) {
const text = String(html);
const count = counts.get(text) ?? 0;
counts.set(text, count + 1);
if (count === 0) view.attribution?.add([{ attributionHtml: text }]);
let removed = false;
return () => {
if (removed) return;
removed = true;
release(text);
};
},
};
return {
credits,
destroy() {
for (const html of [...counts.keys()]) {
counts.set(html, 1);
release(html);
}
},
};
}
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNavaraCredits } from './credits.js';
function fakeAttribution() {
const shown = [];
return {
shown,
add(items) {
for (const item of items)
if (!shown.some((entry) => entry.attributionHtml === item.attributionHtml))
shown.push(item);
},
remove(items) {
for (const item of items) {
const index = shown.findIndex(
(entry) => entry.attributionHtml === item.attributionHtml,
);
if (index >= 0) shown.splice(index, 1);
}
},
};
}
test('addStatic shows HTML in the attribution and the remover is idempotent', () => {
const attribution = fakeAttribution();
const { credits } = createNavaraCredits({ attribution });
const remove = credits.addStatic('<a href="x">X</a>', { onScreen: true });
assert.deepEqual(attribution.shown, [{ attributionHtml: '<a href="x">X</a>' }]);
remove();
remove();
assert.deepEqual(attribution.shown, []);
});
test('the same HTML added twice stays until both holders remove it', () => {
const attribution = fakeAttribution();
const { credits } = createNavaraCredits({ attribution });
const first = credits.addStatic('Same');
const second = credits.addStatic('Same');
first();
first();
assert.equal(attribution.shown.length, 1);
second();
assert.equal(attribution.shown.length, 0);
});
test('destroy removes every credit; a view without attribution UI is a no-op', () => {
const attribution = fakeAttribution();
const { credits, destroy } = createNavaraCredits({ attribution });
credits.addStatic('A');
credits.addStatic('B');
destroy();
assert.deepEqual(attribution.shown, []);
const bare = createNavaraCredits({ attribution: undefined });
const remove = bare.credits.addStatic('A');
remove();
});
+77
View File
@@ -0,0 +1,77 @@
// The Navara globe for the app's experimental engine mode: builds the view
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a
// dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView from '@navaramap/three';
import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js';
import { navaraHooks } from './internals.js';
import {
NAVARA_CONTEXT_ATTRIBUTES,
navaraScenePlan,
navaraViewOptions,
} from './viewOptions.js';
// Navara 0.2.1 has no preserveDrawingBuffer option but takes a canvas whose
// WebGL2 context is already created (S4); three then uses that context.
function preservedCanvas(container) {
const canvas = document.createElement('canvas');
Object.assign(canvas.style, {
width: '100%',
height: '100%',
display: 'block',
});
container.appendChild(canvas);
if (!canvas.getContext('webgl2', NAVARA_CONTEXT_ATTRIBUTES)) {
canvas.remove();
throw new Error('WebGL2 is not available');
}
return canvas;
}
/**
* @param {{
* container: HTMLElement,
* effects?: 'minimal' | 'photoreal',
* pixelRatio?: number,
* }} options `pixelRatio` only for measurements; the product uses the device's.
* @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
*/
export async function createNavaraGlobe({ container, effects, pixelRatio }) {
if (!container) throw new TypeError('createNavaraGlobe needs a container');
const plan = navaraScenePlan(effects);
const canvas = preservedCanvas(container);
let view;
try {
view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio }));
const plugin = new DefaultPlugin();
view.addPlugin(plugin);
await view.init();
if (plan.pluginDefault) plugin.addDefaultPhotorealScene();
else {
for (const name of plan.meshes) view.addMesh({ [name]: {} });
for (const name of plan.lights) view.addLight({ [name]: {} });
for (const name of plan.effects) view.addEffect({ [name]: {} });
}
} catch (error) {
view?.dispose();
canvas.remove();
throw error;
}
const engine = createNavaraEngine(view, {
hooks: navaraHooks(view),
container,
});
let destroyed = false;
return {
view,
engine,
destroy() {
if (destroyed) return;
destroyed = true;
engine.destroy();
view.dispose();
canvas.remove();
},
};
}
+132
View File
@@ -0,0 +1,132 @@
// Screen input for the Navara engine, from DOM pointer events on Navara's
// canvas, with Cesium's ScreenSpaceEventHandler semantics: left button only,
// a click is a down and up within 5 px, positions are CSS px from the
// canvas's top-left corner, and a listener with a modifier fires only while
// that key is held (shift over ctrl over alt), one without only while none is.
//
// Camera inputs map to Navara's controller flags (view.camera.options, S4).
// Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it;
// `onCameraInput` reports user camera input so the engine can do the same.
import { INPUT_TYPES, MODIFIERS } from '../contract.js';
const CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance
const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick'];
const CAMERA_EVENTS = ['pointerdown', 'wheel', 'touchstart'];
function modifierOf(event) {
if (event.shiftKey) return 'shift';
if (event.ctrlKey) return 'ctrl';
if (event.altKey) return 'alt';
return undefined;
}
/**
* @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view
* @param {{ onCameraInput?: (type: string) => void }} [options]
* @returns {{ input: object, destroy: () => void }}
*/
export function createNavaraInput(view, { onCameraInput = () => {} } = {}) {
const { canvas } = view;
const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
let attached = false;
let destroyed = false;
let cameraInputs = true;
let downAt = null;
let last = null;
const positionOf = (event) => {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
const emit = (type, event, payload) => {
const modifier = modifierOf(event);
for (const entry of [...listeners.get(type)])
if (entry.modifier === modifier && listeners.get(type).has(entry))
entry.listener(payload);
};
const onScreenEvent = (event) => {
const position = positionOf(event);
switch (event.type) {
case 'pointerdown':
if (event.button !== 0) break;
downAt = position;
emit('down', event, { position });
break;
case 'pointerup': {
if (event.button !== 0) break;
emit('up', event, { position });
const start = downAt;
downAt = null;
if (
start &&
Math.hypot(position.x - start.x, position.y - start.y) <
CLICK_PIXEL_TOLERANCE
)
emit('click', event, { position });
break;
}
case 'pointermove':
emit('move', event, {
startPosition: last ?? position,
endPosition: position,
});
break;
case 'dblclick':
emit('doubleClick', event, { position });
break;
}
last = position;
};
const onCameraEvent = (event) => {
if (cameraInputs) onCameraInput(event.type);
};
for (const type of CAMERA_EVENTS)
canvas.addEventListener(type, onCameraEvent, {
capture: true,
passive: true,
});
const input = {
on(type, listener, { modifier } = {}) {
if (!INPUT_TYPES.includes(type))
throw new TypeError(`Unknown input type: ${type}`);
if (modifier !== undefined && !MODIFIERS.includes(modifier))
throw new TypeError(`Unknown input modifier: ${modifier}`);
if (destroyed) return () => {};
if (!attached) {
attached = true;
for (const name of SCREEN_EVENTS)
canvas.addEventListener(name, onScreenEvent);
}
const entry = { listener, modifier };
listeners.get(type).add(entry);
return () => {
listeners.get(type).delete(entry);
};
},
setCameraInputs(enabled) {
cameraInputs = Boolean(enabled);
view.camera.options = {
enableSpin: cameraInputs,
enableZoom: cameraInputs,
enableTilt: cameraInputs,
};
},
};
return {
input,
destroy() {
if (destroyed) return;
destroyed = true;
for (const type of CAMERA_EVENTS)
canvas.removeEventListener(type, onCameraEvent, { capture: true });
if (attached)
for (const name of SCREEN_EVENTS)
canvas.removeEventListener(name, onScreenEvent);
attached = false;
for (const set of listeners.values()) set.clear();
},
};
}
+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNavaraInput } from './input.js';
function fakeCanvas() {
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 50 });
canvas.listenerCount = 0;
const add = canvas.addEventListener.bind(canvas);
const remove = canvas.removeEventListener.bind(canvas);
canvas.addEventListener = (...args) => {
canvas.listenerCount++;
add(...args);
};
canvas.removeEventListener = (...args) => {
canvas.listenerCount--;
remove(...args);
};
return canvas;
}
const pointer = (type, x, y, extra = {}) =>
Object.assign(new Event(type), {
clientX: x,
clientY: y,
button: 0,
shiftKey: false,
ctrlKey: false,
altKey: false,
...extra,
});
function setup() {
const canvas = fakeCanvas();
const cameraOptions = [];
const cameraInputs = [];
const view = {
canvas,
camera: {
set options(value) {
cameraOptions.push(value);
},
},
};
const { input, destroy } = createNavaraInput(view, {
onCameraInput: (type) => cameraInputs.push(type),
});
return { canvas, input, destroy, cameraOptions, cameraInputs };
}
test('down, up and click carry canvas-relative positions', () => {
const { canvas, input } = setup();
const seen = [];
for (const type of ['down', 'up', 'click'])
input.on(type, (event) => seen.push([type, event.position]));
canvas.dispatchEvent(pointer('pointerdown', 110, 60));
canvas.dispatchEvent(pointer('pointerup', 113, 61));
assert.deepEqual(seen, [
['down', { x: 10, y: 10 }],
['up', { x: 13, y: 11 }],
['click', { x: 13, y: 11 }],
]);
});
test('a drag beyond the 5 px click tolerance is not a click; other buttons are ignored', () => {
const { canvas, input } = setup();
let clicks = 0;
input.on('click', () => clicks++);
canvas.dispatchEvent(pointer('pointerdown', 110, 60));
canvas.dispatchEvent(pointer('pointerup', 120, 60));
canvas.dispatchEvent(pointer('pointerdown', 110, 60, { button: 2 }));
canvas.dispatchEvent(pointer('pointerup', 110, 60, { button: 2 }));
assert.equal(clicks, 0);
});
test('move reports start and end positions; doubleClick a position', () => {
const { canvas, input } = setup();
const seen = [];
input.on('move', (event) => seen.push(event));
input.on('doubleClick', (event) => seen.push(event));
canvas.dispatchEvent(pointer('pointermove', 110, 60));
canvas.dispatchEvent(pointer('pointermove', 115, 70));
canvas.dispatchEvent(pointer('dblclick', 115, 70));
assert.deepEqual(seen, [
{ startPosition: { x: 10, y: 10 }, endPosition: { x: 10, y: 10 } },
{ startPosition: { x: 10, y: 10 }, endPosition: { x: 15, y: 20 } },
{ position: { x: 15, y: 20 } },
]);
});
test('modifiers: a listener fires only for its own key, an unmodified one only without keys', () => {
const { canvas, input } = setup();
const seen = [];
input.on('down', () => seen.push('none'));
input.on('down', () => seen.push('shift'), { modifier: 'shift' });
input.on('down', () => seen.push('ctrl'), { modifier: 'ctrl' });
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
canvas.dispatchEvent(pointer('pointerdown', 1, 1, { shiftKey: true }));
canvas.dispatchEvent(pointer('pointerdown', 1, 1, { ctrlKey: true }));
// Cesium takes shift over ctrl over alt.
canvas.dispatchEvent(
pointer('pointerdown', 1, 1, { ctrlKey: true, shiftKey: true }),
);
assert.deepEqual(seen, ['none', 'shift', 'ctrl', 'shift']);
});
test('on validates its arguments and returns an idempotent remover', () => {
const { canvas, input } = setup();
assert.throws(() => input.on('wheel', () => {}), TypeError);
assert.throws(() => input.on('click', () => {}, { modifier: 'meta' }), TypeError);
let count = 0;
const off = input.on('down', () => count++);
off();
off();
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
assert.equal(count, 0);
});
test('setCameraInputs toggles Navara camera control and user input cancels flights only while enabled', () => {
const { canvas, input, cameraOptions, cameraInputs } = setup();
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']);
input.setCameraInputs(false);
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']);
input.setCameraInputs(true);
assert.deepEqual(cameraOptions, [
{ enableSpin: false, enableZoom: false, enableTilt: false },
{ enableSpin: true, enableZoom: true, enableTilt: true },
]);
});
test('destroy removes every DOM listener and later subscriptions are inert', () => {
const { canvas, input, destroy } = setup();
input.on('click', () => {});
input.on('move', () => {});
destroy();
assert.equal(canvas.listenerCount, 0);
const off = input.on('click', () => {});
assert.equal(typeof off, 'function');
assert.equal(canvas.listenerCount, 0);
});
+73
View File
@@ -0,0 +1,73 @@
// Every private Navara member the engine relies on, in one place. Written
// against @navaramap/three 0.2.1 (package.json pins it exactly; the test in
// internals.test.mjs fails if the two drift apart): re-check each access below
// before moving to another Navara version. Each names the parity spike that
// found it and the public API that would replace it upstream.
import { ModelMesh } from '@navaramap/three';
export const NAVARA_INTERNALS_VERSION = '0.2.1';
let restoreRelease = null;
/**
* S2: keep 3D-tile positions and indices on the CPU after upload so the
* surface ray can hit them. Private: ModelMesh.prototype._releaseGeometryArrays,
* which frees every attribute array once the tile is on the GPU. Patched on
* the prototype, for all views; idempotent. Upstream ask: an option to retain
* tile geometry for ray queries, or a sampleHeight/pickRay over 3D tiles.
* Throws when the member is gone (another Navara version).
*/
export function retainTileGeometry() {
if (restoreRelease) return restoreRelease;
const proto = ModelMesh.prototype;
const release = proto._releaseGeometryArrays;
if (typeof release !== 'function')
throw new Error('ModelMesh._releaseGeometryArrays not found');
proto._releaseGeometryArrays = function releaseAllButPositions(mesh) {
const geometry = mesh.geometry;
const position = geometry?.attributes?.position;
const index = geometry?.index ?? null;
if (!position) return release.call(this, mesh);
delete geometry.attributes.position;
geometry.index = null;
try {
return release.call(this, mesh);
} finally {
geometry.attributes.position = position;
geometry.index = index;
}
};
restoreRelease = () => {
proto._releaseGeometryArrays = release;
restoreRelease = null;
};
return restoreRelease;
}
/**
* S2: the loaded 3D-tile roots (ModelMesh objects). Private: view._scenes.mrt,
* the scene Navara renders tiles into. Upstream ask: as for
* retainTileGeometry.
*/
export function tileRoots(view) {
return (view._scenes?.mrt?.children ?? []).filter(
(object) => object instanceof ModelMesh,
);
}
/**
* S2/S5: whether Navara has stopped updating (tiles loaded, camera still).
* Private: view._isIdle, which the public `idle` event reports only as an
* edge. Upstream ask: a public isIdle/tilesLoaded getter.
*/
export function isIdle(view) {
return view._isIdle === true;
}
/** The hooks createNavaraEngine takes. */
export const navaraHooks = (view) => ({
retainTileGeometry,
tileRoots: () => tileRoots(view),
isIdle: () => isIdle(view),
});
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
const here = new URL('./', import.meta.url);
const read = (name) => readFileSync(new URL(name, here), 'utf8');
test('Navara internals are pinned: package.json and internals.js name the same exact version', () => {
const pkg = JSON.parse(read('../../../package.json'));
const declared = read('internals.js').match(
/NAVARA_INTERNALS_VERSION = '([^']+)'/,
)?.[1];
assert.equal(declared, '0.2.1');
for (const name of ['@navaramap/three', '@navaramap/three-default-plugin'])
assert.equal(pkg.dependencies[name], declared, name);
});
test('only internals.js reaches private Navara members', () => {
const offenders = readdirSync(here).filter(
(name) =>
name.endsWith('.js') &&
name !== 'internals.js' &&
/\.\s*_[A-Za-z]/.test(read(name).replace(/\/\/.*$/gm, '')),
);
assert.deepEqual(offenders, []);
});
+187
View File
@@ -0,0 +1,187 @@
// Map sources on a Navara view. Sources are maps/registry.js entries
// ({ descriptor, credit } for imagery, { id } for terrain) or plain
// descriptions ({ kind, accessToken?, googleApiKey? }); this module builds
// Navara sources and layers from them. The URLs and credits repeat those of
// src/maps/imagery.js and src/maps/terrain.js, which import Cesium.
//
// - imagery: OSM and Esri as `raster-tile` layers; Cesium ion imagery (Bing)
// has no Navara path yet and rejects.
// - terrain: the keyless Re:Earth quantized mesh, Cesium World Terrain through
// the ion endpoint with an explicit token (Navara sends it as a Bearer
// token), or none (the ellipsoid). Navara 0.2.1 panics on a quantized-mesh
// URL without the {z}/{x}/{y}.terrain template (S2), so URLs always carry it.
// - photoreal: Google 3D tiles with a browser key only in this phase (the
// server proxy of S3 comes later), with Google's logo kept on screen and the
// tiles' own copyright lines (creditLayerId). Showing them removes the base
// imagery, as MapSourceController does; clearing them does not restore it.
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const OSM_CREDIT = {
attribution: '© OpenStreetMap contributors',
attributionUrl: 'https://www.openstreetmap.org/copyright',
};
const ESRI_URL =
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const ESRI_CREDIT = {
attributionHtml:
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>',
};
const KEYLESS_TERRAIN_URL =
'https://terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain';
const KEYLESS_TERRAIN_MAX_ZOOM = 14;
const ION_WORLD_TERRAIN_ENDPOINT =
'https://api.cesium.com/v1/assets/1/endpoint?access_token=';
const GOOGLE_TILES_ROOT = 'https://tile.googleapis.com/v1/3dtiles/root.json';
// The mark Cesium shows for Google tiles (GoogleMaps.getDefaultCredit).
const GOOGLE_LOGO = 'https://assets.ion.cesium.com/google-credit.png';
// Cesium3DTileset's default maximumScreenSpaceError, as the S1–S5 benches use.
const PHOTOREAL_MAX_SSE = 16;
const kindOf = (source) => (source?.descriptor ?? source)?.kind;
function imageryFor(source) {
const kind = kindOf(source);
const credit =
source?.credit != null ? { attributionHtml: String(source.credit) } : null;
switch (kind) {
case 'osm':
return { url: OSM_URL, credit: credit ?? OSM_CREDIT };
case 'esri-imagery':
return { url: ESRI_URL, credit: credit ?? ESRI_CREDIT };
case 'ion':
throw new Error('Cesium ion imagery is not available on Navara yet');
default:
throw new TypeError(`Unknown imagery source: ${kind}`);
}
}
/**
* @param {object} view A Navara ThreeView (addSource, addLayer, attribution, forceUpdate).
* @param {{ fetch?: typeof fetch }} [options]
* @returns {{ maps: object, destroy: () => void }}
*/
export function createNavaraMaps(
view,
{ fetch: fetchJson = globalThis.fetch } = {},
) {
const slots = { imagery: null, terrain: null, photoreal: null };
const generations = { imagery: 0, terrain: 0, photoreal: 0 };
let destroyed = false;
function clear(name) {
const slot = slots[name];
if (!slot) return;
slots[name] = null;
slot.layer.delete();
slot.source.delete();
if (slot.credits.length) view.attribution?.remove(slot.credits);
}
function show(name, key, sourceDesc, layerDesc, credits = []) {
clear(name);
const source = view.addSource(sourceDesc);
const layer = view.addLayer({ ...layerDesc, source });
const shown = credits.map((credit) =>
typeof credit === 'function' ? credit(layer) : credit,
);
if (shown.length) view.attribution?.add(shown);
slots[name] = { key, source, layer, credits: shown };
view.forceUpdate();
}
async function terrainFor(source) {
const kind = source?.kind ?? source?.id;
switch (kind) {
case 'ellipsoid':
return null;
case 'keyless':
return {
type: 'quantized-mesh',
url: KEYLESS_TERRAIN_URL,
maxZoom: KEYLESS_TERRAIN_MAX_ZOOM,
};
case 'world': {
const token = String(source.accessToken || '').trim();
if (!token)
throw new Error('World terrain requires an explicit ion token');
const response = await fetchJson(
`${ION_WORLD_TERRAIN_ENDPOINT}${encodeURIComponent(token)}`,
);
if (!response.ok)
throw new Error(`Cesium ion answered ${response.status}`);
const endpoint = await response.json();
return {
type: 'quantized-mesh',
url: `${endpoint.url}{z}/{x}/{y}.terrain?v=1.2.0`,
token: endpoint.accessToken,
};
}
default:
throw new TypeError(`Unknown terrain source: ${kind}`);
}
}
const maps = {
async setBaseImagery(source) {
const generation = ++generations.imagery;
const { url, credit } = imageryFor(source);
if (destroyed || generation !== generations.imagery) return;
if (slots.imagery?.key === url) return;
show('imagery', url, { type: 'raster-tile', url }, { type: 'raster' }, [
credit,
]);
},
async setTerrain(source) {
const generation = ++generations.terrain;
const description = await terrainFor(source);
if (destroyed || generation !== generations.terrain) return;
if (!description) {
clear('terrain');
view.forceUpdate();
return;
}
if (slots.terrain?.key === description.url) return;
show('terrain', description.url, description, { type: 'terrain' });
},
async setPhotorealTiles(source) {
const generation = ++generations.photoreal;
if (source == null) {
clear('photoreal');
view.forceUpdate();
return;
}
const key = String(source.googleApiKey || '').trim();
if (!key)
throw new Error(
'Google 3D tiles on Navara need a browser key (googleApiKey)',
);
if (destroyed || generation !== generations.photoreal) return;
const url = `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`;
clear('imagery');
if (slots.photoreal?.key === url) return;
show(
'photoreal',
url,
{ type: '3d-tiles', url },
{ type: '3d-tiles', model: { maxSse: PHOTOREAL_MAX_SSE } },
[
(layer) => ({
attribution: 'Google',
logo: GOOGLE_LOGO,
creditLayerId: layer.id,
}),
],
);
},
photorealActive: () => slots.photoreal != null,
};
return {
maps,
destroy() {
if (destroyed) return;
destroyed = true;
for (const name of Object.keys(slots)) clear(name);
},
};
}
+193
View File
@@ -0,0 +1,193 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNavaraMaps } from './maps.js';
function fakeView() {
const view = {
sources: [],
layers: [],
shown: [],
frames: 0,
addSource(desc) {
const source = {
id: `source-${view.sources.length}`,
desc,
delete() {
view.sources = view.sources.filter((entry) => entry !== source);
return true;
},
};
view.sources.push(source);
return source;
},
addLayer(desc) {
const layer = {
id: `layer-${view.layers.length}`,
desc,
delete() {
view.layers = view.layers.filter((entry) => entry !== layer);
},
};
view.layers.push(layer);
return layer;
},
attribution: {
add(items) {
view.shown.push(...items);
},
remove(items) {
for (const item of items)
view.shown = view.shown.filter(
(entry) => JSON.stringify(entry) !== JSON.stringify(item),
);
},
},
forceUpdate() {
view.frames++;
},
};
return view;
}
const layersOf = (view, type) =>
view.layers.filter((layer) => layer.desc.type === type);
test('setBaseImagery builds Navara raster layers for OSM and Esri, one at a time', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
await maps.setBaseImagery({ kind: 'osm' });
let [layer] = layersOf(view, 'raster');
assert.equal(
view.sources[0].desc.url,
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
);
assert.equal(layer.desc.source, view.sources[0]);
assert.ok(view.shown.some((item) => /OpenStreetMap/.test(JSON.stringify(item))));
await maps.setBaseImagery({
descriptor: { id: 'esri-imagery', kind: 'esri-imagery' },
credit: '<a href="https://www.esri.com">Powered by Esri</a>',
});
assert.equal(layersOf(view, 'raster').length, 1);
assert.equal(view.sources.length, 1);
assert.match(view.sources[0].desc.url, /World_Imagery\/MapServer\/tile\/\{z\}\/\{y\}\/\{x\}$/);
assert.deepEqual(view.shown, [
{ attributionHtml: '<a href="https://www.esri.com">Powered by Esri</a>' },
]);
assert.ok(view.frames > 0);
});
test('the same imagery source again keeps the live layer', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
await maps.setBaseImagery({ kind: 'osm' });
const [layer] = view.layers;
await maps.setBaseImagery({ kind: 'osm' });
assert.deepEqual(view.layers, [layer]);
});
test('an imagery source Navara cannot build rejects and the current one stays', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
await maps.setBaseImagery({ kind: 'osm' });
await assert.rejects(maps.setBaseImagery({ kind: 'ion', style: 2 }), /ion/);
await assert.rejects(maps.setBaseImagery({ kind: 'nope' }), TypeError);
assert.equal(view.sources[0].desc.url, 'https://tile.openstreetmap.org/{z}/{x}/{y}.png');
});
test('setTerrain: keyless Re:Earth mesh, flat ellipsoid, and ion world terrain with a token', async () => {
const view = fakeView();
const requests = [];
const { maps } = createNavaraMaps(view, {
fetch: async (url) => {
requests.push(url);
return {
ok: true,
json: async () => ({
url: 'https://assets.ion.cesium.com/asset_depot/1/CesiumWorldTerrain/v1.2/',
accessToken: 'endpoint-token',
}),
};
},
});
await maps.setTerrain({ id: 'keyless', create: () => {} });
const [terrain] = layersOf(view, 'terrain');
assert.equal(
terrain.desc.source.desc.url,
'https://terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain',
);
assert.equal(terrain.desc.source.desc.type, 'quantized-mesh');
await maps.setTerrain({ kind: 'ellipsoid' });
assert.equal(layersOf(view, 'terrain').length, 0);
assert.equal(view.sources.length, 0);
await assert.rejects(maps.setTerrain({ kind: 'world' }), /token/);
await maps.setTerrain({ kind: 'world', accessToken: 'ion-token' });
assert.equal(
requests[0],
'https://api.cesium.com/v1/assets/1/endpoint?access_token=ion-token',
);
const [world] = layersOf(view, 'terrain');
assert.equal(
world.desc.source.desc.url,
'https://assets.ion.cesium.com/asset_depot/1/CesiumWorldTerrain/v1.2/{z}/{x}/{y}.terrain?v=1.2.0',
);
assert.equal(world.desc.source.desc.token, 'endpoint-token');
});
test('a newer terrain request wins over an older one still resolving', async () => {
const view = fakeView();
let answer;
const { maps } = createNavaraMaps(view, {
fetch: () => new Promise((resolve) => (answer = resolve)),
});
const world = maps.setTerrain({ kind: 'world', accessToken: 't' });
await maps.setTerrain({ kind: 'keyless' });
answer({ ok: true, json: async () => ({ url: 'https://x/', accessToken: 'y' }) });
await world;
const [terrain] = layersOf(view, 'terrain');
assert.match(terrain.desc.source.desc.url, /reearth/);
assert.equal(layersOf(view, 'terrain').length, 1);
});
test('setPhotorealTiles shows Google 3D tiles with a browser key and the Google credit', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
await maps.setBaseImagery({ kind: 'osm' });
await maps.setPhotorealTiles({ googleApiKey: ' browser key ' });
const [tiles] = layersOf(view, '3d-tiles');
assert.equal(
tiles.desc.source.desc.url,
'https://tile.googleapis.com/v1/3dtiles/root.json?key=browser%20key',
);
assert.deepEqual(tiles.desc.model, { maxSse: 16 });
assert.equal(layersOf(view, 'raster').length, 0, 'base imagery removed');
const google = view.shown.find((item) => item.creditLayerId === tiles.id);
assert.ok(google?.logo, 'no Google logo');
assert.equal(maps.photorealActive(), true);
await maps.setPhotorealTiles(null);
assert.equal(maps.photorealActive(), false);
assert.equal(layersOf(view, '3d-tiles').length, 0);
assert.ok(!view.shown.some((item) => item.creditLayerId));
});
test('photoreal without a browser key rejects and changes nothing', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
await assert.rejects(
maps.setPhotorealTiles({ descriptor: { kind: 'photoreal' }, tileset: {} }),
/browser key/,
);
assert.equal(maps.photorealActive(), false);
assert.equal(view.layers.length, 0);
});
test('destroy removes every layer, source and credit it added', async () => {
const view = fakeView();
const { maps, destroy } = createNavaraMaps(view);
await maps.setBaseImagery({ kind: 'osm' });
await maps.setTerrain({ kind: 'keyless' });
await maps.setPhotorealTiles({ googleApiKey: 'k' });
destroy();
assert.deepEqual(view.layers, []);
assert.deepEqual(view.sources, []);
assert.deepEqual(view.shown, []);
});
+185
View File
@@ -0,0 +1,185 @@
// GlobeSurface on Navara (S2): the height of the rendered scene is a CPU ray
// straight down the ellipsoid normal against the 3D-tile meshes Navara shows.
// Tile positions are float32 relative to the tile centre, which sits in the
// float64 matrixWorld with no rotation, so a ray taken into a mesh's local
// frame gives Earth-fixed metres. Navara frees the CPU arrays after upload;
// retainTileGeometry (internals.js) keeps positions and indices. Without a
// tile hit, the answer is Navara's terrain height, as Cesium's sampleHeight
// also samples the globe. Navara internals come in through `hooks`, so this
// module stays testable on plain three objects.
import {
BackSide,
DoubleSide,
FrontSide,
Matrix4,
Ray,
Sphere,
Vector3,
} from 'three';
import * as geo from '../../geo/index.js';
const { cartesian3, cartographic, math } = geo;
const WGS84 = geo.ellipsoid.WGS84;
// Above every rendered surface (Everest is 8.8 km) and down to below the
// lowest one (Dead Sea, about −430 m plus the geoid).
const RAY_START_HEIGHT = 12000;
const RAY_BELOW_ELLIPSOID = 1000;
/** The downward ray at a place: every point on it shares its longitude and latitude. */
export function surfaceRay(place) {
const origin = cartesian3.fromRadians(
place.longitude,
place.latitude,
RAY_START_HEIGHT,
WGS84,
);
const normal = WGS84.geodeticSurfaceNormal(origin, cartesian3.create());
return {
origin,
direction: cartesian3.negate(normal, cartesian3.create()),
far: RAY_START_HEIGHT + RAY_BELOW_ELLIPSOID,
};
}
/** Height above the ellipsoid of a hit `distance` metres down a surfaceRay. */
export const heightAlongRay = (ray, distance) =>
Number.isFinite(distance) ? RAY_START_HEIGHT - distance : undefined;
const worldRay = new Ray();
const localRay = new Ray();
const inverse = new Matrix4();
const sphere = new Sphere();
const entry = new Vector3();
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
const point = new Vector3();
// Meshes with CPU positions under the shown tile roots, minus `exclude`.
function tileMeshes(roots, exclude) {
const meshes = [];
const stack = roots.filter((root) => root.visible && !exclude.has(root));
while (stack.length) {
const object = stack.pop();
if (object.isMesh && object.geometry?.attributes?.position?.array)
meshes.push(object);
for (const child of object.children)
if (child.visible && !exclude.has(child)) stack.push(child);
}
return meshes;
}
// Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that
// interpolates UVs and normals, whose arrays Navara has released.
function meshHitDistance(mesh, far) {
const geometry = mesh.geometry;
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
if (!worldRay.intersectsSphere(sphere)) return Infinity;
inverse.copy(mesh.matrixWorld).invert();
localRay.copy(worldRay).applyMatrix4(inverse);
if (!geometry.boundingBox) geometry.computeBoundingBox();
if (!localRay.intersectsBox(geometry.boundingBox)) return Infinity;
const material = Array.isArray(mesh.material)
? mesh.material[0]
: mesh.material;
const side = material?.side ?? FrontSide;
const cull = side !== DoubleSide;
const flip = side === BackSide;
const position = geometry.attributes.position;
const index = geometry.index?.array ?? null;
const count = index ? index.length : position.count;
const start = Math.max(0, geometry.drawRange.start);
const end = Math.min(count, start + geometry.drawRange.count);
let nearest = Infinity;
for (let i = start; i + 2 < end; i += 3) {
a.fromBufferAttribute(position, index ? index[i] : i);
b.fromBufferAttribute(position, index ? index[i + 1] : i + 1);
c.fromBufferAttribute(position, index ? index[i + 2] : i + 2);
const hit = flip
? localRay.intersectTriangle(c, b, a, cull, point)
: localRay.intersectTriangle(a, b, c, cull, point);
if (!hit) continue;
point.applyMatrix4(mesh.matrixWorld);
const distance = worldRay.origin.distanceTo(point);
if (distance < nearest && distance <= far) nearest = distance;
}
return nearest;
}
// Nearest hit over meshes, tried in the order the ray enters their bounding
// spheres; stops once the next sphere starts below the best hit.
function nearestHit(meshes, ray) {
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
const candidates = [];
for (const mesh of meshes) {
const geometry = mesh.geometry;
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
if (!worldRay.intersectSphere(sphere, entry)) continue;
candidates.push([worldRay.origin.distanceTo(entry), mesh]);
}
candidates.sort((x, y) => x[0] - y[0]);
let nearest = Infinity;
for (const [enter, mesh] of candidates) {
if (enter > nearest) break;
nearest = Math.min(nearest, meshHitDistance(mesh, ray.far));
}
return nearest;
}
/**
* @param {{ sampleTerrainHeight: (place: { lat: number, lng: number }) => number | undefined }} view
* @param {{
* tileRoots: () => object[],
* isIdle: () => boolean,
* retainTileGeometry: () => void,
* }} hooks Navara internals (internals.js).
* @returns {{ surface: import('../contract.js').GlobeSurface }}
*/
export function createNavaraSurface(view, hooks) {
let supported = true;
try {
hooks.retainTileGeometry();
} catch (error) {
supported = false;
console.warn('[globe] Navara tile heights are unavailable:', error);
}
const terrainHeight = (place) => {
const height = view.sampleTerrainHeight({
lat: math.toDegrees(place.latitude),
lng: math.toDegrees(place.longitude),
});
return Number.isFinite(height) ? height : undefined;
};
const sampleHeight = (place, exclude) => {
if (!supported) return undefined;
const ray = surfaceRay(place);
const meshes = tileMeshes(hooks.tileRoots(), new Set(exclude ?? []));
return heightAlongRay(ray, nearestHit(meshes, ray)) ?? terrainHeight(place);
};
return {
surface: {
sampleHeight,
supported,
terrainHeight,
terrainHeightSupported: true,
clampToHeight(position, exclude) {
const place = cartographic.fromCartesian(position, WGS84);
if (!place) return undefined;
const height = sampleHeight(place, exclude);
if (height === undefined) return undefined;
return cartesian3.fromRadians(
place.longitude,
place.latitude,
height,
WGS84,
);
},
clampToHeightSupported: supported,
tilesLoaded: () => hooks.isIdle(),
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
BufferAttribute,
BufferGeometry,
DoubleSide,
Group,
Mesh,
MeshBasicMaterial,
} from 'three';
import * as geo from '../../geo/index.js';
import { createNavaraSurface, heightAlongRay, surfaceRay } from './surface.js';
const { cartesian3, cartographic, math } = geo;
const WGS84 = geo.ellipsoid.WGS84;
const near = (actual, expected, tolerance, label) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
// A tile as Navara loads it: float32 positions relative to the tile centre,
// which sits in the mesh's matrixWorld with an identity rotation. The square
// lies `height` metres up at (lon, lat), `half` metres to each side.
function tileAt(lon, lat, height, half = 100) {
const centre = cartesian3.fromDegrees(lon, lat, height);
const frame = geo.transforms.eastNorthUpToFixedFrame(centre, WGS84);
const corner = (e, n) => [
frame[0] * e + frame[4] * n,
frame[1] * e + frame[5] * n,
frame[2] * e + frame[6] * n,
];
const geometry = new BufferGeometry();
geometry.setAttribute(
'position',
new BufferAttribute(
new Float32Array([
...corner(-half, -half),
...corner(half, -half),
...corner(half, half),
...corner(-half, half),
]),
3,
),
);
geometry.setIndex([0, 1, 2, 0, 2, 3]);
const mesh = new Mesh(geometry, new MeshBasicMaterial({ side: DoubleSide }));
const tile = new Group();
tile.position.set(centre.x, centre.y, centre.z);
tile.add(mesh);
tile.updateMatrixWorld(true);
return { tile, mesh };
}
function setup({ tiles = [], terrain = () => undefined, idle = true, retain } = {}) {
const view = {
sampleTerrainHeight: (place) => terrain(place),
};
let retained = 0;
const surface = createNavaraSurface(view, {
tileRoots: () => tiles,
isIdle: () => idle,
retainTileGeometry:
retain ??
(() => {
retained++;
}),
});
return { surface: surface.surface, retained: () => retained };
}
test('surfaceRay runs down the ellipsoid normal from 12 km', () => {
const ray = surfaceRay(cartographic.fromDegrees(20, 10, 0));
const start = cartographic.fromCartesian(ray.origin, WGS84);
near(start.height, 12000, 1e-6, 'start height');
const below = cartesian3.add(
ray.origin,
cartesian3.multiplyByScalar(ray.direction, 12000, cartesian3.create()),
cartesian3.create(),
);
near(cartographic.fromCartesian(below, WGS84).height, 0, 1e-6, 'ground');
assert.equal(heightAlongRay(ray, 11950), 50);
assert.equal(heightAlongRay(ray, null), undefined);
});
test('sampleHeight hits the top of the loaded tiles and keeps tile geometry', () => {
const low = tileAt(-73.98, 40.75, 12);
const high = tileAt(-73.98, 40.75, 380, 20);
const { surface, retained } = setup({ tiles: [low.tile, high.tile] });
assert.equal(retained(), 1);
assert.equal(surface.supported, true);
near(
surface.sampleHeight(cartographic.fromDegrees(-73.98, 40.75, 0)),
380,
0.05,
'roof',
);
near(
surface.sampleHeight(cartographic.fromDegrees(-73.9795, 40.75, 0)),
12,
0.05,
'street beside it',
);
});
test('excluded and hidden objects are skipped; terrain is the fallback', () => {
const roof = tileAt(10, 45, 300, 20);
const ground = tileAt(10, 45, 40);
const { surface } = setup({
tiles: [roof.tile, ground.tile],
terrain: ({ lat, lng }) => (lat === 45 && lng === 10 ? 7 : undefined),
});
const place = cartographic.fromDegrees(10, 45, 0);
near(surface.sampleHeight(place, [roof.mesh]), 40, 0.05, 'excluded roof');
ground.tile.visible = false;
near(surface.sampleHeight(place, [roof.tile]), 7, 1e-9, 'terrain fallback');
assert.equal(
surface.sampleHeight(cartographic.fromDegrees(11, 46, 0)),
undefined,
);
});
test('terrainHeight reads Navara terrain in degrees; clampToHeight moves along the normal', () => {
const ground = tileAt(5, 50, 120);
const seen = [];
const { surface } = setup({
tiles: [ground.tile],
terrain: (place) => {
seen.push(place);
return 95;
},
});
assert.equal(surface.terrainHeightSupported, true);
assert.equal(surface.terrainHeight(cartographic.fromDegrees(5, 50, 0)), 95);
near(seen[0].lat, 50, 1e-12, 'lat');
near(seen[0].lng, 5, 1e-12, 'lng');
assert.equal(surface.clampToHeightSupported, true);
const clamped = surface.clampToHeight(cartesian3.fromDegrees(5, 50, 5000));
const place = cartographic.fromCartesian(clamped, WGS84);
near(place.height, 120, 0.05, 'height');
near(math.toDegrees(place.longitude), 5, 1e-9, 'longitude');
});
test('tilesLoaded follows Navara idle; a failed geometry patch means unsupported', () => {
assert.equal(setup({ idle: false }).surface.tilesLoaded(), false);
assert.equal(setup({ idle: true }).surface.tilesLoaded(), true);
const { surface } = setup({
retain: () => {
throw new Error('ModelMesh._releaseGeometryArrays not found');
},
});
assert.equal(surface.supported, false);
assert.equal(surface.clampToHeightSupported, false);
});
+53
View File
@@ -0,0 +1,53 @@
// How the product builds its Navara view (S4, S5): render on demand (the
// engine's render.setContinuous turns animation on), MSAA 4 as Cesium's
// msaaSamples, the device pixel ratio unless a measurement pins one, a canvas
// whose WebGL2 context keeps its drawing buffer (Cesium's
// contextOptions.webgl.preserveDrawingBuffer, for captures), and the
// `minimal` look: sky, stars, sky light and sun without post effects —
// Navara's photoreal chain costs about 5 ms a frame and greys the Google
// tiles with tone mapping.
/** WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer. */
export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({
alpha: true,
antialias: true,
depth: true,
stencil: true,
premultipliedAlpha: true,
preserveDrawingBuffer: true,
powerPreference: 'default',
});
const MSAA_SAMPLES = 4;
/** ThreeView options; `pixelRatio` only when a measurement pins it. */
export function navaraViewOptions({ container, canvas, pixelRatio } = {}) {
return {
container,
...(canvas ? { canvas } : {}),
animation: false,
multisampling: MSAA_SAMPLES,
...(pixelRatio !== undefined ? { pixelRatio } : {}),
};
}
const PLANS = {
minimal: { pluginDefault: false, effects: [] },
photoreal: { pluginDefault: true, effects: [] },
};
/**
* What the view adds for a look: descriptor names, or the default plugin's
* own addDefaultPhotorealScene (sky, sun, aerial perspective, lens flare,
* tone mapping, SMAA).
*/
export function navaraScenePlan(effects = 'minimal') {
if (!Object.hasOwn(PLANS, effects))
throw new RangeError(`Unknown Navara effects: ${effects}`);
return {
pluginDefault: PLANS[effects].pluginDefault,
meshes: ['sky', 'stars'],
lights: ['skyLightProbe', 'sun'],
effects: [...PLANS[effects].effects],
};
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NAVARA_CONTEXT_ATTRIBUTES,
navaraScenePlan,
navaraViewOptions,
} from './viewOptions.js';
test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => {
const container = { id: 'globe' };
const canvas = { id: 'canvas' };
assert.deepEqual(navaraViewOptions({ container, canvas }), {
container,
canvas,
animation: false,
multisampling: 4,
});
assert.equal(
navaraViewOptions({ container, pixelRatio: 1 }).pixelRatio,
1,
'measurements pin the pixel ratio',
);
});
test('the canvas context keeps its drawing buffer, with the attributes Navara asks for', () => {
assert.deepEqual(NAVARA_CONTEXT_ATTRIBUTES, {
alpha: true,
antialias: true,
depth: true,
stencil: true,
premultipliedAlpha: true,
preserveDrawingBuffer: true,
powerPreference: 'default',
});
});
test('effects=minimal is the default look: sky, stars and sun light, no post effects (S5)', () => {
assert.deepEqual(navaraScenePlan(), {
pluginDefault: false,
meshes: ['sky', 'stars'],
lights: ['skyLightProbe', 'sun'],
effects: [],
});
assert.equal(navaraScenePlan('photoreal').pluginDefault, true);
assert.throws(() => navaraScenePlan('cinematic'), RangeError);
});