Files
gods-eye-view/src/annotations/worldAnnotationRenderer.js
T
Bilawal Sidhuandspcpza d78696dc17 feat(directions): keyless A→B directions with turn-by-turn steps and FLY (#564)
Supersedes #219 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.

Co-authored-by: spcpza <[email protected]>
2026-09-14 20:09:41 -05:00

449 lines
19 KiB
JavaScript

import * as Cesium from 'cesium';
/**
* World-space annotation renderer (Direction A).
*
* Draws annotations as native Cesium entities anchored to lon/lat. Because they
* live in the 3D scene they track the camera, sit at the right depth, and are
* occluded by the photoreal buildings the way a real marker would be.
*
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
* sitting ON the world instead of buried at sea level.
*
* Live alpha (fade in/out) and pulsing are driven by CallbackProperty so they
* animate every render without the engine touching them per frame.
*
* Renderer contract (shared with the screen-space renderer):
* add(anno) / remove(anno) / sync(map) / destroy()
*/
const PALETTE = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
/**
* What a draped mark is allowed to paint onto.
*
* This was CESIUM_3D_TILE, which is right with Google's photoreal tiles under
* the camera and wrong everywhere else: on a keyless boot (Esri or OSM imagery
* on Cesium's own globe) there are no 3D tiles to classify against, so every
* area fill, area outline, route and arrow rendered to nothing. The labels
* showed and the geometry did not, which reads as a broken feature rather than
* an unsupported surface — and it hit spoken annotations exactly as hard as
* hand-drawn ones.
*
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
*/
const CLASSIFY = Cesium.ClassificationType.BOTH;
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
// material the route pipeline renders. The animated `time` uniform is read straight
// from performance.now() inside FlowMaterialProperty.getValue (which Cesium calls each
// rendered frame with the live uniforms object), so the dashes flow with no extra
// per-frame bookkeeping.
ensureFlowFabricRegistered();
function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
}
// Target-ring radius (meters) scaled to camera height so it reads at any
// altitude. Read once per frame for BOTH ellipse axes — camera height is
// constant within a frame, so semiMajor === semiMinor always holds.
function ringRadius() {
const h = viewer.camera.positionCartographic?.height ?? 1000;
return Math.max(14, Math.min(170, h * 0.03));
}
// A live color that follows the annotation's fade alpha and an optional pulse.
function liveColor(anno, base, { alpha = 0.9, pulse = false } = {}) {
return new Cesium.CallbackProperty(() => {
const a = (anno.alpha ?? 1) * alpha * (pulse ? pulseFactor() : 1);
return base.withAlpha(Math.max(0, Math.min(1, a)));
}, false);
}
function add(anno) {
const base = colorFor(anno);
// Published to the mark BEFORE anything is added, and mutated in place as
// each entity lands: a mid-add failure (bad geometry, lost context) must
// leave the entities that DID land visible to remove(), or the rollback
// path cannot reach them and they stay on the globe forever.
const entities = [];
anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
// extrusion is only 2.5D and misses domes; classification colors the
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
},
}));
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}));
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
classificationType: CLASSIFY,
},
}));
entities.push(dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
// A real path (street-following) draped on the 3D tiles, with dashes that
// FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]),
);
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
const positions = [
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
];
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
if (anno.label) {
const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
};
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}));
}
} else {
// pin / highlight / label — a camera-proportional target ring + a marker.
if (anno.type !== 'label') {
// Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
outline: false,
classificationType: CLASSIFY,
},
}));
}
entities.push(labelMarker(anno, base, { point: true }));
}
}
// A clamped point + (optional) label that sits on the tile surface.
function labelMarker(anno, base, { point }) {
return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
label: anno.label ? labelGraphic(anno, base) : undefined,
});
}
function labelGraphic(anno, base) {
return {
text: anno.label,
font: '600 14px "Inter", system-ui, sans-serif',
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true,
backgroundColor: new Cesium.CallbackProperty(
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
false,
),
backgroundPadding: new Cesium.Cartesian2(8, 5),
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
};
}
function remove(anno) {
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
dataSource.entities.remove(entity);
} catch {
/* already gone */
}
}
anno._entities = null;
}
function sync() {
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
}
function destroy() {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* scene torn down */
}
}
return { add, remove, sync, destroy };
}
function pulseFactor() {
// 0.6 .. 1.0 sinusoid at ~0.8 Hz
return 0.8 + 0.2 * Math.sin(performance.now() * 0.005);
}
/**
* A custom Fabric polyline material whose dashes flow toward the destination.
* `materialInput.st.s` is the along-line coordinate (0 = origin, 1 = destination),
* so `fract(s*repeat - time*speed)` scrolls the pattern toward the end. Works on
* a clamped/classified ground polyline (PolylineMaterialAppearance supports it).
*/
function makeRouteFlowMaterial(colorCss) {
return new Cesium.Material({
fabric: {
type: 'GevRouteFlow',
uniforms: {
color: Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95),
time: 0.0,
repeat: 64.0, // dash cells along the whole route
duty: 0.46, // fraction of each cell that is "on"
speed: 0.55, // cells per second toward the destination
},
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material m = czm_getDefaultMaterial(materialInput);
float s = materialInput.st.s; // 0 origin -> 1 dest
float flow = fract(s * repeat - time * speed); // scroll toward dest
float on = smoothstep(duty + 0.08, duty - 0.08, flow);
// keep a faint baseline so the whole route stays readable between dashes
float a = max(on, 0.18);
m.diffuse = color.rgb;
m.emission = color.rgb * on * 0.9; // glow on the lit cells
m.alpha = color.a * a;
return m;
}`,
},
});
}
let _flowFabricRegistered = false;
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
* can build the material the render pipeline uses. Constructing one Material with the
* fabric caches it under its type name. */
export function ensureFlowFabricRegistered() {
if (_flowFabricRegistered) return;
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
_flowFabricRegistered = true;
}
/**
* MaterialProperty for the animated route. Cesium builds the rendered Material once
* from getType() (our registered GevRouteFlow fabric), then EACH FRAME calls
* getValue(time, material.uniforms) and uses whatever we write INTO that uniforms
* object — it ignores the return value. So getValue writes color/time/repeat/duty/
* speed straight onto `result` (the live uniforms). Writing the animated `time` here
* is what actually makes the dashes flow on the GPU. (The prior versions either
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
export function FlowMaterialProperty(colorCss) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
definitionChanged: { get() { return this._definitionChanged; } },
});
FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow';
};
FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
// `result` IS the live uniforms object Cesium renders — write into it directly.
// `time` is read straight from the wall clock so the dashes flow every rendered
// frame (the scene renders continuously; no requestRenderMode here).
if (!Cesium.defined(result)) result = {};
result.color = this._color;
result.time = performance.now() / 1000; // the per-frame animated value
result.repeat = 64.0;
result.duty = 0.46;
result.speed = 0.55;
return result;
};
FlowMaterialProperty.prototype.equals = function equals(other) {
return this === other;
};
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
function decimateRing(ring, n) {
if (ring.length <= n) return ring;
const out = [];
for (let i = 0; i < n; i++) out.push(ring[Math.floor((i * ring.length) / n)]);
return out;
}
/**
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
* the centroid would clamp onto the roof, so we probe several points beyond the
* footprint radius and take a low percentile (≈ ground). Returns null if the
* tiles under those points aren't loaded yet.
*/
function sampleGroundOutside(scene, ring) {
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
clon /= ring.length;
clat /= ring.length;
const latS = 111320;
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
let maxR = 0;
for (const [lon, lat] of ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
}
const out = maxR * 1.5 + 12;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
const samples = [];
for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS;
const lat = clat + (dy * out) / latS;
try {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch { /* tile not ready */ }
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * 0.3)];
}
/**
* Inflate a [[lon,lat],...] ring outward from its centroid by `meters`, so an
* extruded building volume encloses the photogrammetry mesh instead of slicing
* through its edges. Radial buffer — fine for compact building footprints.
*/
function bufferRing(ring, meters) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
clon /= ring.length;
clat /= ring.length;
const latScale = 111320;
const lonScale = latScale * Math.cos(Cesium.Math.toRadians(clat));
return ring.map(([lon, lat]) => {
const dx = (lon - clon) * lonScale;
const dy = (lat - clat) * latScale;
const d = Math.hypot(dx, dy) || 1;
const k = (d + meters) / d;
return [clon + (dx * k) / lonScale, clat + (dy * k) / latScale];
});
}