mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
378 lines
17 KiB
JavaScript
378 lines
17 KiB
JavaScript
/**
|
||
* Shared icon-orientation + horizon helpers. Orientation and culling serve the
|
||
* moving-entity layers (commercial flights, military flights, AIS vessels);
|
||
* `skyBackdropFactor` serves the detection overlay, which needs the same
|
||
* silhouette geometry to know what a label is being read against.
|
||
*
|
||
* THE ORIENTATION PROBLEM (2026-06-10 playtest): billboards are camera-facing
|
||
* quads, so "point along the real-world course" must be computed in SCREEN
|
||
* space. The previous approach mixed two regimes (surface-normal alignedAxis
|
||
* at oblique pitch, camera-heading compensation at nadir) and broke in
|
||
* tracked-entity orbit mode, where camera.heading is expressed in the
|
||
* entity's reference frame — the tracked icon stayed glued to the viewport.
|
||
*
|
||
* THE FIX: transform the local course vector into world space, then project it
|
||
* directly onto the camera's right/up basis. This stays valid when a forward
|
||
* probe point would be off-screen or behind the camera during a >180° tracked
|
||
* orbit. The camera-basis projection is exact at the viewport center and an
|
||
* intentional orthographic approximation elsewhere: an off-center contact at
|
||
* oblique pitch can diverge from a pinhole/window-space projection because the
|
||
* latter also includes the course vector's depth component. The approximation
|
||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||
* unverified math swap, decides whether exact projection becomes preferred.
|
||
*/
|
||
import * as Cesium from 'cesium';
|
||
|
||
/** Meters used to give the course vector a stable projection magnitude. */
|
||
const FORWARD_PROBE_M = 2000;
|
||
/**
|
||
* Minimum camera-plane component before we trust the angle — below this the
|
||
* course points almost exactly into/out of the screen and the angle is noise.
|
||
*/
|
||
const MIN_SCREEN_COMPONENT_M = 0.5;
|
||
/** Ignore sub-degree projection noise while retaining deliberate camera-orbit rotation. */
|
||
const ROTATION_DEADBAND_RAD = Cesium.Math.toRadians(0.5);
|
||
|
||
const _scratchEnu = new Cesium.Matrix4();
|
||
const _scratchForward = new Cesium.Cartesian3();
|
||
const _scratchWorldForward = new Cesium.Cartesian3();
|
||
|
||
/**
|
||
* Computes the billboard rotation (radians, CCW-positive, for
|
||
* alignedAxis = Cartesian3.ZERO) that points an icon along its real-world
|
||
* course in the stable camera-basis approximation described above.
|
||
*
|
||
* @param {Cesium.Scene} scene - The scene (projection source).
|
||
* @param {Cesium.Cartesian3} position - Entity world position.
|
||
* @param {number} courseDeg - Course/track in degrees clockwise from north.
|
||
* @param {number|null} previous - Rotation to keep when projection is
|
||
* unavailable (off-screen/behind camera/degenerate).
|
||
* @returns {number|null} Rotation in radians, or `previous` when unknown.
|
||
*/
|
||
export function screenProjectedRotation(
|
||
scene,
|
||
position,
|
||
courseDeg,
|
||
previous = null,
|
||
) {
|
||
const camera = scene?.camera;
|
||
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
|
||
|
||
const courseRad = Cesium.Math.toRadians(courseDeg || 0);
|
||
Cesium.Cartesian3.fromElements(
|
||
Math.sin(courseRad) * FORWARD_PROBE_M,
|
||
Math.cos(courseRad) * FORWARD_PROBE_M,
|
||
0,
|
||
_scratchForward,
|
||
);
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||
position,
|
||
Cesium.Ellipsoid.WGS84,
|
||
_scratchEnu,
|
||
);
|
||
Cesium.Matrix4.multiplyByPointAsVector(
|
||
enu,
|
||
_scratchForward,
|
||
_scratchWorldForward,
|
||
);
|
||
|
||
// Screen x follows camera-right. Window y grows downward, the opposite of
|
||
// camera-up. Projecting the vector itself avoids clipping/behind-camera
|
||
// failure modes from the old forward-point worldToWindowCoordinates probe.
|
||
const dx = Cesium.Cartesian3.dot(_scratchWorldForward, camera.rightWC);
|
||
const dy = -Cesium.Cartesian3.dot(_scratchWorldForward, camera.upWC);
|
||
if (dx * dx + dy * dy < MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M)
|
||
return previous;
|
||
|
||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||
// Icon direction in window coords after CCW rotation r is (-sin r, -cos r),
|
||
// so matching the projected course (dx, dy) gives r = atan2(-dx, -dy).
|
||
return Math.atan2(-dx, -dy);
|
||
}
|
||
|
||
/**
|
||
* Hold a billboard rotation when the newly projected angle differs only by
|
||
* sub-degree render noise. The comparison uses the shortest wrapped arc so
|
||
* values around ±π do not jump.
|
||
*
|
||
* @param {number|null} previous Last displayed rotation.
|
||
* @param {number|null} next Newly projected rotation.
|
||
* @param {number} [deadbandRad=ROTATION_DEADBAND_RAD] Angular hold threshold.
|
||
* @returns {number|null} Stable rotation.
|
||
*/
|
||
export function stabilizeScreenRotation(
|
||
previous,
|
||
next,
|
||
deadbandRad = ROTATION_DEADBAND_RAD,
|
||
) {
|
||
if (!Number.isFinite(next))
|
||
return Number.isFinite(previous) ? previous : null;
|
||
if (!Number.isFinite(previous)) return next;
|
||
const delta = Math.atan2(
|
||
Math.sin(next - previous),
|
||
Math.cos(next - previous),
|
||
);
|
||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||
}
|
||
|
||
const _occluder = new Cesium.EllipsoidalOccluder(
|
||
Cesium.Ellipsoid.WGS84,
|
||
new Cesium.Cartesian3(),
|
||
);
|
||
|
||
/**
|
||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||
* radians. ~1.1° per side: at the product's default 60° frustum on a 1280×800
|
||
* viewport that is roughly 30 px of screen crossfade — wide enough that a
|
||
* contact drifting across the horizon never pops, narrow enough that a label
|
||
* plainly over ground still gets its full treatment.
|
||
*/
|
||
export const HORIZON_FEATHER_RAD = 0.019;
|
||
|
||
const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||
|
||
/**
|
||
* How much SKY sits behind a world point from this camera: 0 (planet behind)
|
||
* … 1 (sky behind), smoothly blended across a band centred on the horizon.
|
||
*
|
||
* THERE ARE TWO REGIMES AND THEY ANSWER DIFFERENT QUESTIONS. Read this before
|
||
* trusting the number for anything other than a plate alpha.
|
||
*
|
||
* ABOVE the ellipsoid the horizon is the tangent silhouette and the test is a
|
||
* genuine ray/ellipsoid hit test: 1 means the view ray through the point misses
|
||
* the planet entirely. Here the function is the exact complement of
|
||
* {@link horizonOccluder} — the occluder asks whether the planet is in FRONT of
|
||
* a point (cull it), this asks whether it is BEHIND (what a screen-space label
|
||
* anchored there is read against). For any point the occluder keeps, a ray that
|
||
* hits the ellipsoid must hit it beyond the point, so the hit/miss test alone
|
||
* settles the backdrop.
|
||
*
|
||
* AT OR BELOW the ellipsoid there is no tangent cone, and the test is NOT a ray
|
||
* hit test. It is an EYE-PLANE convention: 1 means the point sits above the
|
||
* camera's local geodetic horizontal. The two genuinely differ — from a camera
|
||
* 18 m under the surface, the ray to a contact 900 m up and a few km out
|
||
* CROSSES the ellipsoid on the way there, and the answer is still sky. That is
|
||
* deliberate. A sub-ellipsoid camera is an artifact of the geoid/ellipsoid
|
||
* split, not of being underground: the geoid runs ~34 m below the ellipsoid at
|
||
* JFK, so a cockpit parked on the ramp there sits at −18 m of ellipsoid height
|
||
* with the whole rendered world still above it. The ellipsoid that ray crosses
|
||
* is not a surface anyone can see, so intersecting it would answer a question
|
||
* nobody asked; what a label is read against is decided instead by whether it
|
||
* sits above or below the viewer's eye level. Cesium's `EllipsoidalOccluder`
|
||
* takes the same convention below the surface, so the two stay sign-consistent
|
||
* — the complementarity above survives as agreement here.
|
||
*
|
||
* The regimes MEET rather than being switched between: the horizon dip
|
||
* acos(R/(R+h)) falls to zero as the camera settles onto the surface, so the
|
||
* tangent cone opens continuously to the horizontal plane, and clamping the
|
||
* cone's sine at 1 (half-angle 90°) IS that limit — not a special case.
|
||
*
|
||
* Computed in scaled space, where WGS84 becomes a unit sphere. That map is
|
||
* affine, so both regimes stay exact: above the surface ray-surface
|
||
* intersection is preserved and the zero crossing is the true silhouette rather
|
||
* than a spherical stand-in; below it the eye plane is the true GEODETIC
|
||
* tangent plane, because the scaled radial maps back to (x/a², y/a², z/b²), the
|
||
* real surface normal, which at 40°N sits 0.19° off the geocentric radial. Only
|
||
* the magnitude of the margin picks up the flattening, and a feather band does
|
||
* not care about a third of a percent.
|
||
*
|
||
* Deliberately geometric, not photometric, in BOTH regimes: sampling rendered
|
||
* pixels behind every label would be correct about mountains and towers that
|
||
* rise above the horizon, and would also cost a readback per label per frame.
|
||
* The band absorbs that approximation — a contact just above the horizon lands
|
||
* mid-blend, which is the honest answer when the backdrop is part sky.
|
||
*
|
||
* @param {Cesium.Cartesian3} cameraPosition World camera position.
|
||
* @param {Cesium.Cartesian3} position World position of the labelled point.
|
||
* @param {number} [featherRad=HORIZON_FEATHER_RAD] Half-width of the blend band.
|
||
* @returns {number} 0 (ground behind) … 1 (sky behind).
|
||
*/
|
||
export function skyBackdropFactor(
|
||
cameraPosition,
|
||
position,
|
||
featherRad = HORIZON_FEATHER_RAD,
|
||
) {
|
||
if (!cameraPosition || !position) return 0;
|
||
|
||
const s = _wgs84OneOverRadii;
|
||
const cx = cameraPosition.x * s.x;
|
||
const cy = cameraPosition.y * s.y;
|
||
const cz = cameraPosition.z * s.z;
|
||
const cameraMag = Math.sqrt(cx * cx + cy * cy + cz * cz);
|
||
// Only the planet's exact centre (or a non-finite camera) has no local
|
||
// vertical to measure against. Everything else — including the sub-ellipsoid
|
||
// cameras that coastal ground level actually produces — gets a real answer.
|
||
// `> 0` already rejects NaN and negatives; the finite check also rejects an
|
||
// infinite coordinate, which would otherwise normalize to NaN below and slip
|
||
// a NaN plate alpha into the paint.
|
||
if (!(cameraMag > 0) || !Number.isFinite(cameraMag)) return 0;
|
||
|
||
let dx = position.x * s.x - cx;
|
||
let dy = position.y * s.y - cy;
|
||
let dz = position.z * s.z - cz;
|
||
const rayMag = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||
// Same two rejections on the ray: a zero-length one has no direction, and an
|
||
// infinite one normalizes to NaN.
|
||
if (!(rayMag > 0) || !Number.isFinite(rayMag)) return 0;
|
||
dx /= rayMag;
|
||
dy /= rayMag;
|
||
dz /= rayMag;
|
||
|
||
// Angle between the view ray and the direction to the planet's centre,
|
||
// against the half-angle of the tangent cone. Inside the cone the ray strikes
|
||
// the planet; outside it, the ray escapes to sky.
|
||
const cosToCentre = -(cx * dx + cy * dy + cz * dz) / cameraMag;
|
||
const rayAngle = Math.acos(Math.min(1, Math.max(-1, cosToCentre)));
|
||
// The clamp is the eye-level case described above, reached as a limit rather
|
||
// than as a special case: at and under the surface the cone has opened to the
|
||
// full 90° half-angle and the test reads "above or below local horizontal".
|
||
const horizonHalfAngle = Math.asin(Math.min(1, 1 / cameraMag));
|
||
const margin = rayAngle - horizonHalfAngle;
|
||
|
||
if (!(featherRad > 0)) return margin > 0 ? 1 : 0;
|
||
const t = Math.min(1, Math.max(0, margin / (2 * featherRad) + 0.5));
|
||
// Smoothstep: C1-continuous, so a contact crossing the horizon fades rather
|
||
// than steps, and the fade has no visible corner at either edge of the band.
|
||
return t * t * (3 - 2 * t);
|
||
}
|
||
|
||
/**
|
||
* Returns the shared horizon occluder, updated to the camera's position.
|
||
* With the Cesium globe hidden (Google 3D tiles provide the planet) nothing
|
||
* writes far-side depth, so billboards must be horizon-culled manually.
|
||
* Call once per tick, then test points with occluder.isPointVisible(pos).
|
||
*
|
||
* @param {Cesium.Camera} camera - The scene camera.
|
||
* @returns {Cesium.EllipsoidalOccluder}
|
||
*/
|
||
export function horizonOccluder(camera) {
|
||
_occluder.cameraPosition = camera.positionWC;
|
||
return _occluder;
|
||
}
|
||
|
||
/**
|
||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||
* passes (position quantized to ~10m, angles to ~0.06 deg).
|
||
* @param {Cesium.Camera} camera - The scene camera.
|
||
* @returns {string}
|
||
*/
|
||
export function cameraPoseSignature(camera) {
|
||
const p = camera.positionWC;
|
||
return (
|
||
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Meters ahead used by the exact projection below. Short on purpose: the point
|
||
* of a perspective projection is that it is LOCAL to the contact, and a two
|
||
* kilometre probe on a bus at six hundred metres would sample a part of the
|
||
* frustum the vehicle is nowhere near.
|
||
*/
|
||
const NEAR_PROBE_M = 30;
|
||
const _scratchProbe = new Cesium.Cartesian3();
|
||
const _scratchWindowAt = new Cesium.Cartesian2();
|
||
const _scratchWindowAhead = new Cesium.Cartesian2();
|
||
|
||
/**
|
||
* Billboard rotation for a SURFACE contact, projected exactly — perspective
|
||
* division included — instead of through the camera-basis approximation.
|
||
*
|
||
* `screenProjectedRotation` above deliberately projects the course vector onto
|
||
* the camera's right/up basis, which is orthographic: exact at the viewport
|
||
* centre and an approximation away from it. That is the right trade for
|
||
* aircraft, where the alternative loses continuity through a tracked orbit and
|
||
* the contacts are far enough away for the error to be small.
|
||
*
|
||
* It is the wrong trade for street-level vehicles. A probe with a real Cesium
|
||
* camera at 600 m and −45° pitch, on a northbound contact 350 m right of
|
||
* centre, put the orthographic answer at 0° where the true screen direction of
|
||
* travel was 22.5°. At that pitch and that range the vehicles fill the frame
|
||
* away from the centre, and every one of them points visibly wrong.
|
||
*
|
||
* So this projects two nearby world points through the actual view-projection
|
||
* and reads the angle between them on screen. When either point cannot be
|
||
* projected — behind the camera, off the frustum, degenerate — it falls back to
|
||
* the camera-basis answer rather than inventing one.
|
||
*
|
||
* @param {Cesium.Scene} scene The scene (projection source).
|
||
* @param {Cesium.Cartesian3} position Entity world position.
|
||
* @param {number} courseDeg Course in degrees clockwise from north.
|
||
* @param {number|null} previous Rotation to keep when projection is unavailable.
|
||
* @returns {number|null} Rotation in radians, or the fallback.
|
||
*/
|
||
export function perspectiveProjectedRotation(
|
||
scene,
|
||
position,
|
||
courseDeg,
|
||
previous = null,
|
||
) {
|
||
if (!scene?.camera || !position || !Number.isFinite(courseDeg)) {
|
||
return previous;
|
||
}
|
||
const courseRad = Cesium.Math.toRadians(courseDeg);
|
||
Cesium.Cartesian3.fromElements(
|
||
Math.sin(courseRad) * NEAR_PROBE_M,
|
||
Math.cos(courseRad) * NEAR_PROBE_M,
|
||
0,
|
||
_scratchForward,
|
||
);
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||
position,
|
||
Cesium.Ellipsoid.WGS84,
|
||
_scratchEnu,
|
||
);
|
||
Cesium.Matrix4.multiplyByPointAsVector(
|
||
enu,
|
||
_scratchForward,
|
||
_scratchWorldForward,
|
||
);
|
||
Cesium.Cartesian3.add(position, _scratchWorldForward, _scratchProbe);
|
||
|
||
const at = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||
scene,
|
||
position,
|
||
_scratchWindowAt,
|
||
);
|
||
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||
scene,
|
||
_scratchProbe,
|
||
_scratchWindowAhead,
|
||
);
|
||
if (!at || !ahead) {
|
||
return screenProjectedRotation(scene, position, courseDeg, previous);
|
||
}
|
||
const dx = ahead.x - at.x;
|
||
const dy = ahead.y - at.y;
|
||
const separation = Math.hypot(dx, dy);
|
||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||
const exact = Math.atan2(-dx, -dy);
|
||
if (separation >= PERSPECTIVE_BLEND_MAX_PX) return exact;
|
||
// Below a pixel or so of separation the exact answer is dominated by
|
||
// projection noise, and above it the camera-basis answer is the wrong one
|
||
// by up to twenty-odd degrees. A hard cutoff between the two flipped a
|
||
// sprite by 23° for a 20 m change of camera altitude; the two answers are
|
||
// blended across the band instead, the short way round, so the rotation is
|
||
// continuous through it.
|
||
const basis = screenProjectedRotation(scene, position, courseDeg, previous);
|
||
if (!Number.isFinite(basis)) return exact;
|
||
if (separation <= PERSPECTIVE_BLEND_MIN_PX) return basis;
|
||
const weight =
|
||
(separation - PERSPECTIVE_BLEND_MIN_PX) /
|
||
(PERSPECTIVE_BLEND_MAX_PX - PERSPECTIVE_BLEND_MIN_PX);
|
||
const delta = Math.atan2(Math.sin(exact - basis), Math.cos(exact - basis));
|
||
return basis + delta * weight;
|
||
}
|
||
|
||
/**
|
||
* Probe separations (px) between which the exact and camera-basis answers
|
||
* are blended. At or below the floor the camera-basis answer is used alone;
|
||
* at or above the ceiling the exact one.
|
||
*/
|
||
export const PERSPECTIVE_BLEND_MIN_PX = 0.5;
|
||
export const PERSPECTIVE_BLEND_MAX_PX = 3;
|
||
|
||
/** Exported for tests: how far ahead the exact projection probes. */
|
||
export const PERSPECTIVE_PROBE_M = NEAR_PROBE_M;
|