diff --git a/src/globe/navara/cameraMath.js b/src/globe/navara/cameraMath.js new file mode 100644 index 000000000..a6911d61a --- /dev/null +++ b/src/globe/navara/cameraMath.js @@ -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, + ); +} diff --git a/src/globe/navara/cameraMath.test.mjs b/src/globe/navara/cameraMath.test.mjs new file mode 100644 index 000000000..343de7a47 --- /dev/null +++ b/src/globe/navara/cameraMath.test.mjs @@ -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', + ); +}); diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js new file mode 100644 index 000000000..25202fb28 --- /dev/null +++ b/src/globe/navara/cameraModel.js @@ -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, + * 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; + }, + }; +} diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs new file mode 100644 index 000000000..d7d9cb266 --- /dev/null +++ b/src/globe/navara/cameraModel.test.mjs @@ -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'); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js new file mode 100644 index 000000000..c80fc132f --- /dev/null +++ b/src/globe/navara/createNavaraEngine.js @@ -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(); + }, + }; +} diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs new file mode 100644 index 000000000..f2a557b32 --- /dev/null +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -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}`, + ); +} diff --git a/src/globe/navara/credits.js b/src/globe/navara/credits.js new file mode 100644 index 000000000..1b68cff96 --- /dev/null +++ b/src/globe/navara/credits.js @@ -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); + } + }, + }; +} diff --git a/src/globe/navara/credits.test.mjs b/src/globe/navara/credits.test.mjs new file mode 100644 index 000000000..1d6f3e7eb --- /dev/null +++ b/src/globe/navara/credits.test.mjs @@ -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('X', { onScreen: true }); + assert.deepEqual(attribution.shown, [{ attributionHtml: 'X' }]); + 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(); +}); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js new file mode 100644 index 000000000..24c2d738f --- /dev/null +++ b/src/globe/navara/index.js @@ -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(); + }, + }; +} diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js new file mode 100644 index 000000000..0bb41f1c3 --- /dev/null +++ b/src/globe/navara/input.js @@ -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(); + }, + }; +} diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs new file mode 100644 index 000000000..d58a1599d --- /dev/null +++ b/src/globe/navara/input.test.mjs @@ -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); +}); diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js new file mode 100644 index 000000000..f79ed824d --- /dev/null +++ b/src/globe/navara/internals.js @@ -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), +}); diff --git a/src/globe/navara/internals.test.mjs b/src/globe/navara/internals.test.mjs new file mode 100644 index 000000000..1430ad365 --- /dev/null +++ b/src/globe/navara/internals.test.mjs @@ -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, []); +}); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js new file mode 100644 index 000000000..ec59027f1 --- /dev/null +++ b/src/globe/navara/maps.js @@ -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: + 'Powered by Esri', +}; +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); + }, + }; +} diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs new file mode 100644 index 000000000..3edec6405 --- /dev/null +++ b/src/globe/navara/maps.test.mjs @@ -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: 'Powered by Esri', + }); + 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: 'Powered by Esri' }, + ]); + 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, []); +}); diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js new file mode 100644 index 000000000..0aaf692ec --- /dev/null +++ b/src/globe/navara/surface.js @@ -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(), + }, + }; +} diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs new file mode 100644 index 000000000..75178a093 --- /dev/null +++ b/src/globe/navara/surface.test.mjs @@ -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); +}); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js new file mode 100644 index 000000000..4c23653c4 --- /dev/null +++ b/src/globe/navara/viewOptions.js @@ -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], + }; +} diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs new file mode 100644 index 000000000..30a9841d4 --- /dev/null +++ b/src/globe/navara/viewOptions.test.mjs @@ -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); +});